diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 83d880cd17..c1ef2bfaca 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -93,10 +93,12 @@ export class AntigravityExecutor extends BaseExecutor { role = "user"; } - // Strip thought parts (no valid signature -> provider rejects). - // Also drop entries that become empty after filtering, which can trigger - // 400 invalid argument on Gemini 3 Flash through Antigravity. - const parts = c.parts?.filter((p) => !p.thought && !p.thoughtSignature) || []; + const hasFunctionCall = c.parts?.some((p) => p.functionCall) || false; + + // Antigravity rejects synthetic thought text, but Gemini 3+ requires any + // returned thoughtSignature metadata to survive model tool-call turns. + const parts = + c.parts?.filter((p) => !p.thought && (hasFunctionCall || !p.thoughtSignature)) || []; return { ...c, role, parts }; }) || []; diff --git a/open-sse/services/geminiThoughtSignatureStore.ts b/open-sse/services/geminiThoughtSignatureStore.ts new file mode 100644 index 0000000000..f52d7a8821 --- /dev/null +++ b/open-sse/services/geminiThoughtSignatureStore.ts @@ -0,0 +1,44 @@ +const MAX_SIGNATURES = 1000; +const TTL_MS = 1000 * 60 * 60; + +type Entry = { + signature: string; + expiresAt: number; +}; + +const signatures = new Map(); + +function pruneExpired() { + const now = Date.now(); + for (const [key, value] of signatures.entries()) { + if (value.expiresAt <= now) { + signatures.delete(key); + } + } + + while (signatures.size > MAX_SIGNATURES) { + const oldestKey = signatures.keys().next().value; + if (!oldestKey) break; + signatures.delete(oldestKey); + } +} + +export function storeGeminiThoughtSignature(toolCallId: unknown, signature: unknown) { + if (typeof toolCallId !== "string" || !toolCallId) return; + if (typeof signature !== "string" || !signature) return; + + pruneExpired(); + signatures.set(toolCallId, { + signature, + expiresAt: Date.now() + TTL_MS, + }); +} + +export function getGeminiThoughtSignature(toolCallId: unknown) { + if (typeof toolCallId !== "string" || !toolCallId) return null; + + pruneExpired(); + const entry = signatures.get(toolCallId); + if (!entry) return null; + return entry.signature; +} diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index dc4212ec95..2cee5d98f7 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -92,9 +92,6 @@ export function claudeToGeminiRequest(model, body, stream) { break; case "tool_use": - // Do NOT include thoughtSignature on functionCall parts — it is only valid - // on thinking/reasoning parts and causes HTTP 400 "invalid argument" from the - // Gemini API when present on a functionCall part. parts.push({ functionCall: { id: block.id, @@ -148,19 +145,20 @@ export function claudeToGeminiRequest(model, body, stream) { // Map Claude roles to Gemini roles const geminiRole = msg.role === "assistant" ? "model" : "user"; - // Gemini 3+ requires thoughtSignature as a sibling part in model content - // that contains functionCall parts. Inject if not already present from - // a thinking block. (#927) + // Gemini 3+ expects the signature on the first functionCall part in a tool-call + // batch. If the assistant turn had no explicit thinking block, inject a fallback + // signature into that first functionCall. (#927) if (geminiRole === "model") { const hasFunctionCall = parts.some((p) => p.functionCall); const hasSignature = parts.some((p) => p.thoughtSignature); if (hasFunctionCall && !hasSignature) { - // Insert before the first functionCall part const fcIndex = parts.findIndex((p) => p.functionCall); - parts.splice(fcIndex, 0, { - thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, - text: "", - }); + if (fcIndex >= 0) { + parts[fcIndex] = { + ...parts[fcIndex], + thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, + }; + } } } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index fafe9c6796..96431330dc 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -2,6 +2,7 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/constants.ts"; +import { getGeminiThoughtSignature } from "../../services/geminiThoughtSignatureStore.ts"; import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.ts"; import { capMaxOutputTokens, @@ -74,6 +75,15 @@ type CloudCodeEnvelope = { }; }; +function normalizeAntigravityToolName(name: unknown) { + if (typeof name !== "string") return name; + const trimmed = name.trim(); + if (!trimmed) return trimmed; + + const namespaceIndex = trimmed.indexOf(":"); + return namespaceIndex >= 0 ? trimmed.slice(namespaceIndex + 1) : trimmed; +} + // Core: Convert OpenAI request to Gemini format (base for all variants) function openaiToGeminiBase(model, body, stream) { const result: GeminiRequest = { @@ -167,31 +177,40 @@ function openaiToGeminiBase(model, body, stream) { } if (msg.tool_calls && Array.isArray(msg.tool_calls)) { - // Gemini 3+ requires thoughtSignature as a sibling part in model content - // that contains functionCall parts. If no reasoning_content was present - // (which already injects the signature above), inject one now. (#927) - const hasSignatureAlready = parts.some((p) => p.thoughtSignature); - if (!hasSignatureAlready) { - parts.push({ - thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, - }); - } - const toolCallIds = []; + const firstPersistedSignature = msg.tool_calls + .map((tc) => getGeminiThoughtSignature(tc.id)) + .find((signature) => typeof signature === "string" && signature.length > 0); + + const shouldUseEmbeddedSignature = !parts.some((p) => p.thoughtSignature); + let embeddedSignatureUsed = false; + for (const tc of msg.tool_calls) { if (tc.type !== "function") continue; const args = tryParseJSON(tc.function?.arguments || "{}"); - // Do NOT include thoughtSignature ON the functionCall part itself — it is - // only valid as a separate sibling part. Including it inside functionCall - // causes HTTP 400 "invalid argument" (#725). + const signatureForToolCall = getGeminiThoughtSignature(tc.id); + const embeddedThoughtSignature = + shouldUseEmbeddedSignature && !embeddedSignatureUsed + ? firstPersistedSignature || + signatureForToolCall || + DEFAULT_THINKING_GEMINI_SIGNATURE + : undefined; + + // Gemini expects the signature on the functionCall part itself. For + // parallel calls, only the first functionCall in the batch carries it. parts.push({ + ...(embeddedThoughtSignature ? { thoughtSignature: embeddedThoughtSignature } : {}), functionCall: { id: tc.id, name: tc.function.name, args: args, }, }); + + if (embeddedThoughtSignature) { + embeddedSignatureUsed = true; + } toolCallIds.push(tc.id); } @@ -330,6 +349,7 @@ export function openaiToGeminiCLIRequest(model, body, stream) { // Clean schema for tools if (gemini.tools?.[0]?.functionDeclarations) { for (const fn of gemini.tools[0].functionDeclarations) { + fn.name = normalizeAntigravityToolName(fn.name); if (fn.parameters) { const cleanedSchema = cleanJSONSchemaForAntigravity(fn.parameters); fn.parameters = cleanedSchema; @@ -343,6 +363,20 @@ export function openaiToGeminiCLIRequest(model, body, stream) { } } + if (Array.isArray(gemini.contents)) { + for (const content of gemini.contents) { + if (!Array.isArray(content.parts)) continue; + for (const part of content.parts) { + if (part.functionCall?.name) { + part.functionCall.name = normalizeAntigravityToolName(part.functionCall.name); + } + if (part.functionResponse?.name) { + part.functionResponse.name = normalizeAntigravityToolName(part.functionResponse.name); + } + } + } + } + return gemini; } diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index c57ab9299a..e335231dbc 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -1,5 +1,6 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; +import { storeGeminiThoughtSignature } from "../../services/geminiThoughtSignatureStore.ts"; // Convert Gemini response chunk to OpenAI format export function geminiToOpenAIResponse(chunk, state) { @@ -38,6 +39,9 @@ export function geminiToOpenAIResponse(chunk, state) { for (const part of content.parts) { const hasThoughtSig = part.thoughtSignature || part.thought_signature; const isThought = part.thought === true; + if (hasThoughtSig && typeof hasThoughtSig === "string") { + state.pendingThoughtSignature = hasThoughtSig; + } // Handle thought signature (thinking mode) if (hasThoughtSig) { @@ -75,6 +79,11 @@ export function geminiToOpenAIResponse(chunk, state) { }, }; + if (state.pendingThoughtSignature) { + storeGeminiThoughtSignature(toolCall.id, state.pendingThoughtSignature); + state.pendingThoughtSignature = null; + } + state.toolCalls.set(toolCallIndex, toolCall); results.push({ @@ -127,6 +136,11 @@ export function geminiToOpenAIResponse(chunk, state) { }, }; + if (state.pendingThoughtSignature) { + storeGeminiThoughtSignature(toolCall.id, state.pendingThoughtSignature); + state.pendingThoughtSignature = null; + } + state.toolCalls.set(toolCallIndex, toolCall); results.push({ diff --git a/tests/unit/t43-gemini-tool-call-no-thought-signature.test.mjs b/tests/unit/t43-gemini-tool-call-no-thought-signature.test.mjs index 8adc7722b0..af517ad03c 100644 --- a/tests/unit/t43-gemini-tool-call-no-thought-signature.test.mjs +++ b/tests/unit/t43-gemini-tool-call-no-thought-signature.test.mjs @@ -1,10 +1,10 @@ /** - * T43: Gemini tool call parts must NOT include thoughtSignature. + * T43: Gemini tool call parts must preserve thoughtSignature correctly. * * Regression test for HTTP 400 "invalid argument" when OmniRoute translates - * OpenAI tool_calls to Gemini format. The thoughtSignature field is only valid - * on thinking/reasoning parts — injecting it on functionCall parts causes the - * Gemini API to reject the request with a 400 error. + * OpenAI tool_calls to Gemini format. Gemini 3 requires the signature to live on + * the first functionCall part for a tool-call batch, and replays fail if the + * signature is stripped or emitted as a separate sibling part. * * Reproduces: https://github.com/diegosouzapw/OmniRoute/issues/725 */ @@ -24,7 +24,7 @@ function translateToGemini(messages, tools) { }); } -test("T43: functionCall parts must not contain thoughtSignature", () => { +test("T43: first functionCall part keeps thoughtSignature", () => { const messages = [ { role: "user", content: "What is the weather in Tokyo?" }, { @@ -69,19 +69,18 @@ test("T43: functionCall parts must not contain thoughtSignature", () => { assert.ok(modelTurn, "Expected a model turn with functionCall parts"); - for (const part of modelTurn.parts) { - if (part.functionCall) { - assert.ok( - !("thoughtSignature" in part), - `functionCall part must not contain thoughtSignature — Gemini API returns HTTP 400 "invalid argument" when it does. Got: ${JSON.stringify(part)}` - ); - assert.equal(part.functionCall.name, "get_weather"); - assert.deepEqual(part.functionCall.args, { location: "Tokyo" }); - } - } + const functionCallParts = modelTurn.parts.filter((part) => part.functionCall); + assert.equal(functionCallParts.length, 1, "Expected exactly 1 functionCall part"); + assert.equal(functionCallParts[0].functionCall.name, "get_weather"); + assert.deepEqual(functionCallParts[0].functionCall.args, { location: "Tokyo" }); + assert.ok( + typeof functionCallParts[0].thoughtSignature === "string" && + functionCallParts[0].thoughtSignature.length > 0, + `first functionCall part must carry thoughtSignature. Got: ${JSON.stringify(functionCallParts[0])}` + ); }); -test("T43: multiple tool calls — none of the functionCall parts may have thoughtSignature", () => { +test("T43: multiple tool calls only tag the first functionCall part", () => { const messages = [ { role: "user", content: "Get weather for Tokyo and London" }, { @@ -122,12 +121,15 @@ test("T43: multiple tool calls — none of the functionCall parts may have thoug const functionCallParts = modelTurn.parts.filter((p) => p.functionCall); assert.equal(functionCallParts.length, 2, "Expected 2 functionCall parts"); - for (const part of functionCallParts) { - assert.ok( - !("thoughtSignature" in part), - `functionCall part must not contain thoughtSignature. Got: ${JSON.stringify(part)}` - ); - } + assert.ok( + typeof functionCallParts[0].thoughtSignature === "string" && + functionCallParts[0].thoughtSignature.length > 0, + `first functionCall part must carry thoughtSignature. Got: ${JSON.stringify(functionCallParts[0])}` + ); + assert.ok( + !("thoughtSignature" in functionCallParts[1]), + `parallel follow-up functionCall parts must stay unsigned. Got: ${JSON.stringify(functionCallParts[1])}` + ); }); test("T43: thinking parts still include thoughtSignature (regression guard)", () => { diff --git a/tests/unit/t44-antigravity-thought-signature-preserved.test.mjs b/tests/unit/t44-antigravity-thought-signature-preserved.test.mjs new file mode 100644 index 0000000000..db9e6b290a --- /dev/null +++ b/tests/unit/t44-antigravity-thought-signature-preserved.test.mjs @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts"; + +test("T44: Antigravity preserves thoughtSignature for functionCall turns", () => { + const executor = new AntigravityExecutor(); + const transformed = executor.transformRequest( + "gemini-3-flash", + { + request: { + contents: [ + { + role: "model", + parts: [ + { thought: true, text: "internal reasoning" }, + { thoughtSignature: "sig_123" }, + { + functionCall: { + id: "call_1", + name: "default_api:memos_load_user_memory", + args: { userId: "u1" }, + }, + }, + ], + }, + ], + tools: [{ functionDeclarations: [{ name: "default_api:memos_load_user_memory" }] }], + }, + }, + true, + { projectId: "test-project" } + ); + + const parts = transformed.request.contents[0].parts; + + assert.equal( + parts.some((part) => part.thought === true), + false, + "thought text should still be stripped before sending to Antigravity" + ); + assert.equal( + parts.some((part) => part.thoughtSignature === "sig_123"), + true, + "tool-call turns must keep thoughtSignature for Gemini 3+ compatibility" + ); + assert.equal( + parts.some((part) => part.functionCall?.name === "default_api:memos_load_user_memory"), + true, + "functionCall must still be present" + ); +}); + +test("T44: Antigravity still strips standalone thoughtSignature without tool calls", () => { + const executor = new AntigravityExecutor(); + const transformed = executor.transformRequest( + "gemini-3-flash", + { + request: { + contents: [ + { + role: "model", + parts: [{ thoughtSignature: "sig_123" }, { text: "plain text" }], + }, + ], + }, + }, + true, + { projectId: "test-project" } + ); + + assert.deepEqual(transformed.request.contents[0].parts, [{ text: "plain text" }]); +});