diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 20b4d4bcdc..27650dcc81 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -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); diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 66a226bf95..66972d0211 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -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): 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) { diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index e6e8681a4f..341416fdb4 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -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", diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index 579eca1811..f35f215267 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -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", diff --git a/tests/unit/translator-resp-gemini-to-openai.test.ts b/tests/unit/translator-resp-gemini-to-openai.test.ts index 42f52df891..c18e94b36c 100644 --- a/tests/unit/translator-resp-gemini-to-openai.test.ts +++ b/tests/unit/translator-resp-gemini-to-openai.test.ts @@ -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(