From ef10658010ee74544ef5fc45fbb7e21c221f31ce Mon Sep 17 00:00:00 2001 From: Sam280903 Date: Tue, 4 Aug 2026 15:33:04 -0300 Subject: [PATCH] fix(mcp): persist and re-attach Gemini thoughtSignature on the direct Claude<->Gemini path The direct Claude<->Gemini translator (claude-to-gemini.ts / gemini-to-claude.ts) never persisted the thoughtSignature Gemini returns on functionCall parts, and never re-attached one on the next turn. Gemini 3+/2.5 strictly reject a native functionCall part with no signature (400), which surfaces whenever a combo falls back onto a Gemini model mid-conversation (the fallback tool_use never went through Gemini, so no signature exists for it). - gemini-to-claude.ts: store the signature (keyed by tool_use id + connection namespace) when Gemini's response carries one, mirroring the existing gemini-to-openai.ts hub-path behavior. - claude-to-gemini.ts: resolve a stored signature for historical tool_use blocks; when none exists and the target model requires one, downgrade the tool_use/tool_result pair to inert text instead of sending a signature-less native part, matching the "context" fallback already used by the OpenAI hub path (#3358) rather than the removed fake-signature injection. --- .../translator/request/claude-to-gemini.ts | 68 +++++++++++++++--- .../translator/response/gemini-to-claude.ts | 22 ++++++ .../unit/translator-claude-to-gemini.test.ts | 70 +++++++++++++++++-- .../translator-resp-gemini-to-claude.test.ts | 32 +++++++++ .../unit/vertex-functioncall-id-3440.test.ts | 8 +++ 5 files changed, 184 insertions(+), 16 deletions(-) diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 21d81922716c..725e476d2425 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -5,10 +5,17 @@ import { tryParseJSON, cleanJSONSchemaForAntigravity, } from "../helpers/geminiHelper.ts"; -import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts"; import { capMaxOutputTokens, capThinkingBudget } from "../../../src/lib/modelCapabilities.ts"; import { getModelSpec } from "../../../src/shared/constants/modelSpecs.ts"; +import { + buildGeminiThoughtSignatureKey, + getGeminiThoughtSignature, +} from "../../services/geminiThoughtSignatureStore.ts"; +import { + buildInertHistoricalToolCallText, + buildInertHistoricalToolResponseText, +} from "./openai-to-gemini/helpers.ts"; /** * Direct Claude → Gemini request translator. @@ -26,6 +33,29 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { // is scoped to the routed vertex provider only (threaded via credentials._provider). const provider = credentials && typeof credentials === "object" ? credentials._provider : null; const stripFunctionCallId = provider === "vertex" || provider === "vertex-partner"; + const signatureNamespace = + credentials && + typeof credentials === "object" && + typeof (credentials as Record)._signatureNamespace === "string" + ? ((credentials as Record)._signatureNamespace as string) + : null; + // All modern Gemini models (2.5+, 3.x, pro-agent, etc.) use thinking by default + // and require a real thoughtSignature on functionCall parts in a multi-turn + // tool-call history — sending one without a signature returns a strict 400. + // Matches the isThinkingGemini heuristic used by the Antigravity path + // (openai-to-gemini.ts) since Gemini's requirement is the same regardless of + // which translator hop reaches it. + const modelLower = model.toLowerCase(); + const requiresThoughtSignature = + modelLower.includes("gemini-3") || + modelLower.includes("gemini-2.5") || + modelLower.includes("thinking") || + modelLower.includes("gemini-pro"); + // tool_use ids downgraded to inert text because no real signature was found + // (e.g. the call originated from a different model via combo fallback) — the + // matching tool_result must be downgraded the same way so Gemini never sees + // a functionResponse referencing a functionCall id that isn't a native part. + const contextualizedToolUseIds = new Set(); const result: { model: string; contents: Array>; @@ -114,8 +144,24 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { } break; - case "tool_use": + case "tool_use": { + const signature = getGeminiThoughtSignature( + buildGeminiThoughtSignatureKey(signatureNamespace, block.id) + ); + if (!signature && requiresThoughtSignature) { + // No real signature available (e.g. this tool_use was made by a + // different model before a combo fallback landed on Gemini) and + // this model strictly validates it — represent it as inert text + // instead of a native functionCall part, matching the "context" + // fallback the OpenAI hub path uses for the same situation (#3358). + contextualizedToolUseIds.add(block.id); + parts.push({ + text: buildInertHistoricalToolCallText(block.name, block.input || {}), + }); + break; + } parts.push({ + ...(signature ? { thoughtSignature: signature } : {}), functionCall: { ...(stripFunctionCallId ? {} : { id: block.id }), name: sanitizeToolName(block.name), @@ -123,6 +169,7 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { }, }); break; + } case "tool_result": { let content = block.content; @@ -137,6 +184,15 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { } else if (typeof parsedContent !== "object") { parsedContent = { result: parsedContent }; } + if (contextualizedToolUseIds.has(block.tool_use_id)) { + parts.push({ + text: buildInertHistoricalToolResponseText( + toolUseNames[block.tool_use_id] || "unknown", + parsedContent + ), + }); + break; + } parts.push({ functionResponse: { ...(stripFunctionCallId ? {} : { id: block.tool_use_id }), @@ -167,14 +223,6 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { if (parts.length > 0) { // Map Claude roles to Gemini roles const geminiRole = msg.role === "assistant" ? "model" : "user"; - - // Gemini 3+ expects the signature on all functionCall parts in a tool-call - // batch. If there is no real signature, we don't inject a fake one because - // Gemini API strictly validates it and returns 400. - if (geminiRole === "model") { - // No operation needed since we no longer inject fake signatures. - } - result.contents.push({ role: geminiRole, parts }); } } diff --git a/open-sse/translator/response/gemini-to-claude.ts b/open-sse/translator/response/gemini-to-claude.ts index 2307edd61e82..3e6a397af029 100644 --- a/open-sse/translator/response/gemini-to-claude.ts +++ b/open-sse/translator/response/gemini-to-claude.ts @@ -2,6 +2,10 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { isAbortFinishReason } from "../../utils/finishReason.ts"; import { REVERSE_MAP } from "../../services/claudeCodeToolRemapper.ts"; +import { + buildGeminiThoughtSignatureKey, + storeGeminiThoughtSignature, +} from "../../services/geminiThoughtSignatureStore.ts"; function normalizeToolName(name: string): string { return REVERSE_MAP[name] ?? name; @@ -56,6 +60,16 @@ export function geminiToClaudeResponse(chunk, state) { const hasThoughtSig = part.thoughtSignature || part.thought_signature; const isThought = part.thought === true; + // Gemini/Antigravity can emit thoughtSignature as a standalone part + // immediately before the functionCall part, or co-located on the same + // part. Keep it pending so the functionCall handler below can persist + // it keyed to the tool_use id — otherwise the next turn has no real + // signature to re-attach and historical tool calls must be degraded + // to inert text (or rejected outright by strict Gemini 3+ validation). + if (hasThoughtSig && typeof hasThoughtSig === "string") { + state.pendingThoughtSignature = hasThoughtSig; + } + // Thinking content → thinking block (always open+close per chunk) if (isThought && part.text) { // Close any open text block first @@ -91,6 +105,14 @@ export function geminiToClaudeResponse(chunk, state) { const idx = state.contentBlockIndex++; const toolId = fc.id || `toolu_${Date.now()}_${idx}`; + if (state.pendingThoughtSignature) { + storeGeminiThoughtSignature( + buildGeminiThoughtSignatureKey(state.signatureNamespace, toolId), + state.pendingThoughtSignature + ); + state.pendingThoughtSignature = null; + } + results.push({ type: "content_block_start", index: idx, diff --git a/tests/unit/translator-claude-to-gemini.test.ts b/tests/unit/translator-claude-to-gemini.test.ts index d82e6b427630..0ec468d57498 100644 --- a/tests/unit/translator-claude-to-gemini.test.ts +++ b/tests/unit/translator-claude-to-gemini.test.ts @@ -1,10 +1,17 @@ -import test from "node:test"; +import test, { beforeEach } from "node:test"; import assert from "node:assert/strict"; const { claudeToGeminiRequest } = await import("../../open-sse/translator/request/claude-to-gemini.ts"); const { DEFAULT_SAFETY_SETTINGS } = await import("../../open-sse/translator/helpers/geminiHelper.ts"); +const { storeGeminiThoughtSignature, clearGeminiThoughtSignatureMemoryForTests } = await import( + "../../open-sse/services/geminiThoughtSignatureStore.ts" +); + +beforeEach(() => { + clearGeminiThoughtSignatureMemoryForTests(); +}); type UnknownRecord = Record; @@ -34,6 +41,9 @@ function getFunctionResponse(part: unknown) { } test("Claude -> Gemini maps system, thinking, tool use, tool result and tools", () => { + // A real stored signature (as gemini-to-claude.ts persists from Gemini's own + // response) means the historical tool_use can be re-sent as a native part. + storeGeminiThoughtSignature("tu_1", "sig-tu-1"); const result = claudeToGeminiRequest( "gemini-2.5-pro", { @@ -82,6 +92,7 @@ test("Claude -> Gemini maps system, thinking, tool use, tool result and tools", assert.equal(result.contents[0].role, "model"); assert.deepEqual(result.contents[0].parts[0] as any, { thought: true, text: "need tool" }); assert.deepEqual(result.contents[0].parts[1] as any, { + thoughtSignature: "sig-tu-1", functionCall: { id: "tu_1", name: "weather", args: { city: "Tokyo" } }, }); assert.deepEqual(result.contents[1].parts[0] as any, { @@ -162,14 +173,58 @@ test("Claude -> Gemini converts text and base64 images to Gemini parts", () => { ]); }); -test("Claude -> Gemini injects a fallback thoughtSignature on tool-call batches without thinking", () => { +test("Claude -> Gemini downgrades a signature-less historical tool_use to inert text on thinking-capable models (fixes 400 on combo fallback)", () => { + // Reproduces the production bug: a combo falls back from another model (e.g. + // Claude) to Gemini mid-conversation. The prior tool_use never went through + // Gemini, so no real thoughtSignature was ever stored for it. Gemini 3+/2.5 + // strictly rejects a native functionCall part with no signature (400), so it + // must be represented as inert text instead — never sent natively. const result = claudeToGeminiRequest( - "gemini-2.5-flash", + "gemini-3.1-flash-lite", + { + messages: [ + { + role: "assistant", + content: [ + { type: "tool_use", id: "tu_fallback_1", name: "read_file", input: { path: "/a" } }, + ], + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "tu_fallback_1", content: "file contents" }, + ], + }, + ], + }, + false + ); + + assert.equal(result.contents.length, 2); + assert.equal(result.contents[0].role, "model"); + const toolUsePart = result.contents[0].parts[0] as UnknownRecord; + assert.equal(toolUsePart.functionCall, undefined); + assert.equal(toolUsePart.thoughtSignature, undefined); + assert.match(toolUsePart.text as string, /^\[tool_history_call: read_file\]/); + + // The paired tool_result must be downgraded the same way — Gemini must never + // see a functionResponse referencing a functionCall id that isn't a native part. + assert.equal(result.contents[1].role, "user"); + const toolResultPart = result.contents[1].parts[0] as UnknownRecord; + assert.equal(toolResultPart.functionResponse, undefined); + assert.match(toolResultPart.text as string, /^\[tool_history_result: read_file\]/); +}); + +test("Claude -> Gemini keeps native functionCall passthrough for non-thinking models even without a signature", () => { + // Older Gemini models (< 2.5, no "gemini-pro"/"thinking" in the id) never + // required thought_signature — preserve the pre-fix passthrough for them. + const result = claudeToGeminiRequest( + "gemini-2.0-flash", { messages: [ { role: "assistant", - content: [{ type: "tool_use", id: "tu_1", name: "read_file", input: {} }], + content: [{ type: "tool_use", id: "tu_legacy_1", name: "read_file", input: {} }], }, ], }, @@ -178,13 +233,16 @@ test("Claude -> Gemini injects a fallback thoughtSignature on tool-call batches assert.equal(result.contents.length, 1); assert.equal(result.contents[0].role, "model"); - assert.equal((result.contents[0].parts[0] as any).functionCall.name, "read_file"); - assert.equal((result.contents[0].parts[0] as any).thoughtSignature, undefined); + assert.equal(getFunctionCall(result.contents[0].parts[0]).name, "read_file"); + assert.equal((result.contents[0].parts[0] as UnknownRecord).thoughtSignature, undefined); }); test("Claude -> Gemini sanitizes long tool names and exposes a restore map", () => { const longToolName = "mcp__filesystem__read_multiple_files_with_validation_and_metadata_bundle_v2"; + // Not under test here — prime a signature so the tool-name sanitization + // assertions below aren't affected by the thoughtSignature guard. + storeGeminiThoughtSignature("tu_long_1", "sig-long-1"); const result = claudeToGeminiRequest( "gemini-2.5-pro", { diff --git a/tests/unit/translator-resp-gemini-to-claude.test.ts b/tests/unit/translator-resp-gemini-to-claude.test.ts index d1e46da020d4..4e63824842ba 100644 --- a/tests/unit/translator-resp-gemini-to-claude.test.ts +++ b/tests/unit/translator-resp-gemini-to-claude.test.ts @@ -142,6 +142,38 @@ test("Gemini -> Claude stream: STOP after prior tool use still maps to tool_use" assert.equal(result[1].type, "message_stop"); }); +test("Gemini -> Claude stream: stores thoughtSignature from a standalone part preceding functionCall", async () => { + const { getGeminiThoughtSignature, clearGeminiThoughtSignatureMemoryForTests } = await import( + "../../open-sse/services/geminiThoughtSignatureStore.ts" + ); + clearGeminiThoughtSignatureMemoryForTests(); + + const state: { signatureNamespace: string; pendingThoughtSignature?: string | null } = { + signatureNamespace: "conn-claude-1", + }; + const result = geminiToClaudeResponse( + { + responseId: "resp-sig-1", + modelVersion: "gemini-3.1-flash-lite", + candidates: [ + { + content: { + parts: [ + { thoughtSignature: "sig-claude-1" }, + { functionCall: { id: "toolu_sig_1", name: "read_file", args: { path: "/a" } } }, + ], + }, + }, + ], + }, + state + ); + + assert.equal(result[1].content_block.id, "toolu_sig_1"); + assert.equal(state.pendingThoughtSignature, null); + assert.equal(getGeminiThoughtSignature("conn-claude-1:toolu_sig_1"), "sig-claude-1"); +}); + test("Gemini -> Claude stream: response wrapper is supported and promptFeedback-only chunk is ignored", () => { const wrapped = geminiToClaudeResponse( { diff --git a/tests/unit/vertex-functioncall-id-3440.test.ts b/tests/unit/vertex-functioncall-id-3440.test.ts index 8609755c3660..ca5e03d51bb8 100644 --- a/tests/unit/vertex-functioncall-id-3440.test.ts +++ b/tests/unit/vertex-functioncall-id-3440.test.ts @@ -17,6 +17,9 @@ const { openaiToGeminiRequest } = await import( const { claudeToGeminiRequest } = await import( "../../open-sse/translator/request/claude-to-gemini.ts" ); +const { storeGeminiThoughtSignature } = await import( + "../../open-sse/services/geminiThoughtSignatureStore.ts" +); type UnknownRecord = Record; @@ -138,6 +141,11 @@ test("#3440 Claude->Gemini: vertex provider omits id from functionCall and funct }); test("#3440 Claude->Gemini: no provider hint PRESERVES id (default, non-vertex)", () => { + // gemini-2.5-pro requires a real thoughtSignature on historical tool_use + // parts; without one the fix in claude-to-gemini.ts downgrades the call to + // inert text (never a bare functionCall part) to avoid a strict 400. Prime + // a signature so this id-preservation assertion stays isolated from that. + storeGeminiThoughtSignature("tu_weather_1", "sig-weather-1"); const result = claudeToGeminiRequest("gemini-2.5-pro", CLAUDE_TOOL_BODY, false); assert.equal(findFunctionCall(result)?.id, "tu_weather_1"); });