From 9febe4414dcee52b04e1f66cf2f0f466a21ff1fa Mon Sep 17 00:00:00 2001 From: initguru Date: Thu, 17 Sep 2026 14:29:39 +0900 Subject: [PATCH] fix(compat): preserve GPT and Claude Code tool-call history (#12909) * fix(compat): preserve GPT and Claude Code tool-call history * docs(changelog): add fragment for tool-call history preservation fix Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../fixes/12909-tool-call-history-preserve.md | 1 + .../translator/request/openai-responses.ts | 44 +++++++++++- ...slator-openai-responses-tool-calls.test.ts | 68 +++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/12909-tool-call-history-preserve.md create mode 100644 tests/unit/translator-openai-responses-tool-calls.test.ts diff --git a/changelog.d/fixes/12909-tool-call-history-preserve.md b/changelog.d/fixes/12909-tool-call-history-preserve.md new file mode 100644 index 0000000000..25ac4c2f28 --- /dev/null +++ b/changelog.d/fixes/12909-tool-call-history-preserve.md @@ -0,0 +1 @@ +- **fix(compat):** preserve GPT and Claude Code tool-call history when translating OpenAI Responses API requests to Chat Completions — `role:"tool"` items and role-based assistant `tool_calls` are no longer dropped, fixing incomplete multi-turn history ([#12909](https://github.com/diegosouzapw/OmniRoute/pull/12909)) diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 0e4e4f5d71..e51851f006 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -79,6 +79,29 @@ function appendReasoningContent(current: unknown, next: string): string { return existing ? `${existing}\n\n${next}` : next; } +function normalizeRoleBasedToolCalls(toolCalls: unknown): JsonRecord[] { + if (!Array.isArray(toolCalls)) return []; + + return toolCalls + .map((toolCallValue) => { + const toolCall = toRecord(toolCallValue); + const fn = toRecord(toolCall.function); + const name = toString(fn.name).trim(); + const id = toString(toolCall.id).trim(); + if (!name || !id) return null; + return { + id, + type: "function", + function: { + name, + arguments: + typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments ?? {}), + }, + }; + }) + .filter((toolCall): toolCall is JsonRecord => toolCall !== null); +} + /** * Convert OpenAI Responses API request to OpenAI Chat Completions format */ @@ -252,6 +275,15 @@ export function openaiResponsesToOpenAIRequest( pendingToolResults = []; } + if (toString(item.role) === "tool") { + messages.push({ + role: "tool", + tool_call_id: toString(item.tool_call_id), + content: toolOutputContentToString(item.content), + }); + continue; + } + // Convert content: input_text -> text, output_text -> text const content = Array.isArray(item.content) ? item.content.map((contentValue) => { @@ -288,7 +320,17 @@ export function openaiResponsesToOpenAIRequest( : item.content; if (role === "assistant") { - if (!currentAssistantMsg) { + const roleBasedToolCalls = normalizeRoleBasedToolCalls(item.tool_calls); + if (roleBasedToolCalls.length > 0) { + if (currentAssistantMsg) { + messages.push(currentAssistantMsg); + } + currentAssistantMsg = { + role, + content, + tool_calls: roleBasedToolCalls, + }; + } else if (!currentAssistantMsg) { currentAssistantMsg = { role, content }; } else if (currentAssistantMsg.content == null && content != null) { currentAssistantMsg.content = content; diff --git a/tests/unit/translator-openai-responses-tool-calls.test.ts b/tests/unit/translator-openai-responses-tool-calls.test.ts new file mode 100644 index 0000000000..a10ad404fd --- /dev/null +++ b/tests/unit/translator-openai-responses-tool-calls.test.ts @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiResponsesToOpenAIRequest } = + await import("../../open-sse/translator/request/openai-responses.ts"); + +test("Responses -> Chat preserves role-based assistant tool_calls and tool results", () => { + const result = openaiResponsesToOpenAIRequest( + "deepseek-v4-flash", + { + model: "deepseek-v4-flash", + input: [ + { role: "user", content: "Run pwd" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "exec_command", arguments: '{"cmd":"pwd"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "/tmp" }, + ], + }, + false, + { provider: "deepseek" } + ) as { messages: Array> }; + + assert.deepEqual(result.messages[1].tool_calls, [ + { + id: "call_1", + type: "function", + function: { name: "exec_command", arguments: '{"cmd":"pwd"}' }, + }, + ]); + assert.deepEqual(result.messages[2], { + role: "tool", + tool_call_id: "call_1", + content: "/tmp", + }); +}); + +test("Responses -> Chat drops role-based tool_calls with empty name or id", () => { + const result = openaiResponsesToOpenAIRequest( + "deepseek-v4-flash", + { + model: "deepseek-v4-flash", + input: [ + { role: "user", content: "Run" }, + { + role: "assistant", + content: null, + tool_calls: [ + { id: "call_nameless", type: "function", function: { name: "", arguments: "{}" } }, + { id: "", type: "function", function: { name: "exec_command", arguments: "{}" } }, + ], + }, + ], + }, + false, + { provider: "deepseek" } + ) as { messages: Array> }; + + assert.equal(result.messages[1].tool_calls, undefined); +});