diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index d7f107fbb7..082fbca2ac 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -256,13 +256,17 @@ export function translateRequest( // Check for direct translation path first (e.g., Claude → Gemini) const directTranslator = getRequestTranslator(sourceFormat, targetFormat); 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). + // Thread the routed provider id AND the per-connection signature namespace so + // direct target translators can apply the same quirks as the hub path — notably + // Claude→Gemini needs _signatureNamespace to replay Gemini 3+ thought_signature + // on multi-turn tool calls (#2504, direct-path port). + const directHasNs = options?.signatureNamespace != null; const directCredentials = - provider != null + provider != null || directHasNs ? { ...(credentials && typeof credentials === "object" ? credentials : {}), - _provider: provider, + ...(provider != null ? { _provider: provider } : {}), + ...(directHasNs ? { _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..e82a031994 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -7,6 +7,10 @@ import { } from "../helpers/geminiHelper.ts"; import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts"; +import { + buildGeminiThoughtSignatureKey, + resolveGeminiThoughtSignature, +} from "../../services/geminiThoughtSignatureStore.ts"; import { capMaxOutputTokens, capThinkingBudget } from "../../../src/lib/modelCapabilities.ts"; import { getModelSpec } from "../../../src/shared/constants/modelSpecs.ts"; @@ -26,6 +30,11 @@ 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"; + // Per-connection namespace so cached thought_signatures don't collide across + // conversations (#2504). Threaded via credentials._signatureNamespace by the + // dispatcher (connectionId) when translateRequest runs the direct path. + const signatureNamespace = + credentials && typeof credentials === "object" ? credentials._signatureNamespace : null; const result: { model: string; contents: Array>; @@ -97,6 +106,9 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { // ── Convert messages ─────────────────────────────────────────── if (body.messages && Array.isArray(body.messages)) { + // Tool-ids whose functionCall was omitted (no stored thought_signature) so the + // matching tool_result becomes text instead of a Gemini-400'd functionResponse. + const omittedToolCallIds = new Set(); for (const msg of body.messages) { const parts = []; @@ -114,15 +126,30 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { } break; - case "tool_use": - parts.push({ - functionCall: { - ...(stripFunctionCallId ? {} : { id: block.id }), - name: sanitizeToolName(block.name), - args: block.input || {}, - }, - }); + case "tool_use": { + // Gemini 3+ strictly validates thought_signature on every functionCall + // part in a multi-turn tool-call batch and returns 400 without it. Resolve + // the stored signature captured on the prior Gemini response (keyed by this + // tool id) and replay it. When no signature is available (historical tool + // calls predating the store), omit the functionCall and convert the matching + // tool_result to text — mirrors openai→gemini context mode (#2504). + const thoughtSignature = resolveGeminiThoughtSignature( + buildGeminiThoughtSignatureKey(signatureNamespace, block.id) + ); + if (thoughtSignature) { + parts.push({ + thoughtSignature, + functionCall: { + ...(stripFunctionCallId ? {} : { id: block.id }), + name: sanitizeToolName(block.name), + args: block.input || {}, + }, + }); + } else { + omittedToolCallIds.add(block.id); + } break; + } case "tool_result": { let content = block.content; @@ -137,13 +164,20 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { } else if (typeof parsedContent !== "object") { parsedContent = { result: parsedContent }; } - parts.push({ - functionResponse: { - ...(stripFunctionCallId ? {} : { id: block.tool_use_id }), - name: toolUseNames[block.tool_use_id] || "unknown", - response: { result: parsedContent }, - }, - }); + if (omittedToolCallIds.has(block.tool_use_id)) { + // Matching tool_use was omitted — emit this result as plain text so + // Gemini doesn't 400 a bare functionResponse without a matching + // functionCall carrying thought_signature. + parts.push({ text: JSON.stringify(parsedContent) }); + } else { + parts.push({ + functionResponse: { + ...(stripFunctionCallId ? {} : { id: block.tool_use_id }), + name: toolUseNames[block.tool_use_id] || "unknown", + response: { result: parsedContent }, + }, + }); + } break; } @@ -168,13 +202,6 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { // 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..9ad6e0af84 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,15 @@ export function geminiToClaudeResponse(chunk, state) { const hasThoughtSig = part.thoughtSignature || part.thought_signature; const isThought = part.thought === true; + // Capture thought_signature from any part (thought, standalone signature, or + // functionCall) so it can be stored against the next functionCall's tool id. + // Mirrors the gemini→openai direct path — the signature frequently lands on a + // preceding thought part rather than the functionCall part itself. + const partSig = part.thoughtSignature || part.thought_signature; + if (typeof partSig === "string" && partSig) { + state.pendingThoughtSignature = partSig; + } + // Thinking content → thinking block (always open+close per chunk) if (isThought && part.text) { // Close any open text block first @@ -87,10 +100,27 @@ export function geminiToClaudeResponse(chunk, state) { } const fc = part.functionCall; const rawToolName = fc.name; - const restoredToolName = normalizeToolName(state.toolNameMap?.get(rawToolName) || rawToolName); + const restoredToolName = normalizeToolName( + state.toolNameMap?.get(rawToolName) || rawToolName + ); const idx = state.contentBlockIndex++; const toolId = fc.id || `toolu_${Date.now()}_${idx}`; + // Persist the thought_signature keyed by this tool id (scoped to the + // connection) so the next Claude→Gemini request can replay it on the + // functionCall part. Without it Gemini 3+ 400s multi-turn tool calls. + const sig = + (typeof part.thoughtSignature === "string" && part.thoughtSignature) || + (typeof part.thought_signature === "string" && part.thought_signature) || + state.pendingThoughtSignature; + if (sig) { + storeGeminiThoughtSignature( + buildGeminiThoughtSignatureKey(state.signatureNamespace, toolId), + sig + ); + state.pendingThoughtSignature = null; + } + results.push({ type: "content_block_start", index: idx, diff --git a/tests/unit/vertex-functioncall-id-3440.test.ts b/tests/unit/vertex-functioncall-id-3440.test.ts index 8609755c36..758557e374 100644 --- a/tests/unit/vertex-functioncall-id-3440.test.ts +++ b/tests/unit/vertex-functioncall-id-3440.test.ts @@ -11,15 +11,24 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { openaiToGeminiRequest } = await import( - "../../open-sse/translator/request/openai-to-gemini.ts" -); -const { claudeToGeminiRequest } = await import( - "../../open-sse/translator/request/claude-to-gemini.ts" -); +const { openaiToGeminiRequest } = + await import("../../open-sse/translator/request/openai-to-gemini.ts"); +const { claudeToGeminiRequest } = + await import("../../open-sse/translator/request/claude-to-gemini.ts"); +const { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature } = + await import("../../open-sse/services/geminiThoughtSignatureStore.ts"); type UnknownRecord = Record; +const CLAUDE_SIGNATURE_NAMESPACE = "regression-3440"; + +function seedClaudeThoughtSignature() { + storeGeminiThoughtSignature( + buildGeminiThoughtSignatureKey(CLAUDE_SIGNATURE_NAMESPACE, "tu_weather_1"), + "SIG_3440" + ); +} + function findFunctionCall(result: any): UnknownRecord | undefined { for (const content of result.contents ?? []) { for (const part of content.parts ?? []) { @@ -126,10 +135,16 @@ test("#3440 OpenAI->Gemini: no provider hint PRESERVES id (default, non-vertex)" }); test("#3440 Claude->Gemini: vertex provider omits id from functionCall and functionResponse", () => { + seedClaudeThoughtSignature(); const result = claudeToGeminiRequest("gemini-2.5-pro", CLAUDE_TOOL_BODY, false, { _provider: "vertex", + _signatureNamespace: CLAUDE_SIGNATURE_NAMESPACE, }); - assert.equal(findFunctionCall(result)?.id, undefined, "functionCall.id must be omitted for Vertex"); + assert.equal( + findFunctionCall(result)?.id, + undefined, + "functionCall.id must be omitted for Vertex" + ); assert.equal( findFunctionResponse(result)?.id, undefined, @@ -138,6 +153,9 @@ test("#3440 Claude->Gemini: vertex provider omits id from functionCall and funct }); test("#3440 Claude->Gemini: no provider hint PRESERVES id (default, non-vertex)", () => { - const result = claudeToGeminiRequest("gemini-2.5-pro", CLAUDE_TOOL_BODY, false); + seedClaudeThoughtSignature(); + const result = claudeToGeminiRequest("gemini-2.5-pro", CLAUDE_TOOL_BODY, false, { + _signatureNamespace: CLAUDE_SIGNATURE_NAMESPACE, + }); assert.equal(findFunctionCall(result)?.id, "tu_weather_1"); });