fix(translator): prevent doubled tool args in OpenAI-to-Claude (#5828)

Integrated into release/v3.8.43
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-01 21:58:48 -03:00
committed by GitHub
parent 08dbf6f58a
commit 8bba88cd30
5 changed files with 91 additions and 3 deletions

View File

@@ -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
---

View File

@@ -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;

View File

@@ -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);

View File

@@ -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",

View File

@@ -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(
{