diff --git a/open-sse/translator/request/claude-to-openai.ts b/open-sse/translator/request/claude-to-openai.ts index f426c99857..013fd77a4d 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -136,6 +136,30 @@ export function claudeToOpenAIRequest(model, body, stream, credentials: unknown // Fix missing tool responses - OpenAI requires every tool_call to have a response fixMissingToolResponses(result.messages); + // #4385: drop orphan tool results — a role:"tool" message whose tool_call_id has no + // matching assistant.tool_calls (e.g. history truncation / compression removed the + // assistant turn that issued the call but kept the tool_result). OpenAI-compatible + // upstreams reject these with 502 "Messages with role 'tool' must be a response to a + // preceding message with 'tool_calls'". Mirrors the filter already applied on the + // Responses->Chat path in openai-responses.ts (#2893). Run after fixMissingToolResponses + // so the inserted "[No response received]" placeholders (which DO match a tool_call) + // are kept, and only genuinely orphaned results are removed. + const assistantToolCallIds = new Set(); + for (const msg of result.messages) { + const calls = (msg as JsonRecord).tool_calls; + if (Array.isArray(calls)) { + for (const tc of calls as { id?: string }[]) { + if (tc.id) assistantToolCallIds.add(String(tc.id)); + } + } + } + result.messages = result.messages.filter((msg) => { + if ((msg as JsonRecord).role === "tool") { + return assistantToolCallIds.has(String((msg as JsonRecord).tool_call_id ?? "")); + } + return true; + }); + const useNativeResponsesWebSearch = shouldUseNativeResponsesWebSearch(credentials); // Tools diff --git a/tests/unit/translator-claude-to-openai-orphan-tool-4385.test.ts b/tests/unit/translator-claude-to-openai-orphan-tool-4385.test.ts new file mode 100644 index 0000000000..67ac2857af --- /dev/null +++ b/tests/unit/translator-claude-to-openai-orphan-tool-4385.test.ts @@ -0,0 +1,94 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #4385: routing a Claude-protocol conversation (e.g. via /v1/messages) to an +// OpenAI-compatible provider (command-code, custom openai-compatible) returned +// 502 "Messages with role 'tool' must be a response to a preceding message with +// 'tool_calls'". Cause: claudeToOpenAIRequest emits a role:"tool" message for every +// Claude tool_result block, but never drops an ORPHAN one whose tool_call_id has no +// matching assistant.tool_calls (e.g. when history truncation / compression removed +// the assistant turn but kept the tool_result). OpenAI-compatible upstreams reject it. +// This mirrors the orphan filter already on the Responses->Chat path (#2893). + +const { claudeToOpenAIRequest } = await import( + "../../open-sse/translator/request/claude-to-openai.ts" +); + +type Msg = { role: string; tool_call_id?: string; tool_calls?: { id?: string }[] }; + +test("#4385 drops an orphan tool_result with no preceding assistant tool_call", () => { + const result = claudeToOpenAIRequest( + "deepseek/deepseek-v4-pro", + { + messages: [ + { role: "user", content: "start the task" }, + { + role: "user", + content: [ + // orphan: the assistant turn that issued tool_use "orphan_tu" was dropped + { type: "tool_result", tool_use_id: "orphan_tu", content: "stale output" }, + { type: "text", text: "please continue" }, + ], + }, + ], + }, + false + ); + + const toolMsgs = (result.messages as Msg[]).filter((m) => m.role === "tool"); + assert.equal(toolMsgs.length, 0, "orphan role:'tool' must be filtered out"); + // The user's accompanying text is preserved. + const userTexts = (result.messages as Msg[]).filter((m) => m.role === "user"); + assert.equal(userTexts.length, 2); +}); + +test("#4385 preserves a tool_result paired with its assistant tool_call", () => { + const result = claudeToOpenAIRequest( + "deepseek/deepseek-v4-pro", + { + messages: [ + { role: "user", content: "list files" }, + { role: "assistant", content: [{ type: "tool_use", id: "tu_1", name: "ls", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "tu_1", content: "a.ts" }] }, + ], + }, + false + ); + + const toolMsgs = (result.messages as Msg[]).filter((m) => m.role === "tool"); + assert.equal(toolMsgs.length, 1); + assert.equal(toolMsgs[0].tool_call_id, "tu_1"); + // The assistant.tool_calls is still present and precedes the tool message. + const assistantIdx = (result.messages as Msg[]).findIndex( + (m) => m.role === "assistant" && Array.isArray(m.tool_calls) + ); + const toolIdx = (result.messages as Msg[]).findIndex((m) => m.role === "tool"); + assert.ok(assistantIdx >= 0 && assistantIdx < toolIdx); +}); + +test("#4385 keeps valid tool_results and drops orphans in the same user turn", () => { + const result = claudeToOpenAIRequest( + "deepseek/deepseek-v4-pro", + { + messages: [ + { + role: "assistant", + content: [{ type: "tool_use", id: "tu_valid", name: "fn", input: {} }], + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "tu_valid", content: "ok" }, + { type: "tool_result", tool_use_id: "tu_orphan", content: "stale" }, + { type: "text", text: "done" }, + ], + }, + ], + }, + false + ); + + const toolMsgs = (result.messages as Msg[]).filter((m) => m.role === "tool"); + assert.equal(toolMsgs.length, 1); + assert.equal(toolMsgs[0].tool_call_id, "tu_valid"); +}); diff --git a/tests/unit/translator-claude-to-openai.test.ts b/tests/unit/translator-claude-to-openai.test.ts index e2d4dc2ee4..23354d6e3b 100644 --- a/tests/unit/translator-claude-to-openai.test.ts +++ b/tests/unit/translator-claude-to-openai.test.ts @@ -264,6 +264,14 @@ test("Claude -> OpenAI converts tool_result blocks into tool messages and preser "gpt-4o", { messages: [ + // #4385: a tool_result must be paired with a preceding assistant tool_call, + // otherwise it is an orphan that OpenAI-compatible upstreams reject (and that + // claudeToOpenAIRequest now filters). Pair it so this test still exercises the + // content-extraction mechanics (array [text, image] → "20C") on a valid sequence. + { + role: "assistant", + content: [{ type: "tool_use", id: "tu_1", name: "weather", input: {} }], + }, { role: "user", content: [ @@ -287,11 +295,15 @@ test("Claude -> OpenAI converts tool_result blocks into tool messages and preser ); assert.deepEqual(result.messages[0], { + role: "assistant", + tool_calls: [{ id: "tu_1", type: "function", function: { name: "weather", arguments: "{}" } }], + }); + assert.deepEqual(result.messages[1], { role: "tool", tool_call_id: "tu_1", content: "20C", }); - assert.deepEqual(result.messages[1], { + assert.deepEqual(result.messages[2], { role: "user", content: "Thanks", });