diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 27650dcc81..d498e106c0 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -36,7 +36,15 @@ function firstPositiveNumber(...values: unknown[]): number { 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*$/); + + // Gemini/Antigravity sometimes imitates the request-side fallback with small + // variations, e.g. a leading "(empty)" marker or zero-width chars inserted + // into argument strings. Normalize those variants before parsing so the + // response is still surfaced as a structured OpenAI tool call. + const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + const match = normalized.match( + /^[\s\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(); diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index 341416fdb4..9094bd2283 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -25,7 +25,15 @@ 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*$/); + + // Gemini/Antigravity sometimes imitates the request-side fallback with small + // variations, e.g. a leading "(empty)" marker or zero-width chars inserted + // into argument strings. Normalize those variants before parsing so the + // response is still surfaced as a structured OpenAI tool call. + const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + const match = normalized.match( + /^[\s\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(); diff --git a/tests/unit/translator-resp-gemini-to-openai.test.ts b/tests/unit/translator-resp-gemini-to-openai.test.ts index c18e94b36c..b684f23137 100644 --- a/tests/unit/translator-resp-gemini-to-openai.test.ts +++ b/tests/unit/translator-resp-gemini-to-openai.test.ts @@ -381,6 +381,45 @@ test("Gemini stream: converts textual Tool call block to structured tool_calls", assert.equal(result.at(-1).choices[0].finish_reason, "tool_calls"); }); +test("Gemini stream: converts prefixed textual Tool call block with zero-width chars", () => { + const state = createStreamingState(); + const result = geminiToOpenAIResponse( + { + responseId: "resp-textual-tool-prefixed", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: '(empty)[Tool call: terminal]\nArguments: {"command":"sqlite3 ~/.o\u200dmniroute/storage.sqlite"}', + }, + ], + }, + 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", + }) + ); + 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(