diff --git a/changelog.d/fixes/11510-gemini-parallel-tool-thought-signature.md b/changelog.d/fixes/11510-gemini-parallel-tool-thought-signature.md new file mode 100644 index 0000000000..356743ec1a --- /dev/null +++ b/changelog.d/fixes/11510-gemini-parallel-tool-thought-signature.md @@ -0,0 +1 @@ +- **fix(sse):** stop dropping resolved `thoughtSignature` values on parallel (multi tool-call) turns sent to Gemini 3.x — the claude→gemini and openai→gemini translators previously kept the signature only on the *first* function call of a message, causing Gemini to reject subsequent calls in the same turn with HTTP 400 "Function call is missing a thought_signature"; each function call now keeps its own resolved signature ([#11510](https://github.com/diegosouzapw/OmniRoute/issues/11510)). diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 500cec499b..9137aec0f0 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -136,7 +136,6 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { const omittedToolCallIds = new Set(); for (const msg of body.messages) { const parts = []; - let shouldUseEmbeddedSignature = true; if (Array.isArray(msg.content)) { for (const block of msg.content) { @@ -160,15 +159,15 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { break; } - const embeddedThoughtSignature = shouldUseEmbeddedSignature - ? signatureForToolCall - : undefined; - if (embeddedThoughtSignature) { - shouldUseEmbeddedSignature = false; - } - + // #11510: each functionCall part carries its OWN resolved + // thoughtSignature — a parallel (multi tool_use) turn can have a + // real, individually-valid signature per tool call, and Gemini + // 3.x rejects the request if any functionCall in the turn is + // missing one. Previously only the first functionCall of the + // message kept its signature; this dropped valid signatures for + // every subsequent parallel tool call in the same turn. parts.push({ - ...(embeddedThoughtSignature ? { thoughtSignature: embeddedThoughtSignature } : {}), + ...(signatureForToolCall ? { thoughtSignature: signatureForToolCall } : {}), functionCall: { ...(stripFunctionCallId ? {} : { id: block.id }), name: sanitizeToolName(block.name), diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 4e1e445575..d68946369b 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -383,7 +383,6 @@ function openaiToGeminiBase( if (toolCalls && Array.isArray(toolCalls)) { const toolCallIds: string[] = []; const resolvedSignatures = new Map(); - let firstPersistedSignature: string | undefined; for (const tc of toolCalls) { const id = tc.id as string; const resolved = resolveGeminiThoughtSignature( @@ -392,11 +391,9 @@ function openaiToGeminiBase( ); if (typeof resolved === "string" && resolved.length > 0) { resolvedSignatures.set(id, resolved); - firstPersistedSignature ??= resolved; } } - let shouldUseEmbeddedSignature = !parts.some((p) => p.thoughtSignature); const signaturelessToolCallMode = toolNameOptions.signaturelessToolCallMode; const stringifySignaturelessToolCalls = signaturelessToolCallMode === "text"; const contextualizeSignaturelessToolResponses = @@ -433,13 +430,14 @@ function openaiToGeminiBase( } const args = tryParseJSON(fn.arguments || "{}"); - const embeddedThoughtSignature = shouldUseEmbeddedSignature - ? firstPersistedSignature || signatureForToolCall - : undefined; - - if (embeddedThoughtSignature) { - shouldUseEmbeddedSignature = false; - } + // #11510: each functionCall part carries its OWN resolved + // thoughtSignature — a parallel (multi tool_calls) turn can have a + // real, individually-valid signature per tool call, and Gemini 3.x + // rejects the request if any functionCall in the turn is missing + // one. Previously only the first functionCall of the message kept + // its signature; this dropped valid signatures for every + // subsequent parallel tool call in the same turn. + const embeddedThoughtSignature = signatureForToolCall; // Gemini expects the signature on the functionCall part itself. // If we are in a mode where missing signatures cause 400s (and we couldn't find one), diff --git a/tests/unit/issue-11510-parallel-tool-thought-signature-openai.test.ts b/tests/unit/issue-11510-parallel-tool-thought-signature-openai.test.ts new file mode 100644 index 0000000000..d473056baf --- /dev/null +++ b/tests/unit/issue-11510-parallel-tool-thought-signature-openai.test.ts @@ -0,0 +1,78 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { openaiToGeminiRequest } from "../../open-sse/translator/request/openai-to-gemini.ts"; +import { + storeGeminiThoughtSignature, + buildGeminiThoughtSignatureKey, +} from "../../open-sse/services/geminiThoughtSignatureStore.ts"; + +// Mirror of tests/unit/issue-11510-parallel-tool-thought-signature.test.ts for the +// OpenAI-protocol → Gemini path (openai-to-gemini.ts). The same +// shouldUseEmbeddedSignature/firstPersistedSignature gating dropped a resolved, +// individually-valid thoughtSignature for every tool_calls[] entry after the +// first one in a parallel (multi tool_calls) assistant turn, reproducing +// Gemini 3.x's HTTP 400 "Function call is missing a thought_signature in +// functionCall parts" (#11510). +test("openai→gemini must attach EACH resolved thoughtSignature on a parallel (multi tool_calls) turn (#11510)", () => { + const ns = "conn-11510-openai-parallel"; + const toolId1 = "call_11510_first"; + const toolId2 = "call_11510_second_webfetch"; + const sig1 = "SIG_11510_OPENAI_FIRST"; + const sig2 = "SIG_11510_OPENAI_SECOND"; + + storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId1), sig1); + storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId2), sig2); + + const result = openaiToGeminiRequest( + "gemini-3.5-flash", + { + messages: [ + { + role: "assistant", + content: null, + tool_calls: [ + { + id: toolId1, + type: "function", + function: { name: "default_api:Read", arguments: JSON.stringify({ path: "/a" }) }, + }, + { + id: toolId2, + type: "function", + function: { + name: "default_api:WebFetch", + arguments: JSON.stringify({ url: "https://example.com" }), + }, + }, + ], + }, + { role: "tool", tool_call_id: toolId1, content: "file a" }, + { role: "tool", tool_call_id: toolId2, content: "fetched" }, + ], + }, + false, + { _signatureNamespace: ns } + ) as { contents: Array<{ role: string; parts: Array> }> }; + + 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 fcParts = modelTurn!.parts.filter((p) => p.functionCall) as Array<{ + thoughtSignature?: string; + functionCall: { name: string }; + }>; + assert.equal(fcParts.length, 2, "both tool_calls entries must be emitted as native functionCall"); + + const webFetchPart = fcParts.find((p) => p.functionCall.name.includes("WebFetch")); + assert.ok(webFetchPart, "WebFetch functionCall part must be present"); + + assert.equal( + webFetchPart!.thoughtSignature, + sig2, + "second functionCall in a parallel tool_calls turn must keep its own resolved " + + "thoughtSignature, or Gemini 3.x rejects the request with HTTP 400 " + + "'Function call is missing a thought_signature in functionCall parts'" + ); +}); diff --git a/tests/unit/issue-11510-parallel-tool-thought-signature.test.ts b/tests/unit/issue-11510-parallel-tool-thought-signature.test.ts new file mode 100644 index 0000000000..f6cbd8540e --- /dev/null +++ b/tests/unit/issue-11510-parallel-tool-thought-signature.test.ts @@ -0,0 +1,85 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { claudeToGeminiRequest } from "../../open-sse/translator/request/claude-to-gemini.ts"; +import { + storeGeminiThoughtSignature, + buildGeminiThoughtSignatureKey, +} from "../../open-sse/services/geminiThoughtSignatureStore.ts"; + +// Repro for GitHub issue #11510: Claude Code (or any client) issuing a PARALLEL +// tool-call turn (2+ tool_use blocks in one assistant message) against a Gemini +// 3.x thinking model. Gemini attaches (and requires) an individual +// thoughtSignature on EVERY functionCall part in a multi-call turn, and OmniRoute +// stores one per tool_use id (gemini-to-claude.ts reads `part.thoughtSignature` +// per-part, not just a single pending value). But claude-to-gemini.ts's +// `shouldUseEmbeddedSignature` flag strips the signature from every functionCall +// after the first one in the SAME assistant message, even when a real resolved +// signature exists for it — reproducing Gemini's exact HTTP 400: +// "Function call is missing a thought_signature in functionCall parts." +test("claude→gemini must attach EACH resolved thoughtSignature on a parallel (multi tool_use) turn (#11510)", () => { + const ns = "conn-11510-parallel"; + const toolId1 = "toolu_11510_first"; + const toolId2 = "toolu_11510_second_webfetch"; + const sig1 = "SIG_11510_FIRST"; + const sig2 = "SIG_11510_SECOND"; + + // Simulate what gemini-to-claude.ts really stores today: a distinct, + // individually-valid signature per tool_use id, because Gemini attached one + // to each functionCall part of the original response turn. + storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId1), sig1); + storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId2), sig2); + + const result = claudeToGeminiRequest( + "gemini-3.5-flash", + { + messages: [ + { + role: "assistant", + content: [ + { type: "tool_use", id: toolId1, name: "default_api:Read", input: { path: "/a" } }, + { + type: "tool_use", + id: toolId2, + name: "default_api:WebFetch", + input: { url: "https://example.com" }, + }, + ], + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: toolId1, content: "file a" }, + { type: "tool_result", tool_use_id: toolId2, content: "fetched" }, + ], + }, + ], + }, + false, + { _signatureNamespace: ns } + ) as { contents: Array<{ role: string; parts: Array> }> }; + + 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 fcParts = modelTurn!.parts.filter((p) => p.functionCall) as Array<{ + thoughtSignature?: string; + functionCall: { name: string }; + }>; + assert.equal(fcParts.length, 2, "both tool_use blocks must be emitted as native functionCall"); + + const webFetchPart = fcParts.find((p) => p.functionCall.name.includes("WebFetch")); + assert.ok(webFetchPart, "WebFetch functionCall part must be present"); + + // THIS is the reported bug: the second tool_use in the turn has a real, + // resolved thoughtSignature (sig2) available, but the translator drops it + // because it is not the first functionCall in the message. + assert.equal( + webFetchPart!.thoughtSignature, + sig2, + "second functionCall in a parallel tool-call turn must keep its own resolved " + + "thoughtSignature, or Gemini 3.x rejects the request with HTTP 400 " + + "'Function call is missing a thought_signature in functionCall parts'" + ); +});