Compare commits

...

1 Commits

Author SHA1 Message Date
Markus Hartung
e229d597f2 fix(sse): keep each functionCall's own thoughtSignature on parallel Gemini tool-call turns (#11510)
claude-to-gemini.ts and openai-to-gemini.ts only re-attached a resolved thoughtSignature to the FIRST functionCall of an assistant message, silently dropping it for every subsequent tool_use/tool_calls entry in the same parallel (multi tool-call) turn -- even when a real, previously-stored signature existed for it. Gemini 3.x then rejected the request with HTTP 400 'Function call is missing a thought_signature in functionCall parts'.

Each functionCall part now carries its own resolved thoughtSignature unconditionally, instead of only the first one in the message.

Regression tests: tests/unit/issue-11510-parallel-tool-thought-signature.test.ts (claude-to-gemini) and tests/unit/issue-11510-parallel-tool-thought-signature-openai.test.ts (openai-to-gemini), both proven RED against unfixed code and GREEN after the fix.
2026-08-26 13:15:02 -03:00
5 changed files with 180 additions and 19 deletions

View File

@@ -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)).

View File

@@ -136,7 +136,6 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
const omittedToolCallIds = new Set<string>();
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),

View File

@@ -383,7 +383,6 @@ function openaiToGeminiBase(
if (toolCalls && Array.isArray(toolCalls)) {
const toolCallIds: string[] = [];
const resolvedSignatures = new Map<string, string>();
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),

View File

@@ -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<Record<string, unknown>> }> };
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'"
);
});

View File

@@ -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<Record<string, unknown>> }> };
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'"
);
});