fix(antigravity): handle Gemini 3.8 Flash thought signatures, native tool calls, and output token limits

This commit is contained in:
Tuan Dinh
2026-09-11 21:39:44 +07:00
parent fdc4b59d34
commit 4459e75915
7 changed files with 149 additions and 11 deletions

View File

@@ -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<Record<string, unknown>>) : [];
const parts = Array.isArray(record?.parts)
? (record.parts as Array<Record<string, unknown>>)
: [];
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;

View File

@@ -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);

View File

@@ -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<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = stripZeroWidth(v);
}
return out;
}
return value;
}
@@ -54,7 +64,25 @@ function extractGeminiMarkdownShortcut(parsed: Record<string, unknown>): string
/** Append one candidate content part (text or textual tool call) onto the accumulator. */
function applyCandidatePart(part: Record<string, unknown>, 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<string, unknown> | 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) {

View File

@@ -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;

View File

@@ -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");
});

View File

@@ -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",
});
});

View File

@@ -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<string, unknown>;
const envelopeRequest = result.request as Record<string, unknown> | undefined;
const genConfig = envelopeRequest?.generationConfig as Record<string, unknown> | 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".