From 8a3888e510ec7a42e406215aafe5ac8f455f7dbd Mon Sep 17 00:00:00 2001 From: Prudhvivuda Date: Thu, 30 Jul 2026 16:31:44 -0400 Subject: [PATCH] fix(sse): preserve Gemini thought_signature on Claude Desktop tool turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude→Gemini direct translators dropped thoughtSignature, so Gemini 3 tool follow-ups returned 400. Store on the response path, re-attach (or context-fallback) on the request path, and thread signatureNamespace. Closes #8979 --- open-sse/translator/index.ts | 9 +- .../translator/request/claude-to-gemini.ts | 83 +++++- .../translator/response/gemini-to-claude.ts | 37 +++ ...aude-gemini-thought-signature-8979.test.ts | 260 ++++++++++++++++++ .../unit/translator-claude-to-gemini.test.ts | 38 ++- 5 files changed, 404 insertions(+), 23 deletions(-) create mode 100644 tests/unit/claude-gemini-thought-signature-8979.test.ts diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index d7f107fbb7..7f58049b8a 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -258,11 +258,16 @@ export function translateRequest( if (directTranslator && sourceFormat !== FORMATS.OPENAI && targetFormat !== FORMATS.OPENAI) { // Thread the routed provider id so target translators can apply provider-specific // quirks (e.g. Vertex rejects function_call.id — #3440). + // Also thread signatureNamespace so Claude→Gemini can re-attach cached + // thoughtSignature on tool-use history (#8979 / #2504 parity with the hub path). + const hasNs = options?.signatureNamespace != null; + const hasProvider = provider != null; const directCredentials = - provider != null + hasNs || hasProvider ? { ...(credentials && typeof credentials === "object" ? credentials : {}), - _provider: provider, + ...(hasProvider ? { _provider: provider } : {}), + ...(hasNs ? { _signatureNamespace: options.signatureNamespace } : {}), } : credentials; result = directTranslator(model, result, stream, directCredentials); diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 21d8192271..89807dfeca 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -5,10 +5,14 @@ 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, + resolveGeminiThoughtSignature, +} from "../../services/geminiThoughtSignatureStore.ts"; +import { buildHistoricalToolResultContext } from "./openai-to-gemini/helpers.ts"; /** * Direct Claude → Gemini request translator. @@ -26,6 +30,16 @@ 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"; + // Thread the signature namespace so a thinking model's thoughtSignature (cached on the + // Gemini→Claude response turn under `:`) is found and + // re-attached on the follow-up Claude→Gemini request. Without this, Claude Desktop + // combo turns hit HTTP 400 "missing thought_signature" (#8979 / #2504 parity). + const signatureNamespace = + credentials && + typeof credentials === "object" && + typeof credentials._signatureNamespace === "string" + ? credentials._signatureNamespace + : null; const result: { model: string; contents: Array>; @@ -81,14 +95,30 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { } } - // ── Build tool_use name lookup (for tool_result matching) ────── - const toolUseNames = {}; + // ── Build tool_use name lookup + resolve thought signatures ──── + // Standard Gemini rejects signature-less native functionCall parts with + // HTTP 400 (#8979). Match the OPENAI→GEMINI "context" policy (#3688): only + // emit native functionCall/functionResponse when a real signature is + // available; otherwise represent history as context text. + const toolUseNames: Record = {}; + const resolvedSignatures = new Map(); if (body.messages && Array.isArray(body.messages)) { for (const msg of body.messages) { if (msg.role === "assistant" && Array.isArray(msg.content)) { for (const block of msg.content) { if (block.type === "tool_use" && block.id && block.name) { toolUseNames[block.id] = sanitizeToolName(block.name); + const clientSignature = + (typeof block.thoughtSignature === "string" && block.thoughtSignature) || + (typeof block.thought_signature === "string" && block.thought_signature) || + null; + const resolved = resolveGeminiThoughtSignature( + buildGeminiThoughtSignatureKey(signatureNamespace, block.id), + clientSignature + ); + if (typeof resolved === "string" && resolved.length > 0) { + resolvedSignatures.set(block.id, resolved); + } } } } @@ -99,6 +129,7 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { if (body.messages && Array.isArray(body.messages)) { for (const msg of body.messages) { const parts = []; + let shouldUseEmbeddedSignature = true; if (Array.isArray(msg.content)) { for (const block of msg.content) { @@ -114,8 +145,25 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { } break; - case "tool_use": + case "tool_use": { + const signatureForToolCall = resolvedSignatures.get(block.id); + // Signature-less historical tool_use → omit native functionCall + // (context mode). Matching tool_result becomes context text below. + if (!signatureForToolCall) { + break; + } + + const embeddedThoughtSignature = shouldUseEmbeddedSignature + ? signatureForToolCall + : undefined; + if (embeddedThoughtSignature) { + shouldUseEmbeddedSignature = false; + } + parts.push({ + ...(embeddedThoughtSignature + ? { thoughtSignature: embeddedThoughtSignature } + : {}), functionCall: { ...(stripFunctionCallId ? {} : { id: block.id }), name: sanitizeToolName(block.name), @@ -123,6 +171,7 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { }, }); break; + } case "tool_result": { let content = block.content; @@ -137,10 +186,24 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { } else if (typeof parsedContent !== "object") { parsedContent = { result: parsedContent }; } + + const toolUseId = block.tool_use_id; + const name = toolUseNames[toolUseId] || "unknown"; + + // Signature-less history: represent as context text so Gemini 3+ + // does not reject a native functionResponse without a matching + // signed functionCall (#8979 / #3688). + if (!resolvedSignatures.has(toolUseId)) { + parts.push({ + text: buildHistoricalToolResultContext(name, content), + }); + break; + } + parts.push({ functionResponse: { - ...(stripFunctionCallId ? {} : { id: block.tool_use_id }), - name: toolUseNames[block.tool_use_id] || "unknown", + ...(stripFunctionCallId ? {} : { id: toolUseId }), + name, response: { result: parsedContent }, }, }); @@ -167,14 +230,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 2307edd61e..61d4f4283a 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,12 @@ export function geminiToClaudeResponse(chunk, state) { const hasThoughtSig = part.thoughtSignature || part.thought_signature; const isThought = part.thought === true; + // Capture thoughtSignature so the next functionCall (or same-part call) + // can persist it for Claude→Gemini follow-up turns (#8979 / #2504 parity). + if (typeof hasThoughtSig === "string" && hasThoughtSig.length > 0) { + state.pendingThoughtSignature = hasThoughtSig; + } + // Thinking content → thinking block (always open+close per chunk) if (isThought && part.text) { // Close any open text block first @@ -78,6 +88,17 @@ export function geminiToClaudeResponse(chunk, state) { continue; } + // Standalone thoughtSignature part (no text / no functionCall): keep + // pending and wait for the following functionCall — do not emit to Claude. + if ( + typeof hasThoughtSig === "string" && + hasThoughtSig.length > 0 && + (part.text === undefined || part.text === "") && + !part.functionCall + ) { + continue; + } + // Function call → tool_use block if (part.functionCall) { // Close any open text block first @@ -91,6 +112,22 @@ export function geminiToClaudeResponse(chunk, state) { const idx = state.contentBlockIndex++; const toolId = fc.id || `toolu_${Date.now()}_${idx}`; + const signatureForToolCall = + (typeof hasThoughtSig === "string" && hasThoughtSig.length > 0 + ? hasThoughtSig + : null) || + (typeof state.pendingThoughtSignature === "string" && + state.pendingThoughtSignature.length > 0 + ? state.pendingThoughtSignature + : null); + if (signatureForToolCall) { + storeGeminiThoughtSignature( + buildGeminiThoughtSignatureKey(state.signatureNamespace, toolId), + signatureForToolCall + ); + state.pendingThoughtSignature = null; + } + results.push({ type: "content_block_start", index: idx, diff --git a/tests/unit/claude-gemini-thought-signature-8979.test.ts b/tests/unit/claude-gemini-thought-signature-8979.test.ts new file mode 100644 index 0000000000..fd5822cb58 --- /dev/null +++ b/tests/unit/claude-gemini-thought-signature-8979.test.ts @@ -0,0 +1,260 @@ +/** + * #8979: Claude Desktop → Gemini tool calls fail with + * "Function call is missing a thought_signature in functionCall parts". + * + * Claude clients hit the direct CLAUDE↔GEMINI translators (not the OpenAI hub + * path that already stores/reattaches signatures via #2504 / #3688). The direct + * path must: + * 1) Persist thoughtSignature from Gemini functionCall parts (response) + * 2) Re-attach it on the follow-up Claude→Gemini request (tool_use history) + * 3) Fall back to context text when no signature is available (avoid 400) + * 4) Thread credentials._signatureNamespace through translateRequest's direct path + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { claudeToGeminiRequest } = + await import("../../open-sse/translator/request/claude-to-gemini.ts"); +const { geminiToClaudeResponse } = + await import("../../open-sse/translator/response/gemini-to-claude.ts"); +const { translateRequest } = await import("../../open-sse/translator/index.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); +const { + buildGeminiThoughtSignatureKey, + storeGeminiThoughtSignature, + getGeminiThoughtSignature, + clearGeminiThoughtSignatures, +} = await import("../../open-sse/services/geminiThoughtSignatureStore.ts"); + +test.beforeEach(() => { + clearGeminiThoughtSignatures(); +}); + +test("gemini→claude stores thoughtSignature keyed by connection + tool_use id (#8979)", () => { + const ns = "conn-8979-store"; + const toolId = "toolu_8979_read"; + const signature = "SIG_8979_FROM_GEMINI"; + const state = { signatureNamespace: ns }; + + geminiToClaudeResponse( + { + responseId: "resp-8979", + modelVersion: "gemini-3.6-flash", + candidates: [ + { + content: { + parts: [ + { + thoughtSignature: signature, + functionCall: { + id: toolId, + name: "default_api_read", + args: { path: "/tmp/a" }, + }, + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + state + ); + + assert.equal( + getGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId)), + signature, + "direct Gemini→Claude path must persist thoughtSignature for the follow-up turn" + ); +}); + +test("gemini→claude stores thoughtSignature from a preceding standalone part (#8979)", () => { + const ns = "conn-8979-pending"; + const toolId = "toolu_8979_pending"; + const signature = "SIG_8979_PENDING_PART"; + const state = { signatureNamespace: ns }; + + geminiToClaudeResponse( + { + responseId: "resp-8979b", + modelVersion: "gemini-3.6-flash", + candidates: [ + { + content: { + parts: [ + { thoughtSignature: signature }, + { + functionCall: { + id: toolId, + name: "read_file", + args: {}, + }, + }, + ], + }, + }, + ], + }, + state + ); + + assert.equal( + getGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId)), + signature, + "standalone thoughtSignature part must be bound to the following functionCall" + ); +}); + +test("claude→gemini re-attaches cached thoughtSignature on tool_use history (#8979)", () => { + const ns = "conn-8979-reattach"; + const toolId = "toolu_8979_reattach"; + const signature = "SIG_8979_REATTACH"; + storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId), signature); + + const result = claudeToGeminiRequest( + "gemini-3.6-flash", + { + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: toolId, + name: "default_api:read", + input: { path: "/tmp/a" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolId, + content: "file contents", + }, + ], + }, + ], + }, + 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 model turn with functionCall"); + const fcPart = modelTurn.parts.find((p) => p.functionCall) as { + thoughtSignature?: string; + functionCall: { name: string }; + }; + assert.equal( + fcPart.thoughtSignature, + signature, + "cached thoughtSignature must be re-attached to functionCall" + ); + assert.ok( + result.contents.some((c) => c.parts?.some((p) => p.functionResponse)), + "signed tool_result must remain a native functionResponse" + ); +}); + +test("claude→gemini omits unsigned functionCall and uses context fallback (#8979 / #3688 parity)", () => { + const result = claudeToGeminiRequest( + "gemini-3.6-flash", + { + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_8979_unsigned", + name: "default_api:read", + input: { path: "/tmp/a" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_8979_unsigned", + content: "file contents", + }, + ], + }, + { role: "user", content: "summarize the file" }, + ], + }, + false, + { _signatureNamespace: "conn-8979-unsigned" } + ) as { + contents: Array<{ role: string; parts: Array> }>; + }; + + const modelWithFc = result.contents.find( + (c) => c.role === "model" && c.parts?.some((p) => p.functionCall) + ); + assert.equal( + modelWithFc, + undefined, + "must NOT emit signature-less functionCall (triggers Gemini HTTP 400)" + ); + + const body = JSON.stringify(result); + assert.ok( + body.includes("previous_tool_result_context"), + "signature-less tool_result must become context text" + ); + assert.ok(body.includes("file contents"), "context block must keep tool result content"); +}); + +test("translateRequest threads signatureNamespace on direct CLAUDE→GEMINI path (#8979)", () => { + const ns = "conn-8979-direct-ns"; + const toolId = "toolu_8979_direct"; + const signature = "SIG_8979_DIRECT_NS"; + storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId), signature); + + const result = translateRequest( + FORMATS.CLAUDE, + FORMATS.GEMINI, + "gemini-3.6-flash", + { + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: toolId, + name: "read_file", + input: {}, + }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: toolId, content: "ok" }], + }, + ], + }, + false, + null, + "gemini", + null, + { signatureNamespace: ns } + ) as { contents: Array<{ parts: Array> }> }; + + const body = JSON.stringify(result); + assert.ok( + body.includes(signature), + "direct CLAUDE→GEMINI translateRequest must thread _signatureNamespace so cache lookup succeeds" + ); +}); diff --git a/tests/unit/translator-claude-to-gemini.test.ts b/tests/unit/translator-claude-to-gemini.test.ts index d82e6b4276..0fd757b8e3 100644 --- a/tests/unit/translator-claude-to-gemini.test.ts +++ b/tests/unit/translator-claude-to-gemini.test.ts @@ -5,6 +5,15 @@ 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 { + buildGeminiThoughtSignatureKey, + storeGeminiThoughtSignature, + clearGeminiThoughtSignatures, +} = await import("../../open-sse/services/geminiThoughtSignatureStore.ts"); + +test.beforeEach(() => { + clearGeminiThoughtSignatures(); +}); type UnknownRecord = Record; @@ -34,6 +43,10 @@ function getFunctionResponse(part: unknown) { } test("Claude -> Gemini maps system, thinking, tool use, tool result and tools", () => { + // Native functionCall requires a cached thoughtSignature (#8979 / #3688). + const ns = "conn-claude-gemini-map"; + storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, "tu_1"), "SIG_MAP_WEATHER"); + const result = claudeToGeminiRequest( "gemini-2.5-pro", { @@ -72,7 +85,8 @@ test("Claude -> Gemini maps system, thinking, tool use, tool result and tools", top_p: 0.8, thinking: { type: "enabled", budget_tokens: 512 }, }, - false + false, + { _signatureNamespace: ns } ); assert.deepEqual(result.systemInstruction, { @@ -82,6 +96,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_MAP_WEATHER", functionCall: { id: "tu_1", name: "weather", args: { city: "Tokyo" } }, }); assert.deepEqual(result.contents[1].parts[0] as any, { @@ -162,7 +177,10 @@ 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 omits unsigned functionCall instead of injecting a fake thoughtSignature (#8979)", () => { + // After #1410 / #8979: never inject a fake signature. Without a cached + // thoughtSignature, native functionCall parts are omitted (context mode) + // so Gemini 3+ does not return HTTP 400. const result = claudeToGeminiRequest( "gemini-2.5-flash", { @@ -176,15 +194,20 @@ test("Claude -> Gemini injects a fallback thoughtSignature on tool-call batches false ); - 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(result.contents.length, 0); + assert.equal( + JSON.stringify(result).includes('"functionCall"'), + false, + "signature-less tool_use must not become a native functionCall" + ); }); 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"; + const ns = "conn-claude-gemini-long"; + storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, "tu_long_1"), "SIG_LONG_TOOL"); + const result = claudeToGeminiRequest( "gemini-2.5-pro", { @@ -214,7 +237,8 @@ test("Claude -> Gemini sanitizes long tool names and exposes a restore map", () }, ], }, - false + false, + { _signatureNamespace: ns } ); const sanitizedToolName = (result as any).tools[0].functionDeclarations[0].name as string;