From 4459e75915caaa24483144b0051685e4ae5b1b41 Mon Sep 17 00:00:00 2001 From: Tuan Dinh Date: Fri, 11 Sep 2026 21:39:44 +0700 Subject: [PATCH] fix(antigravity): handle Gemini 3.8 Flash thought signatures, native tool calls, and output token limits --- open-sse/executors/antigravity.ts | 17 +++--- open-sse/executors/antigravity/sseCollect.ts | 2 +- open-sse/handlers/sseParser/geminiResponse.ts | 30 +++++++++- .../translator/request/openai-to-gemini.ts | 2 +- ...ntigravity-native-toolcall-collect.test.ts | 24 ++++++++ tests/unit/sse-parser.test.ts | 57 +++++++++++++++++++ .../unit/translator-openai-to-gemini.test.ts | 28 ++++++++- 7 files changed, 149 insertions(+), 11 deletions(-) diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 9582af9fe6..01a315ef80 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -13,10 +13,7 @@ import { getAntigravityOAuthUserAgent, } from "../services/antigravityHeaders.ts"; import { classify429, decide429, type Decision } from "../services/antigravity429Engine.ts"; -import { - parseRetryFromErrorText, - type RetryHintProvenance, -} from "../services/accountFallback.ts"; +import { parseRetryFromErrorText, type RetryHintProvenance } from "../services/accountFallback.ts"; import { parseDetailedRetryHintFromJsonBody } from "../services/retryAfterJson.ts"; import { shouldRetryWithCredits, @@ -331,7 +328,8 @@ function applyAntigravityGenerationDefaults( if ( Number.isFinite(thinkingBudget) && thinkingBudget > 0 && - (!Number.isFinite(maxOutputTokens) || maxOutputTokens <= thinkingBudget) + Number.isFinite(maxOutputTokens) && + maxOutputTokens <= thinkingBudget ) { generationConfig.maxOutputTokens = Math.floor(thinkingBudget) + 1; } @@ -379,7 +377,9 @@ const COMPETITIVE_AGENT_PROMPT_PATTERNS: RegExp[] = [ */ export function stripCompetitiveAgentPrompts(systemInstruction: unknown): unknown { const record = asRecord(systemInstruction); - const parts = Array.isArray(record?.parts) ? (record.parts as Array>) : []; + const parts = Array.isArray(record?.parts) + ? (record.parts as Array>) + : []; if (parts.length === 0) return systemInstruction; let changed = false; @@ -387,7 +387,10 @@ export function stripCompetitiveAgentPrompts(systemInstruction: unknown): unknow if (typeof part.text !== "string" || part.text.length === 0) return part; let text = part.text; for (const pattern of COMPETITIVE_AGENT_PROMPT_PATTERNS) { - const stripped = text.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimStart(); + const stripped = text + .replace(pattern, "") + .replace(/\n{3,}/g, "\n\n") + .trimStart(); if (stripped !== text) { changed = true; text = stripped; diff --git a/open-sse/executors/antigravity/sseCollect.ts b/open-sse/executors/antigravity/sseCollect.ts index ee4de7c11b..5e35890ba3 100644 --- a/open-sse/executors/antigravity/sseCollect.ts +++ b/open-sse/executors/antigravity/sseCollect.ts @@ -113,7 +113,7 @@ export function processAntigravitySSEPayload( collected.finishReason = "tool_calls"; continue; } - if (typeof part.text === "string" && !part.thought && !part.thoughtSignature) { + if (typeof part.text === "string" && !part.thought) { const textualToolCall = parseAntigravityTextualToolCall(part.text); if (textualToolCall) { addAntigravityTextualToolCall(collected, textualToolCall); diff --git a/open-sse/handlers/sseParser/geminiResponse.ts b/open-sse/handlers/sseParser/geminiResponse.ts index df18008c04..c4440ddcd8 100644 --- a/open-sse/handlers/sseParser/geminiResponse.ts +++ b/open-sse/handlers/sseParser/geminiResponse.ts @@ -22,6 +22,16 @@ type GeminiSSEAccumulator = { function stripZeroWidth(value: unknown): unknown { if (typeof value === "string") return stripObfuscationZeroWidth(value); + if (value && typeof value === "object") { + if (Array.isArray(value)) { + return value.map(stripZeroWidth); + } + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = stripZeroWidth(v); + } + return out; + } return value; } @@ -54,7 +64,25 @@ function extractGeminiMarkdownShortcut(parsed: Record): string /** Append one candidate content part (text or textual tool call) onto the accumulator. */ function applyCandidatePart(part: Record, acc: GeminiSSEAccumulator): void { - if (typeof part.text !== "string" || part.thought || part.thoughtSignature) return; + // Native function calls (Gemini 3.x / Antigravity) + const fc = part.functionCall as Record | undefined; + if (fc && typeof fc.name === "string") { + acc.toolCalls.push({ + id: + typeof fc.id === "string" && fc.id.length > 0 + ? fc.id + : `${fc.name}-${Date.now()}-${acc.toolCalls.length}`, + index: acc.toolCalls.length, + type: "function", + function: { + name: fc.name, + arguments: JSON.stringify(stripZeroWidth(fc.args ?? {})), + }, + }); + return; + } + + if (typeof part.text !== "string" || part.thought === true) return; const textualToolCall = tryParseTextualToolCall(part.text); if (textualToolCall) { diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index d68946369b..bed4a3e217 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -816,7 +816,7 @@ export function openaiToAntigravityRequest(model, body, stream, credentials = nu const hasThinking = !!envelope.request?.generationConfig?.thinkingConfig?.thinkingBudget; if ( clientRequestedMaxTokens === undefined && - !hasThinking && + !(isClaude && hasThinking) && envelope.request?.generationConfig ) { delete envelope.request.generationConfig.maxOutputTokens; diff --git a/tests/unit/antigravity-native-toolcall-collect.test.ts b/tests/unit/antigravity-native-toolcall-collect.test.ts index a9c5bc8cc8..3be6203200 100644 --- a/tests/unit/antigravity-native-toolcall-collect.test.ts +++ b/tests/unit/antigravity-native-toolcall-collect.test.ts @@ -118,3 +118,27 @@ test("processAntigravitySSEPayload ignores a malformed functionCall without a na assert.equal(collected.toolCalls.length, 0); assert.equal(collected.textContent, ""); }); + +test("processAntigravitySSEPayload collects text carrying thoughtSignature", () => { + const collected = emptyCollected(); + processAntigravitySSEPayload( + JSON.stringify({ + response: { + candidates: [ + { + content: { + parts: [ + { text: "internal reasoning", thought: true }, + { text: "visible reply after tool execution", thoughtSignature: "sig-tool-res" }, + ], + }, + finishReason: "STOP", + }, + ], + }, + }), + collected + ); + + assert.equal(collected.textContent, "visible reply after tool execution"); +}); diff --git a/tests/unit/sse-parser.test.ts b/tests/unit/sse-parser.test.ts index 5c8bfe627a..b062e1baed 100644 --- a/tests/unit/sse-parser.test.ts +++ b/tests/unit/sse-parser.test.ts @@ -431,3 +431,60 @@ test("parseSSEToGeminiResponse ignores thought/thoughtSignature parts", () => { assert.ok(parsed); assert.equal(parsed.choices[0].message.content, "visible answer"); }); + +test("parseSSEToGeminiResponse preserves text that carries a thoughtSignature", () => { + const rawSSE = [ + `data: ${JSON.stringify({ + response: { + candidates: [ + { + content: { + parts: [ + { text: "internal reasoning", thought: true }, + { text: "visible answer after thinking", thoughtSignature: "sig-xyz-123" }, + ], + }, + finishReason: "STOP", + }, + ], + }, + })}`, + ].join("\n"); + + const parsed = parseSSEToGeminiResponse(rawSSE, "gemini-3.8-flash-tiered"); + + assert.ok(parsed); + assert.equal(parsed.choices[0].message.content, "visible answer after thinking"); +}); + +test("parseSSEToGeminiResponse extracts native functionCall parts carrying thoughtSignature", () => { + const rawSSE = [ + `data: ${JSON.stringify({ + response: { + candidates: [ + { + content: { + parts: [ + { + functionCall: { name: "search_documentation", args: { query: "test" } }, + thoughtSignature: "sig-abc", + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + })}`, + ].join("\n"); + + const parsed = parseSSEToGeminiResponse(rawSSE, "gemini-3.8-flash-tiered"); + + assert.ok(parsed); + assert.equal(parsed.choices[0].finish_reason, "tool_calls"); + assert.equal(parsed.choices[0].message.tool_calls?.length, 1); + assert.equal(parsed.choices[0].message.tool_calls[0].function.name, "search_documentation"); + assert.deepEqual(JSON.parse(parsed.choices[0].message.tool_calls[0].function.arguments), { + query: "test", + }); +}); diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index cf44d1b798..c860a1acf0 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -866,7 +866,11 @@ test("OpenAI -> Antigravity maps Claude-family models to Gemini-compatible schem assert.match(result.requestId, /^agent\/\d+\/[0-9a-f]{8}$/); assert.equal(result.enabledCreditTypes, undefined); assert.equal(result.request.systemInstruction.parts[0].text, ANTIGRAVITY_DEFAULT_SYSTEM); - assert.equal(result.request.systemInstruction.parts.length, 1, "systemInstruction must contain only ANTIGRAVITY_DEFAULT_SYSTEM (#9030)"); + assert.equal( + result.request.systemInstruction.parts.length, + 1, + "systemInstruction must contain only ANTIGRAVITY_DEFAULT_SYSTEM (#9030)" + ); // #9030 — Client system content moved to first user message to avoid upstream 429s assert.equal(result.request.contents[0].parts[0].text, "Project rules"); assert.equal(result.request.contents[0].parts[1].text, "Read a file"); @@ -1026,6 +1030,28 @@ test("OpenAI -> Antigravity Gemini path preserves thinkingConfig (only Claude is assert.equal((result as any).request?.generationConfig.thinkingConfig.includeThoughts, true); }); +test("OpenAI -> Antigravity Gemini thinking models omit maxOutputTokens when max_tokens is undefined", () => { + const result = openaiToAntigravityRequest( + "gemini-3.8-flash-tiered", + { + messages: [{ role: "user", content: "Hello" }], + }, + false, + { projectId: "proj-gemini-thinking" } as unknown as Parameters< + typeof openaiToAntigravityRequest + >[3] + ) as Record; + + const envelopeRequest = result.request as Record | undefined; + const genConfig = envelopeRequest?.generationConfig as Record | undefined; + assert.ok(genConfig?.thinkingConfig, "expected thinkingConfig to be set"); + assert.equal( + genConfig.maxOutputTokens, + undefined, + "maxOutputTokens must be undefined when not requested" + ); +}); + // Regression for #2480: when projectId is stored in providerSpecificData rather than at // the top level of the credential record, the Antigravity Cloud Code envelope must still // pick it up — otherwise the /v1beta path 422s with "Missing Google projectId".