diff --git a/changelog.d/fixes/16544-claude-nonstream-stop-reasons.md b/changelog.d/fixes/16544-claude-nonstream-stop-reasons.md new file mode 100644 index 000000000000..fa1710af81cc --- /dev/null +++ b/changelog.d/fixes/16544-claude-nonstream-stop-reasons.md @@ -0,0 +1 @@ +- **fix(translator):** a non-streaming Claude response now maps `refusal`, `model_context_window_exceeded`, `stop_sequence` and `pause_turn` the same way the streaming path does, so a Gemini client sees a refusal as `SAFETY` and a context-window stop as `MAX_TOKENS` instead of a clean `STOP`. diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 3b1660bce5d9..8956628d2c93 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -647,13 +647,11 @@ export function translateNonStreamingResponse( message.content = ""; } - let finishReason = toString(root.stop_reason, "stop"); - if (finishReason === "end_turn") finishReason = "stop"; - if (finishReason === "tool_use") finishReason = "tool_calls"; - // Streaming claude-to-openai already maps this (convertStopReason). Leaving - // the raw spelling here makes a truncated completion look like an unknown - // finish to the chat empty-output check. - if (finishReason === "max_tokens") finishReason = "length"; + // Same mapping as the streaming claude-to-openai path (convertStopReason): + // refusal -> content_filter, max_tokens and model_context_window_exceeded -> + // length, stop_sequence and pause_turn -> stop. A raw spelling here reached + // non-OpenAI clients unmapped (a Gemini client saw a refusal as STOP). + const finishReason = normalizeOpenAICompatibleFinishReasonString(root.stop_reason, "stop"); const result: JsonRecord = { id: `chatcmpl-${toString(root.id, String(Date.now()))}`, diff --git a/tests/unit/claude-nonstream-stop-reasons.test.ts b/tests/unit/claude-nonstream-stop-reasons.test.ts new file mode 100644 index 000000000000..5e6dd45fc8b5 --- /dev/null +++ b/tests/unit/claude-nonstream-stop-reasons.test.ts @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { translateNonStreamingResponse } = await import("../../open-sse/handlers/responseTranslator.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +function claudeBody(stopReason: string) { + return { + id: "msg_stop", + type: "message", + role: "assistant", + model: "claude-test", + content: [{ type: "text", text: "partial" }], + stop_reason: stopReason, + usage: { input_tokens: 3, output_tokens: 2 }, + }; +} + +function openaiFinish(stopReason: string): unknown { + const out = translateNonStreamingResponse(claudeBody(stopReason), FORMATS.CLAUDE, FORMATS.OPENAI) as { + choices?: Array<{ finish_reason?: unknown }>; + }; + return out.choices?.[0]?.finish_reason; +} + +function geminiFinish(stopReason: string): unknown { + type GeminiBody = { candidates?: Array<{ finishReason?: unknown }> }; + const out = translateNonStreamingResponse(claudeBody(stopReason), FORMATS.CLAUDE, FORMATS.GEMINI) as GeminiBody & { + response?: GeminiBody; + }; + return (out.candidates ?? out.response?.candidates)?.[0]?.finishReason; +} + +test("non-streaming Claude refusal reaches an OpenAI-shaped body as content_filter", () => { + assert.equal(openaiFinish("refusal"), "content_filter"); +}); + +test("non-streaming Claude context-window stop reaches an OpenAI-shaped body as length", () => { + assert.equal(openaiFinish("model_context_window_exceeded"), "length"); +}); + +test("non-streaming Claude stop_sequence and pause_turn reach an OpenAI-shaped body as stop", () => { + assert.equal(openaiFinish("stop_sequence"), "stop"); + assert.equal(openaiFinish("pause_turn"), "stop"); +}); + +test("a Gemini client sees a Claude refusal as SAFETY, not as a clean STOP", () => { + assert.equal(geminiFinish("refusal"), "SAFETY"); +}); + +test("a Gemini client sees a Claude context-window stop as MAX_TOKENS", () => { + assert.equal(geminiFinish("model_context_window_exceeded"), "MAX_TOKENS"); +}); + +test("existing non-streaming mappings are unchanged", () => { + assert.equal(openaiFinish("end_turn"), "stop"); + assert.equal(openaiFinish("tool_use"), "tool_calls"); + assert.equal(openaiFinish("max_tokens"), "length"); + assert.equal(geminiFinish("end_turn"), "STOP"); + assert.equal(geminiFinish("max_tokens"), "MAX_TOKENS"); +});