From c5003665c3f70b1545684f9d29ce8bc30e0d0af8 Mon Sep 17 00:00:00 2001 From: oyi77 Date: Sun, 29 Mar 2026 07:07:12 +0700 Subject: [PATCH] fix(translator): remove thoughtSignature from functionCall parts in all Gemini translators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP 400 "invalid argument" was triggered when OmniRoute translated tool calls to Gemini format, because thoughtSignature was injected onto every functionCall part unconditionally. Affects two code paths: 1. openai-to-gemini.ts — OpenAI tool_calls → Gemini functionCall 2. claude-to-gemini.ts — Claude tool_use blocks → Gemini functionCall thoughtSignature is only valid on thinking/reasoning parts (those with thought: true or thoughtSignature as their primary field). When present on a functionCall part, the Gemini API returns HTTP 400 'invalid argument'. The thinking parts that legitimately carry thoughtSignature (emitted when a message has reasoning_content / thinking blocks) are untouched. Regression tests (T43) cover: - single tool call: no thoughtSignature on functionCall part (openai path) - multiple tool calls: none carry thoughtSignature (openai path) - thinking regression guard: thoughtSignature still on thought parts - claude-to-gemini path: tool_use blocks produce clean functionCall parts Fixes #724 --- .../translator/request/claude-to-gemini.ts | 4 +- .../translator/request/openai-to-gemini.ts | 4 +- ...ni-tool-call-no-thought-signature.test.mjs | 161 ++++++++++++++++++ 3 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 tests/unit/t43-gemini-tool-call-no-thought-signature.test.mjs diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 7ac3dfd4f5..ae34b6cd3a 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -92,8 +92,10 @@ export function claudeToGeminiRequest(model, body, stream) { break; case "tool_use": + // Do NOT include thoughtSignature on functionCall parts — it is only valid + // on thinking/reasoning parts and causes HTTP 400 "invalid argument" from the + // Gemini API when present on a functionCall part. parts.push({ - thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, functionCall: { id: block.id, name: block.name, diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index efcdb9a728..4aa3bba95f 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -167,8 +167,10 @@ function openaiToGeminiBase(model, body, stream) { if (tc.type !== "function") continue; const args = tryParseJSON(tc.function?.arguments || "{}"); + // Do NOT include thoughtSignature on functionCall parts — it is only valid + // on thinking/reasoning parts and causes HTTP 400 "invalid argument" from the + // Gemini API when present on a functionCall part (#725). parts.push({ - thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, functionCall: { id: tc.id, name: tc.function.name, diff --git a/tests/unit/t43-gemini-tool-call-no-thought-signature.test.mjs b/tests/unit/t43-gemini-tool-call-no-thought-signature.test.mjs new file mode 100644 index 0000000000..8adc7722b0 --- /dev/null +++ b/tests/unit/t43-gemini-tool-call-no-thought-signature.test.mjs @@ -0,0 +1,161 @@ +/** + * T43: Gemini tool call parts must NOT include thoughtSignature. + * + * Regression test for HTTP 400 "invalid argument" when OmniRoute translates + * OpenAI tool_calls to Gemini format. The thoughtSignature field is only valid + * on thinking/reasoning parts — injecting it on functionCall parts causes the + * Gemini API to reject the request with a 400 error. + * + * Reproduces: https://github.com/diegosouzapw/OmniRoute/issues/725 + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { translateRequest } = await import("../../open-sse/translator/index.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +function translateToGemini(messages, tools) { + return translateRequest(FORMATS.OPENAI, FORMATS.GEMINI, "gemini-2.0-flash", { + model: "gemini-2.0-flash", + messages, + tools, + stream: false, + }); +} + +test("T43: functionCall parts must not contain thoughtSignature", () => { + const messages = [ + { role: "user", content: "What is the weather in Tokyo?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_abc123", + type: "function", + function: { name: "get_weather", arguments: '{"location":"Tokyo"}' }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_abc123", + content: '{"temp": "15°C", "condition": "cloudy"}', + }, + ]; + + const tools = [ + { + type: "function", + function: { + name: "get_weather", + description: "Get weather for a location", + parameters: { + type: "object", + properties: { location: { type: "string" } }, + required: ["location"], + }, + }, + }, + ]; + + const result = translateToGemini(messages, tools); + + // Find the model turn that contains the functionCall + const modelTurn = result.contents.find( + (c) => c.role === "model" && c.parts?.some((p) => p.functionCall) + ); + + assert.ok(modelTurn, "Expected a model turn with functionCall parts"); + + for (const part of modelTurn.parts) { + if (part.functionCall) { + assert.ok( + !("thoughtSignature" in part), + `functionCall part must not contain thoughtSignature — Gemini API returns HTTP 400 "invalid argument" when it does. Got: ${JSON.stringify(part)}` + ); + assert.equal(part.functionCall.name, "get_weather"); + assert.deepEqual(part.functionCall.args, { location: "Tokyo" }); + } + } +}); + +test("T43: multiple tool calls — none of the functionCall parts may have thoughtSignature", () => { + const messages = [ + { role: "user", content: "Get weather for Tokyo and London" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_001", + type: "function", + function: { name: "get_weather", arguments: '{"location":"Tokyo"}' }, + }, + { + id: "call_002", + type: "function", + function: { name: "get_weather", arguments: '{"location":"London"}' }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_001", + content: '{"temp":"15°C"}', + }, + { + role: "tool", + tool_call_id: "call_002", + content: '{"temp":"10°C"}', + }, + ]; + + const result = translateToGemini(messages, []); + + const modelTurn = result.contents.find( + (c) => c.role === "model" && c.parts?.some((p) => p.functionCall) + ); + assert.ok(modelTurn, "Expected a model turn with functionCall parts"); + + const functionCallParts = modelTurn.parts.filter((p) => p.functionCall); + assert.equal(functionCallParts.length, 2, "Expected 2 functionCall parts"); + + for (const part of functionCallParts) { + assert.ok( + !("thoughtSignature" in part), + `functionCall part must not contain thoughtSignature. Got: ${JSON.stringify(part)}` + ); + } +}); + +test("T43: thinking parts still include thoughtSignature (regression guard)", () => { + // Ensure we did not accidentally break the thinking parts that legitimately + // need thoughtSignature (present when msg.reasoning_content is set). + const messages = [ + { role: "user", content: "Think about the weather" }, + { + role: "assistant", + reasoning_content: "The user wants weather data.", + content: "I'll check the weather.", + tool_calls: undefined, + }, + ]; + + const result = translateToGemini(messages, []); + + const modelTurn = result.contents.find((c) => c.role === "model"); + assert.ok(modelTurn, "Expected a model turn"); + + const thinkingPart = modelTurn.parts.find((p) => p.thought === true); + assert.ok(thinkingPart, "Expected a thinking part when reasoning_content is set"); + assert.equal(thinkingPart.text, "The user wants weather data."); + + const signaturePart = modelTurn.parts.find((p) => "thoughtSignature" in p); + assert.ok(signaturePart, "Expected a thoughtSignature part after thinking part"); + assert.ok( + !signaturePart.functionCall, + "thoughtSignature part must not also be a functionCall part" + ); +});