diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index efe4bd757..90b461469 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -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}`); diff --git a/packages/ai/test/anthropic-sse-parsing.test.ts b/packages/ai/test/anthropic-sse-parsing.test.ts index 030249664..d8daf7f71 100644 --- a/packages/ai/test/anthropic-sse-parsing.test.ts +++ b/packages/ai/test/anthropic-sse-parsing.test.ts @@ -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 = {