fix(translator): drop orphan tool results on the Claude->OpenAI request path (#4385) (#4413)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-20 16:51:07 -03:00
committed by GitHub
parent 4287a124e1
commit 51642dcc99
3 changed files with 131 additions and 1 deletions

View File

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

View File

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

View File

@@ -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",
});