mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
fix(ai): preserve Anthropic refusal details (#5666)
Propagate Anthropic refusal stop_details explanation to errorMessage
This commit is contained in:
committed by
GitHub
Unverified
parent
1c24336560
commit
a455f62f72
@@ -5,6 +5,7 @@ import type {
|
||||
MessageCreateParamsStreaming,
|
||||
MessageParam,
|
||||
RawMessageStreamEvent,
|
||||
RefusalStopDetails,
|
||||
} from "@anthropic-ai/sdk/resources/messages.js";
|
||||
import { calculateCost } from "../models.ts";
|
||||
import type {
|
||||
@@ -660,7 +661,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
|
||||
}
|
||||
} else if (event.type === "message_delta") {
|
||||
if (event.delta.stop_reason) {
|
||||
output.stopReason = mapStopReason(event.delta.stop_reason);
|
||||
const stopReasonResult = mapStopReason(event.delta.stop_reason, event.delta.stop_details);
|
||||
output.stopReason = stopReasonResult.stopReason;
|
||||
if (stopReasonResult.errorMessage) {
|
||||
output.errorMessage = stopReasonResult.errorMessage;
|
||||
}
|
||||
}
|
||||
// Only update usage fields if present (not null).
|
||||
// Preserves input_tokens from message_start when proxies omit it in message_delta.
|
||||
@@ -688,7 +693,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
|
||||
}
|
||||
|
||||
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
||||
throw new Error("An unknown error occurred");
|
||||
throw new Error(output.errorMessage || "An unknown error occurred");
|
||||
}
|
||||
|
||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||
@@ -1202,22 +1207,28 @@ function convertTools(
|
||||
});
|
||||
}
|
||||
|
||||
function mapStopReason(reason: Anthropic.Messages.StopReason | string): StopReason {
|
||||
function mapStopReason(
|
||||
reason: Anthropic.Messages.StopReason | string,
|
||||
stopDetails?: RefusalStopDetails | null,
|
||||
): { stopReason: StopReason; errorMessage?: string } {
|
||||
switch (reason) {
|
||||
case "end_turn":
|
||||
return "stop";
|
||||
return { stopReason: "stop" };
|
||||
case "max_tokens":
|
||||
return "length";
|
||||
return { stopReason: "length" };
|
||||
case "tool_use":
|
||||
return "toolUse";
|
||||
return { stopReason: "toolUse" };
|
||||
case "refusal":
|
||||
return "error";
|
||||
return {
|
||||
stopReason: "error",
|
||||
errorMessage: stopDetails?.explanation || `The model refused to complete the request`,
|
||||
};
|
||||
case "pause_turn": // Stop is good enough -> resubmit
|
||||
return "stop";
|
||||
return { stopReason: "stop" };
|
||||
case "stop_sequence":
|
||||
return "stop"; // We don't supply stop sequences, so this should never happen
|
||||
return { stopReason: "stop" }; // We don't supply stop sequences, so this should never happen
|
||||
case "sensitive": // Content flagged by safety filters (not yet in SDK types)
|
||||
return "error";
|
||||
return { stopReason: "error" };
|
||||
default:
|
||||
// Handle unknown stop reasons gracefully (API may add new values)
|
||||
throw new Error(`Unhandled stop reason: ${reason}`);
|
||||
|
||||
@@ -166,6 +166,64 @@ describe("Anthropic raw SSE parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves refusal stop details from message_delta", async () => {
|
||||
const model = getModel("anthropic", "claude-fable-5");
|
||||
const context: Context = {
|
||||
messages: [{ role: "user", content: "blocked request", timestamp: Date.now() }],
|
||||
};
|
||||
const explanation =
|
||||
"This request triggered restrictions on violative cyber content and was blocked under Anthropic's Usage Policy. To learn more, provide feedback, or request an exemption based on how you use Claude, visit our help center: https://support.claude.com/en/articles/14604842-real-time-cyber-safeguards-on-claude.";
|
||||
const response = createSseResponse([
|
||||
{
|
||||
event: "message_start",
|
||||
data: JSON.stringify({
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_01XFUDYJgAACzvnptvVoYEL",
|
||||
usage: {
|
||||
input_tokens: 412,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
event: "message_delta",
|
||||
data: JSON.stringify({
|
||||
type: "message_delta",
|
||||
delta: {
|
||||
stop_reason: "refusal",
|
||||
stop_details: {
|
||||
type: "refusal",
|
||||
category: "cyber",
|
||||
explanation,
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
input_tokens: 412,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
event: "message_stop",
|
||||
data: JSON.stringify({ type: "message_stop" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const stream = streamAnthropic(model, context, {
|
||||
client: createFakeAnthropicClient(response),
|
||||
});
|
||||
const result = await stream.result();
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toBe(explanation);
|
||||
});
|
||||
|
||||
it("ignores unknown SSE events after message_stop", async () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
const context: Context = {
|
||||
|
||||
Reference in New Issue
Block a user