diff --git a/open-sse/translator/concerns/finishReason.js b/open-sse/translator/concerns/finishReason.js index 684a7001..6bfd021b 100644 --- a/open-sse/translator/concerns/finishReason.js +++ b/open-sse/translator/concerns/finishReason.js @@ -11,6 +11,10 @@ export function toOpenAIFinish(reason, format) { case CLAUDE_STOP.MAX_TOKENS: return OPENAI_FINISH.LENGTH; case CLAUDE_STOP.TOOL_USE: return OPENAI_FINISH.TOOL_CALLS; case CLAUDE_STOP.STOP_SEQUENCE: return OPENAI_FINISH.STOP; + // A refusal is a blocked turn, not a clean stop: with the default mapping an + // OpenAI client saw finish_reason "stop" and an empty message (9Router logged + // "succeeded", OUT 0) and could not tell it from a real answer. + case CLAUDE_STOP.REFUSAL: return OPENAI_FINISH.CONTENT_FILTER; default: return OPENAI_FINISH.STOP; } case "commandcode": @@ -55,6 +59,7 @@ export function fromOpenAIFinish(reason, format) { case OPENAI_FINISH.STOP: return CLAUDE_STOP.END_TURN; case OPENAI_FINISH.LENGTH: return CLAUDE_STOP.MAX_TOKENS; case OPENAI_FINISH.TOOL_CALLS: return CLAUDE_STOP.TOOL_USE; + case OPENAI_FINISH.CONTENT_FILTER: return CLAUDE_STOP.REFUSAL; default: return CLAUDE_STOP.END_TURN; } default: diff --git a/open-sse/translator/response/claude-to-openai.js b/open-sse/translator/response/claude-to-openai.js index 4651d3cf..80de2f18 100644 --- a/open-sse/translator/response/claude-to-openai.js +++ b/open-sse/translator/response/claude-to-openai.js @@ -149,6 +149,13 @@ export function claudeToOpenAIResponse(chunk, state) { if (chunk.delta?.stop_reason) { state.finishReason = convertStopReason(chunk.delta.stop_reason); + // A refusal produces no content blocks at all. Surface Anthropic's own + // explanation as the message text so the client shows *why* the turn is + // empty instead of a blank reply. + const refusalNote = chunk.delta.stop_reason === "refusal" && chunk.delta.stop_details?.explanation; + if (refusalNote) { + results.push(createChunk(state, { content: refusalNote })); + } const finalChunk = createChunk(state, {}, state.finishReason); if (state.usage) { diff --git a/open-sse/translator/schema/finishReasons.js b/open-sse/translator/schema/finishReasons.js index 73535dae..88c320f1 100644 --- a/open-sse/translator/schema/finishReasons.js +++ b/open-sse/translator/schema/finishReasons.js @@ -14,6 +14,9 @@ export const CLAUDE_STOP = { MAX_TOKENS: "max_tokens", TOOL_USE: "tool_use", STOP_SEQUENCE: "stop_sequence", + // Anthropic's API-level refusal (streaming classifier / ToS). Arrives in + // message_delta with zero output tokens; stop_details carries the reason. + REFUSAL: "refusal", }; // Gemini finishReason values. diff --git a/tests/unit/claude-refusal-stream.test.js b/tests/unit/claude-refusal-stream.test.js new file mode 100644 index 00000000..5c281f84 --- /dev/null +++ b/tests/unit/claude-refusal-stream.test.js @@ -0,0 +1,76 @@ +// A refusal from the Anthropic API (stop_reason "refusal", zero output tokens, no +// content blocks) must reach an OpenAI-format client as finish_reason +// "content_filter" carrying Anthropic's explanation — not as a clean, empty "stop". +// Captured live on 2026-09-20 against claude-opus-5 via a Claude Code OAuth +// connection: 9Router logged "Model succeeded · OUT 0" and the client saw nothing. +import { describe, it, expect } from "vitest"; +import { claudeToOpenAIResponse } from "../../open-sse/translator/response/claude-to-openai.js"; + +const EXPLANATION = + "This request was blocked as it seems to violate Anthropic's Terms of Service restrictions on reverse engineering or duplicating model outputs."; + +function runStream(events) { + const state = {}; + const out = []; + for (const ev of events) { + const r = claudeToOpenAIResponse(ev, state); + if (Array.isArray(r)) out.push(...r); + else if (r) out.push(r); + } + return { state, out }; +} + +const refusalStream = [ + { + type: "message_start", + message: { + id: "msg_refusal", model: "claude-opus-5", role: "assistant", content: [], + usage: { input_tokens: 637, cache_creation_input_tokens: 206779, cache_read_input_tokens: 0, output_tokens: 0 } + } + }, + { + type: "message_delta", + delta: { + stop_reason: "refusal", + stop_sequence: null, + stop_details: { type: "refusal", category: "reasoning_extraction", explanation: EXPLANATION } + }, + usage: { input_tokens: 637, cache_creation_input_tokens: 206779, cache_read_input_tokens: 0, output_tokens: 0 } + }, + { type: "message_stop" } +]; + +describe("claude-to-openai: refusal stop_reason", () => { + it("finishes with content_filter, not stop", () => { + const { out } = runStream(refusalStream); + const finishes = out.map(c => c.choices?.[0]?.finish_reason).filter(Boolean); + expect(finishes).toEqual(["content_filter"]); + }); + + it("surfaces Anthropic's explanation as message content", () => { + const { out } = runStream(refusalStream); + const text = out.map(c => c.choices?.[0]?.delta?.content || "").join(""); + expect(text).toBe(EXPLANATION); + }); + + it("keeps usage on the final chunk (prompt tokens were billed)", () => { + const { out } = runStream(refusalStream); + const final = out.find(c => c.choices?.[0]?.finish_reason === "content_filter"); + expect(final.usage.prompt_tokens).toBe(637 + 206779); + expect(final.usage.completion_tokens).toBe(0); + }); + + it("leaves a normal end_turn untouched", () => { + const { out } = runStream([ + { type: "message_start", message: { id: "m", model: "claude-opus-5", role: "assistant", content: [], usage: { input_tokens: 5, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null, stop_details: null }, usage: { output_tokens: 1 } }, + { type: "message_stop" } + ]); + const finishes = out.map(c => c.choices?.[0]?.finish_reason).filter(Boolean); + expect(finishes).toEqual(["stop"]); + expect(out.map(c => c.choices?.[0]?.delta?.content || "").join("")).toBe("ok"); + }); +}); diff --git a/tests/unit/finish-reason-concern.test.js b/tests/unit/finish-reason-concern.test.js index d4fc4713..bb233bf3 100644 --- a/tests/unit/finish-reason-concern.test.js +++ b/tests/unit/finish-reason-concern.test.js @@ -40,6 +40,9 @@ describe("toOpenAIFinish - claude", () => { ["end_turn", "stop"], ["max_tokens", "length"], ["tool_use", "tool_calls"], + ["stop_sequence", "stop"], + ["refusal", "content_filter"], + ["unknown_xyz", "stop"], ])("%s -> %s", (input, expected) => { expect(toOpenAIFinish(input, "claude")).toBe(expected); }); @@ -58,6 +61,9 @@ describe("fromOpenAIFinish round-trip - claude", () => { it("tool_calls -> tool_use", () => { expect(fromOpenAIFinish("tool_calls", "claude")).toBe("tool_use"); }); + it("content_filter -> refusal", () => { + expect(fromOpenAIFinish("content_filter", "claude")).toBe("refusal"); + }); it("length -> max_tokens", () => { expect(fromOpenAIFinish("length", "claude")).toBe("max_tokens"); });