From 8bba88cd30908d7cbda2e2526d975f5f4ebe1298 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:58:48 -0300 Subject: [PATCH] fix(translator): prevent doubled tool args in OpenAI-to-Claude (#5828) Integrated into release/v3.8.43 --- CHANGELOG.md | 2 + .../translator/request/claude-to-openai.ts | 5 +- .../translator/response/openai-to-claude.ts | 4 +- .../unit/translator-claude-to-openai.test.ts | 36 ++++++++++++++ .../translator-resp-openai-to-claude.test.ts | 47 +++++++++++++++++++ 5 files changed, 91 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7752e4eb04..6f672444a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,8 @@ - **fix(kiro):** stop injecting a placeholder user turn on trailing tool-result turns so agentic loops aren't disrupted. (thanks @jetmiky) +- **fix(translator):** prevent doubled tool arguments in OpenAI-to-Claude responses (duplicate finish_reason guard + string tool-input passthrough). (thanks @vishalrajv) + ### 📝 Maintenance --- diff --git a/open-sse/translator/request/claude-to-openai.ts b/open-sse/translator/request/claude-to-openai.ts index e96bf4b7e4..9b8b70f3c7 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -410,7 +410,10 @@ function convertClaudeMessage(msg, preserveCacheControl = false) { type: "function", function: { name: block.name, - arguments: JSON.stringify(block.input || {}), + arguments: + typeof block.input === "string" + ? block.input + : JSON.stringify(block.input || {}), }, }); break; diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts index edc12816b8..8970261e97 100644 --- a/open-sse/translator/response/openai-to-claude.ts +++ b/open-sse/translator/response/openai-to-claude.ts @@ -225,8 +225,8 @@ export function openaiToClaudeResponse(chunk, state) { } } - // Finish - if (choice.finish_reason) { + // Finish — guard against duplicate finish_reason chunks (common with OpenAI-compatible models) + if (choice.finish_reason && !state.finishReason) { stopThinkingBlock(state, results); stopTextBlock(state, results); diff --git a/tests/unit/translator-claude-to-openai.test.ts b/tests/unit/translator-claude-to-openai.test.ts index 23354d6e3b..2e539aa239 100644 --- a/tests/unit/translator-claude-to-openai.test.ts +++ b/tests/unit/translator-claude-to-openai.test.ts @@ -259,6 +259,42 @@ test("Claude -> OpenAI turns thinking and tool_use blocks into assistant tool_ca }); }); +test("Claude -> OpenAI passes pre-stringified tool_use input through verbatim (no double-encoding)", () => { + // #2279: some upstream Claude sources emit tool_use.input already JSON-encoded + // as a string. Re-running JSON.stringify on it would double-encode the + // payload (wrapping it in an extra pair of quotes with escaped internals). + const result = claudeToOpenAIRequest( + "gpt-4o", + { + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tu_2", + name: "read_file", + input: '{"path":"/tmp/demo"}', + }, + ], + }, + ], + }, + false + ); + + assert.deepEqual(result.messages[0].tool_calls, [ + { + id: "tu_2", + type: "function", + function: { + name: "read_file", + arguments: '{"path":"/tmp/demo"}', + }, + }, + ]); +}); + test("Claude -> OpenAI converts tool_result blocks into tool messages and preserves trailing user text", () => { const result = claudeToOpenAIRequest( "gpt-4o", diff --git a/tests/unit/translator-resp-openai-to-claude.test.ts b/tests/unit/translator-resp-openai-to-claude.test.ts index 508d0b7c01..b5a7f1841c 100644 --- a/tests/unit/translator-resp-openai-to-claude.test.ts +++ b/tests/unit/translator-resp-openai-to-claude.test.ts @@ -148,6 +148,53 @@ test("OpenAI stream: tool calls strip Claude OAuth prefix and keep cache usage", assert.equal(result[5].usage.cache_creation_input_tokens, 1); }); +test("OpenAI stream: two finish_reason chunks emit finish events exactly once", () => { + // #2279: some OpenAI-compatible upstreams resend a terminal chunk that still + // carries finish_reason (e.g. an empty-delta echo of the finish chunk). Without + // a guard, the whole finish block (content_block_stop / message_delta / + // message_stop) re-runs and duplicates those events downstream. + const state = createState(); + const first = openaiToClaudeResponse( + { + id: "chatcmpl-4", + model: "gpt-4.1", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + type: "function", + function: { name: "read_file", arguments: '{"path":"/tmp/a"}' }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, + }, + state + ); + // Duplicate terminal chunk: empty delta, same finish_reason. + const second = openaiToClaudeResponse( + { + id: "chatcmpl-4", + model: "gpt-4.1", + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, + }, + state + ); + const result = flatten([first, second]); + + assert.equal(result.filter((e) => e.type === "content_block_stop").length, 1); + assert.equal(result.filter((e) => e.type === "message_delta").length, 1); + assert.equal(result.filter((e) => e.type === "message_stop").length, 1); +}); + test("OpenAI non-stream: chat completion becomes Claude message with thinking and tool_use", () => { const result = translateNonStreamingResponse( {