fix(sse): preserve Gemini thought_signature on Claude Desktop tool turns

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
This commit is contained in:
Prudhvivuda
2026-07-30 16:31:44 -04:00
parent 9b3efef806
commit 8a3888e510
5 changed files with 404 additions and 23 deletions

View File

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

View File

@@ -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 `<connectionId>:<toolUseId>`) 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<Record<string, unknown>>;
@@ -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<string, string> = {};
const resolvedSignatures = new Map<string, string>();
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 });
}
}

View File

@@ -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,

View File

@@ -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<Record<string, unknown>> }>;
};
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<Record<string, unknown>> }>;
};
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<Record<string, unknown>> }> };
const body = JSON.stringify(result);
assert.ok(
body.includes(signature),
"direct CLAUDE→GEMINI translateRequest must thread _signatureNamespace so cache lookup succeeds"
);
});

View File

@@ -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<string, unknown>;
@@ -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;