diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 6696de2119..a69540e49d 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -6,12 +6,12 @@ import { cleanJSONSchemaForAntigravity, } from "../helpers/geminiHelper.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 { capMaxOutputTokens, capThinkingBudget } from "../../../src/lib/modelCapabilities.ts"; +import { getModelSpec } from "../../../src/shared/constants/modelSpecs.ts"; import { buildHistoricalToolResultContext } from "./openai-to-gemini/helpers.ts"; /** @@ -127,6 +127,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 = []; let shouldUseEmbeddedSignature = true; diff --git a/tests/unit/vertex-functioncall-id-3440.test.ts b/tests/unit/vertex-functioncall-id-3440.test.ts index 8609755c36..4fe5aebd92 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 ?? []) { @@ -29,6 +38,15 @@ function findFunctionCall(result: any): UnknownRecord | undefined { return undefined; } +function findFunctionCallPart(result: any): UnknownRecord | undefined { + for (const content of result.contents ?? []) { + for (const part of content.parts ?? []) { + if (part?.functionCall) return part as UnknownRecord; + } + } + return undefined; +} + function findFunctionResponse(result: any): UnknownRecord | undefined { for (const content of result.contents ?? []) { for (const part of content.parts ?? []) { @@ -126,18 +144,43 @@ 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, "functionResponse.id must be omitted for Vertex" ); + + const vertexPart = findFunctionCallPart(result); + assert.ok(vertexPart, "expected a functionCall part"); + assert.equal( + vertexPart.thoughtSignature, + "SIG_3440", + "thoughtSignature must be replayed even for Vertex (only id is stripped)" + ); }); 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"); + + const nonVertexPart = findFunctionCallPart(result); + assert.ok(nonVertexPart, "expected a functionCall part"); + assert.equal( + nonVertexPart.thoughtSignature, + "SIG_3440", + "thoughtSignature must be replayed for direct Claude->Gemini path" + ); });