Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/16544-claude-nonstream-stop-reasons.md
Original file line number Diff line number Diff line change
@@ -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`.
12 changes: 5 additions & 7 deletions open-sse/handlers/responseTranslator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()))}`,
Expand Down
61 changes: 61 additions & 0 deletions tests/unit/claude-nonstream-stop-reasons.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading