mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 06:12:17 +03:00
fix(antigravity): handle Gemini 3.8 Flash thought signatures, native tool calls, and output token limits
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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".
|
||||
|
||||
Reference in New Issue
Block a user