mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(gemini): preserve thought signatures across antigravity tool calls
This commit is contained in:
@@ -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 };
|
||||
}) || [];
|
||||
|
||||
|
||||
44
open-sse/services/geminiThoughtSignatureStore.ts
Normal file
44
open-sse/services/geminiThoughtSignatureStore.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
Reference in New Issue
Block a user