mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
fix(gemini): preserve structured tool calls for antigravity
This commit is contained in:
@@ -34,6 +34,20 @@ function firstPositiveNumber(...values: unknown[]): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseTextualToolCall(text: unknown): { name: string; args: unknown } | null {
|
||||
if (typeof text !== "string") return null;
|
||||
const match = text.match(/^\s*\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/);
|
||||
if (!match) return null;
|
||||
const name = match[1]?.trim();
|
||||
const rawArgs = match[2]?.trim();
|
||||
if (!name || !rawArgs) return null;
|
||||
try {
|
||||
return { name, args: JSON.parse(rawArgs) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractMessageOutputText(item: JsonRecord): string {
|
||||
if (!Array.isArray(item.content)) return "";
|
||||
let text = "";
|
||||
@@ -302,8 +316,21 @@ export function translateNonStreamingResponse(
|
||||
}
|
||||
|
||||
if (typeof partObj.text === "string") {
|
||||
textContent += partObj.text;
|
||||
contentParts.push({ type: "text", text: partObj.text });
|
||||
const textualToolCall = parseTextualToolCall(partObj.text);
|
||||
if (textualToolCall) {
|
||||
const toolCallId = `call_${toString(textualToolCall.name, "unknown")}_${Date.now()}_${toolCalls.length}`;
|
||||
toolCalls.push({
|
||||
id: toolCallId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: textualToolCall.name,
|
||||
arguments: JSON.stringify(textualToolCall.args || {}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
textContent += partObj.text;
|
||||
contentParts.push({ type: "text", text: partObj.text });
|
||||
}
|
||||
}
|
||||
|
||||
const inlineData = toRecord(partObj.inlineData ?? partObj.inline_data);
|
||||
|
||||
@@ -347,12 +347,18 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName
|
||||
}
|
||||
|
||||
// Check if there are actual tool responses in the next messages
|
||||
const hasSignaturelessTextResponses =
|
||||
stringifySignaturelessToolCalls &&
|
||||
msg.tool_calls.some(
|
||||
(tc) =>
|
||||
tc.type === "function" && !resolvedSignatures.has(tc.id) && toolResponses[tc.id]
|
||||
);
|
||||
const signaturelessToolCallIds = stringifySignaturelessToolCalls
|
||||
? msg.tool_calls
|
||||
.filter(
|
||||
(tc) =>
|
||||
tc.type === "function" &&
|
||||
tc.id &&
|
||||
!resolvedSignatures.has(tc.id) &&
|
||||
toolResponses[tc.id]
|
||||
)
|
||||
.map((tc) => tc.id)
|
||||
: [];
|
||||
const hasSignaturelessTextResponses = signaturelessToolCallIds.length > 0;
|
||||
const hasActualResponses =
|
||||
toolCallIds.some((fid) => toolResponses[fid]) || hasSignaturelessTextResponses;
|
||||
|
||||
@@ -398,7 +404,7 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName
|
||||
// functionResponse parts missing a matching thoughtSignature.
|
||||
for (const tc of msg.tool_calls) {
|
||||
if (tc.type !== "function" || !tc.id) continue;
|
||||
if (!resolvedSignatures.has(tc.id) && toolResponses[tc.id]) {
|
||||
if (signaturelessToolCallIds.includes(tc.id)) {
|
||||
const name = tcID2Name[tc.id] || tc.function?.name || "unknown";
|
||||
const resp = toolResponses[tc.id];
|
||||
toolParts.push({
|
||||
@@ -605,6 +611,16 @@ function getAntigravityClaudeOutputTokens(body: Record<string, unknown>): number
|
||||
// OpenAI -> Antigravity (Sandbox Cloud Code with wrapper)
|
||||
export function openaiToAntigravityRequest(model, body, stream, credentials = null) {
|
||||
const isClaude = model.toLowerCase().includes("claude");
|
||||
// All modern Gemini models (2.5+, 3.x, pro-agent, etc.) use thinking by default
|
||||
// and require thought_signature for multi-turn tool calls.
|
||||
// Safe default: all non-Claude models via Antigravity are thinking Gemini.
|
||||
const modelLower = model.toLowerCase();
|
||||
const isThinkingGemini =
|
||||
!isClaude &&
|
||||
(modelLower.includes("thinking") ||
|
||||
modelLower.includes("gemini-3") ||
|
||||
modelLower.includes("gemini-2.5") ||
|
||||
modelLower.includes("gemini-pro"));
|
||||
const signatureNamespace =
|
||||
credentials &&
|
||||
typeof credentials === "object" &&
|
||||
@@ -613,7 +629,7 @@ export function openaiToAntigravityRequest(model, body, stream, credentials = nu
|
||||
: null;
|
||||
const geminiCLI = openaiToGeminiCLIRequest(model, body, stream, {
|
||||
signatureNamespace,
|
||||
signaturelessToolCallMode: isClaude ? "native" : "text",
|
||||
signaturelessToolCallMode: isThinkingGemini ? "text" : "native",
|
||||
});
|
||||
|
||||
if (isClaude) {
|
||||
|
||||
@@ -23,6 +23,20 @@ type GeminiFunctionCallPart = {
|
||||
};
|
||||
};
|
||||
|
||||
function parseTextualToolCall(text: unknown): { name: string; args: unknown } | null {
|
||||
if (typeof text !== "string") return null;
|
||||
const match = text.match(/^\s*\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/);
|
||||
if (!match) return null;
|
||||
const name = match[1]?.trim();
|
||||
const rawArgs = match[2]?.trim();
|
||||
if (!name || !rawArgs) return null;
|
||||
try {
|
||||
return { name, args: JSON.parse(rawArgs) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildToolCallId(
|
||||
functionCall: GeminiFunctionCallPart["functionCall"],
|
||||
toolName: string,
|
||||
@@ -169,6 +183,15 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
const hasTextContent = part.text !== undefined && part.text !== "";
|
||||
const hasFunctionCall = !!part.functionCall;
|
||||
|
||||
// Gemini/Antigravity can emit thoughtSignature as a standalone part
|
||||
// immediately before the functionCall part. Keep it pending so the
|
||||
// following functionCall is cached and can be re-attached on later
|
||||
// turns; otherwise OpenAI-format clients lose the signature and the
|
||||
// next Gemini request has to stringify historical tool calls.
|
||||
if (hasThoughtSig && !hasTextContent && !hasFunctionCall) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasTextContent) {
|
||||
results.push({
|
||||
id: `chatcmpl-${state.messageId}`,
|
||||
@@ -191,8 +214,27 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Text content (non-thinking)
|
||||
// Text content (non-thinking). Some Gemini/Antigravity turns can imitate
|
||||
// the request-side signatureless history fallback and emit a textual
|
||||
// "[Tool call: ...]" block instead of native functionCall. Convert that
|
||||
// back to a structured OpenAI tool call so clients/tools do not see it as
|
||||
// assistant prose.
|
||||
if (part.text !== undefined && part.text !== "") {
|
||||
const textualToolCall = parseTextualToolCall(part.text);
|
||||
if (textualToolCall) {
|
||||
emitFunctionCallPart(
|
||||
{
|
||||
functionCall: {
|
||||
name: textualToolCall.name,
|
||||
args: textualToolCall.args,
|
||||
},
|
||||
},
|
||||
state,
|
||||
results
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: `chatcmpl-${state.messageId}`,
|
||||
object: "chat.completion.chunk",
|
||||
|
||||
@@ -689,6 +689,61 @@ test("OpenAI -> Antigravity Gemini stringifies signature-less historical tool ca
|
||||
);
|
||||
});
|
||||
|
||||
test("OpenAI -> Antigravity preserves multiple signature-less historical tool responses as text", () => {
|
||||
const result = openaiToAntigravityRequest(
|
||||
"gemini-3.5-flash-low",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "Inspect OmniRoute config" },
|
||||
{
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_missing_db",
|
||||
type: "function",
|
||||
function: { name: "terminal", arguments: '{"command":"cat data/db.json"}' },
|
||||
},
|
||||
{
|
||||
id: "call_list_dir",
|
||||
type: "function",
|
||||
function: { name: "terminal", arguments: '{"command":"ls ~/.omniroute"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_missing_db", content: "data/db.json: No such file" },
|
||||
{ role: "tool", tool_call_id: "call_list_dir", content: "storage.sqlite" },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "terminal",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
{ projectId: "proj-antigravity-gemini" } as any
|
||||
);
|
||||
|
||||
const text = JSON.stringify(result.request.contents);
|
||||
assert.ok(text.includes("[Tool call: terminal]"), "expected signature-less calls as text");
|
||||
assert.ok(
|
||||
text.includes("data/db.json: No such file"),
|
||||
"expected first signature-less tool response as text"
|
||||
);
|
||||
assert.ok(
|
||||
text.includes("storage.sqlite"),
|
||||
"expected second signature-less tool response as text"
|
||||
);
|
||||
assert.equal(
|
||||
result.request.contents.some((content) => content.parts.some((part) => part.functionResponse)),
|
||||
false,
|
||||
"signature-less historical responses must not be emitted as native functionResponse"
|
||||
);
|
||||
});
|
||||
|
||||
test("OpenAI -> Antigravity maps Claude-family models to Gemini-compatible schema", () => {
|
||||
const result = openaiToAntigravityRequest(
|
||||
"claude-3-7-sonnet",
|
||||
|
||||
@@ -64,7 +64,9 @@ test("Gemini non-stream: multiple candidates keep multimodal content, reasoning
|
||||
{ thought: true, text: "Plan first." },
|
||||
{ text: "Answer:" },
|
||||
{ inlineData: { mimeType: "image/png", data: "abc123" } },
|
||||
{ functionCall: { id: "native-read-1", name: "read_file", args: { path: "/tmp/a" } } },
|
||||
{
|
||||
functionCall: { id: "native-read-1", name: "read_file", args: { path: "/tmp/a" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
@@ -283,10 +285,7 @@ test("Gemini stream: reasoning, tool call, image and MAX_TOKENS finish are conve
|
||||
);
|
||||
|
||||
assert.equal(result[1].choices[0].delta.reasoning_content, "Need a plan.");
|
||||
assert.equal(
|
||||
result[2].choices[0].delta.tool_calls[0].id,
|
||||
"native-call-1"
|
||||
);
|
||||
assert.equal(result[2].choices[0].delta.tool_calls[0].id, "native-call-1");
|
||||
assert.equal(
|
||||
result[2].choices[0].delta.tool_calls[0].function.name,
|
||||
"mcp__filesystem__read_multiple_files_with_validation_and_metadata_bundle_v2"
|
||||
@@ -303,6 +302,85 @@ test("Gemini stream: reasoning, tool call, image and MAX_TOKENS finish are conve
|
||||
assert.equal(result[4].usage.completion_tokens_details.reasoning_tokens, 2);
|
||||
});
|
||||
|
||||
test("Gemini stream: stores thoughtSignature when signature-only part precedes functionCall", async () => {
|
||||
const { resolveGeminiThoughtSignature } =
|
||||
await import("../../open-sse/services/geminiThoughtSignatureStore.ts");
|
||||
const state = {
|
||||
...createStreamingState(),
|
||||
signatureNamespace: "conn-antigravity-1",
|
||||
};
|
||||
const result = geminiToOpenAIResponse(
|
||||
{
|
||||
responseId: "resp-split-signature",
|
||||
modelVersion: "gemini-3-flash-agent",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{ thoughtSignature: "sig-split-1" },
|
||||
{
|
||||
functionCall: {
|
||||
id: "call_split_1",
|
||||
name: "read_file",
|
||||
args: { path: "/tmp/a" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
const toolCall = result.find((event: any) => event.choices?.[0]?.delta?.tool_calls)?.choices[0]
|
||||
.delta.tool_calls[0];
|
||||
assert.equal(toolCall.id, "call_split_1");
|
||||
assert.equal(state.pendingThoughtSignature, null);
|
||||
assert.equal(resolveGeminiThoughtSignature("conn-antigravity-1:call_split_1"), "sig-split-1");
|
||||
});
|
||||
|
||||
test("Gemini stream: converts textual Tool call block to structured tool_calls", () => {
|
||||
const state = createStreamingState();
|
||||
const result = geminiToOpenAIResponse(
|
||||
{
|
||||
responseId: "resp-textual-tool",
|
||||
modelVersion: "gemini-3.5-flash-low",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
text: '[Tool call: terminal]\nArguments: {"command":"sqlite3 ~/.omniroute/storage.sqlite \\"SELECT name FROM sqlite_master WHERE type=\'table\';\\""}',
|
||||
},
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
const toolCall = result.find((event: any) => event.choices?.[0]?.delta?.tool_calls)?.choices[0]
|
||||
.delta.tool_calls[0];
|
||||
assert.ok(toolCall.id.startsWith("terminal-"));
|
||||
assert.equal(toolCall.function.name, "terminal");
|
||||
assert.equal(
|
||||
toolCall.function.arguments,
|
||||
JSON.stringify({
|
||||
command:
|
||||
"sqlite3 ~/.omniroute/storage.sqlite \"SELECT name FROM sqlite_master WHERE type='table';\"",
|
||||
})
|
||||
);
|
||||
assert.equal(
|
||||
result.some((event: any) => event.choices?.[0]?.delta?.content?.includes("[Tool call:")),
|
||||
false
|
||||
);
|
||||
assert.equal(result.at(-1).choices[0].finish_reason, "tool_calls");
|
||||
});
|
||||
|
||||
test("Gemini stream: tool calls without native IDs keep deterministic fallback shape", () => {
|
||||
const state = createStreamingState();
|
||||
const result = geminiToOpenAIResponse(
|
||||
|
||||
Reference in New Issue
Block a user