fix(gemini): preserve thought signatures across antigravity tool calls

This commit is contained in:
xiaoge1688
2026-04-03 12:10:17 +08:00
parent a36a38bd8d
commit 587bab3eb1
7 changed files with 217 additions and 50 deletions

View File

@@ -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 };
}) || [];

View File

@@ -0,0 +1,44 @@
const MAX_SIGNATURES = 1000;
const TTL_MS = 1000 * 60 * 60;
type Entry = {
signature: string;
expiresAt: number;
};
const signatures = new Map<string, Entry>();
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;
}

View File

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

View File

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

View File

@@ -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({

View File

@@ -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)", () => {

View File

@@ -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" }]);
});