import crypto, { randomUUID } from "crypto"; import { BaseExecutor, mergeAbortSignals, mergeUpstreamExtraHeaders, type ExecuteInput, type ExecutorLog, type ProviderCredentials, } from "./base.ts"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; import { buildAntigravityUpstreamError } from "./antigravityUpstreamError.ts"; import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS, STREAM_READINESS_TIMEOUT_MS, ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE, } from "../config/constants.ts"; import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts"; import { antigravityNativeOAuthUserAgent, antigravityUserAgent, } from "../services/antigravityHeaders.ts"; import { classify429, decide429, type Decision } from "../services/antigravity429Engine.ts"; import { injectCreditsField, shouldRetryWithCredits, shouldUseCreditsFirst, getCreditsMode, handleCreditsFailure, } from "../services/antigravityCredits.ts"; import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/creditBalance"; import { setConnectionRateLimitUntil } from "@/lib/db/providers"; import { getMitmAlias } from "@/lib/db/models"; import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts"; import { resolveAntigravityVersion } from "../services/antigravityVersion.ts"; import { ensureAntigravityProjectAssigned } from "../services/antigravityProjectBootstrap.ts"; import { resolveAntigravityModelId, getAntigravityModelFallbacks, } from "../config/antigravityModelAliases.ts"; import { cloakAntigravityToolPayload } from "../config/toolCloaking.ts"; import { shouldStripCloudCodeThinking, stripCloudCodeThinkingConfig, } from "../services/cloudCodeThinking.ts"; import { buildGeminiTools } from "../translator/helpers/geminiToolsSanitizer.ts"; import { DEFAULT_SAFETY_SETTINGS } from "../translator/helpers/geminiHelper.ts"; import { type AntigravityCollectedStream, processAntigravitySSEText, flushAntigravitySSEText, } from "./antigravity/sseCollect.ts"; // processAntigravitySSEPayload re-exported for external importers (tests). export { processAntigravitySSEPayload } from "./antigravity/sseCollect.ts"; import { applyAntigravityClientProfileHeaders, removeHeaderCaseInsensitive, } from "../services/antigravityClientProfile.ts"; import { generateAntigravityRequestId, getAntigravityEnvelopeUserAgent, getAntigravitySessionId, } from "../services/antigravityIdentity.ts"; import * as prl from "../utils/providerRequestLogging.ts"; const MAX_RETRY_AFTER_MS = 60_000; const LONG_RETRY_THRESHOLD_MS = 60_000; const CREDITS_EXHAUSTED_TTL_MS = 5 * 60 * 60 * 1000; // 5 hours // Cap for transient 5xx backoff — shorter than the 429 cap to avoid long stalls on // infra hiccups ("Agent execution terminated", "high traffic", capacity errors). const ANTIGRAVITY_TRANSIENT_RETRY_MAX_MS = 15_000; const ANTIGRAVITY_TRANSIENT_ERROR_PATTERNS: RegExp[] = [ /high\s+traffic/i, /agent\s+(execution\s+)?terminated\s+due\s+to\s+error/i, /capacity/i, /temporarily\s+unavailable/i, /timeout/i, /stream\s+(ended|closed|terminated|interrupted)/i, /empty\s+response/i, ]; const ANTIGRAVITY_TRANSIENT_STATUSES = new Set([ HTTP_STATUS.SERVER_ERROR, HTTP_STATUS.BAD_GATEWAY, HTTP_STATUS.SERVICE_UNAVAILABLE, HTTP_STATUS.GATEWAY_TIMEOUT, ]); // The upstream API uses plain model IDs (no -high/-low suffix). // Tier suffixes were speculative and caused 404 for gemini-3.x models — the // bare-Pro→Low normalization was retired (the set stayed empty, making the guard // dead code). Only keep models that are live-proven via streamGenerateContent. interface AntigravityContent { role: string; parts: unknown[]; [key: string]: unknown; } type AntigravityCredentials = ProviderCredentials & { projectId?: string | null; expiresIn?: number; }; type AntigravityChunkContent = Record & { role?: string; parts?: Array< Record & { text?: unknown; functionCall?: Record; functionResponse?: unknown; thought?: unknown; thoughtSignature?: unknown; } >; }; type AntigravityCreditEntry = { creditType?: string; creditAmount?: string; }; function getChunkedOrFixedBody(bodyStr: string, stream: boolean): BodyInit { if (stream) { return new ReadableStream( { async start(controller) { controller.enqueue(new TextEncoder().encode(bodyStr)); controller.close(); }, }, { highWaterMark: 16384 } ); } return bodyStr; } function cloneAntigravityRequestBody(body: unknown): unknown { if (!body || typeof body !== "object") { return body; } try { return structuredClone(body); } catch { return JSON.parse(JSON.stringify(body)); } } function serializeAntigravityRequest( provider: string, headers: Record, body: unknown ): { headers: Record; bodyString: string } { const serializedBody = cloneAntigravityRequestBody(body); if (!isCliCompatEnabled(provider)) { return { headers, bodyString: JSON.stringify(serializedBody) }; } return applyFingerprint(provider, { ...headers }, serializedBody); } type AntigravityRequestEnvelope = Record & { project: string; model?: string; userAgent: "antigravity" | "jetski"; requestType: "agent" | "image_gen"; requestId: string; request: Record; enabledCreditTypes?: string[]; }; class AntigravityPreResponseTimeoutError extends Error { code = ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE; status = HTTP_STATUS.GATEWAY_TIMEOUT; constructor(timeoutMs: number, url: string) { super(`Antigravity upstream did not return response headers within ${timeoutMs}ms: ${url}`); this.name = "TimeoutError"; } } function getAbortErrorCode(error: unknown): string | null { if (!error || typeof error !== "object") return null; const value = (error as { code?: unknown }).code; return typeof value === "string" ? value : null; } function isAntigravityPreResponseTimeout(error: unknown): boolean { return getAbortErrorCode(error) === ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE; } /** * Per-account GOOGLE_ONE_AI credits-exhausted tracker. * Key: accountId (OAuth subject / email). Value: expiry timestamp. * When credits hit 0 we skip the credit retry for CREDITS_EXHAUSTED_TTL_MS. */ const MAX_CREDITS_EXHAUSTED_ENTRIES = 50; const creditsExhaustedUntil = new Map(); const _creditsExhaustedSweep = setInterval(() => { const now = Date.now(); for (const [key, until] of creditsExhaustedUntil) { if (now >= until) creditsExhaustedUntil.delete(key); } }, 60_000); if (typeof _creditsExhaustedSweep === "object" && "unref" in _creditsExhaustedSweep) { (_creditsExhaustedSweep as { unref?: () => void }).unref?.(); } const MAX_CREDIT_BALANCE_ENTRIES = 50; const CREDIT_BALANCE_TTL_MS = 5 * 60 * 1000; const creditBalanceCache = new Map(); let creditCacheHydrated = false; function hydrateCreditCacheFromDb(): void { if (creditCacheHydrated) return; creditCacheHydrated = true; try { const persisted = getAllPersistedCreditBalances(); for (const [accountId, balance] of persisted) { if (!creditBalanceCache.has(accountId)) { creditBalanceCache.set(accountId, { balance, updatedAt: Date.now() }); } } } catch {} } function evictStaleCreditBalanceEntries(): void { const now = Date.now(); for (const [key, entry] of creditBalanceCache) { if (now - entry.updatedAt > CREDIT_BALANCE_TTL_MS) { creditBalanceCache.delete(key); } } while (creditBalanceCache.size > MAX_CREDIT_BALANCE_ENTRIES) { const oldestKey = creditBalanceCache.keys().next().value; if (oldestKey !== undefined) creditBalanceCache.delete(oldestKey); else break; } } const _creditBalanceSweep = setInterval(evictStaleCreditBalanceEntries, 60_000); if (typeof _creditBalanceSweep === "object" && "unref" in _creditBalanceSweep) { (_creditBalanceSweep as { unref?: () => void }).unref?.(); } export function getAntigravityRemainingCredits(accountId: string): number | null { hydrateCreditCacheFromDb(); const entry = creditBalanceCache.get(accountId); if (!entry) return null; if (Date.now() - entry.updatedAt > CREDIT_BALANCE_TTL_MS) { creditBalanceCache.delete(accountId); return null; } return entry.balance; } export function updateAntigravityRemainingCredits(accountId: string, balance: number): void { if (creditBalanceCache.size >= MAX_CREDIT_BALANCE_ENTRIES && !creditBalanceCache.has(accountId)) { const oldestKey = creditBalanceCache.keys().next().value; if (oldestKey !== undefined) creditBalanceCache.delete(oldestKey); } creditBalanceCache.set(accountId, { balance, updatedAt: Date.now() }); try { persistCreditBalance(accountId, balance); } catch {} } function isCreditsExhausted(accountId: string): boolean { const until = creditsExhaustedUntil.get(accountId); if (!until) return false; if (Date.now() >= until) { creditsExhaustedUntil.delete(accountId); return false; } return true; } function markCreditsExhausted(accountId: string): void { if ( creditsExhaustedUntil.size >= MAX_CREDITS_EXHAUSTED_ENTRIES && !creditsExhaustedUntil.has(accountId) ) { const now = Date.now(); for (const [key, until] of creditsExhaustedUntil) { if (now >= until) { creditsExhaustedUntil.delete(key); } } if (creditsExhaustedUntil.size >= MAX_CREDITS_EXHAUSTED_ENTRIES) { const oldestKey = creditsExhaustedUntil.keys().next().value; if (oldestKey !== undefined) creditsExhaustedUntil.delete(oldestKey); } } creditsExhaustedUntil.set(accountId, Date.now() + CREDITS_EXHAUSTED_TTL_MS); } /** * Persist a quota-exhausted cooldown to the DB for `connectionId` so that * cross-request and post-restart routing skips this connection until the * cooldown expires. Exported for unit testing. @internal */ export function markConnectionQuotaExhausted(connectionId: string, retryAfterMs: number): void { try { setConnectionRateLimitUntil(connectionId, Date.now() + retryAfterMs); } catch { // DB write failure must never crash the request path } } /** * Accumulate one Antigravity SSE `data:` payload into `collected`. Exported for unit * tests (the markdown / candidate-parts extraction branches). @internal */ /** * Strip provider prefixes (e.g. "antigravity/model" → "model"). * Ensures the model name sent to the upstream API never contains a routing prefix. * * `modelIdOverride` (#3786): when the per-request Pro-family fallback chain forces a * specific upstream id, pass it here. It is an ALREADY-RESOLVED upstream id, so it bypasses * the MITM/static alias resolution and is used verbatim (after prefix stripping). */ async function cleanModelName(model: string, modelIdOverride?: string): Promise { if (modelIdOverride) { return modelIdOverride.includes("/") ? modelIdOverride.split("/").pop()! : modelIdOverride; } if (!model) return model; const stripped = model.includes("/") ? model.split("/").pop()! : model; let clean = stripped; // 1. Check dynamic MITM aliases first (authoritative after first sync). // Built during model sync — contains ONLY currently-available models. // Obsolete/removed models are automatically excluded. try { const mitmAliases = await getMitmAlias("antigravity"); if (mitmAliases && typeof mitmAliases === "object") { const aliases = mitmAliases as Record; const raw = aliases[stripped]; // Only honor string aliases; corrupted/non-string DB values fall through // to the static alias resolution below (never return undefined here). if (typeof raw === "string" && raw) { // Strip the "antigravity/" prefix if present; use the raw model ID otherwise. const PREFIX = "antigravity/"; clean = raw.startsWith(PREFIX) ? raw.slice(PREFIX.length) : raw; } } } catch { // DB not available (build phase, transient error) — fall through to static aliases } // 2. Fall back to static aliases if MITM didn't resolve if (clean === stripped) { clean = resolveAntigravityModelId(clean); } return clean; } function attachToolNameMap(payload: T, toolNameMap: Map | null): T { if (!toolNameMap?.size || !payload || typeof payload !== "object") { return payload; } const copy = Array.isArray(payload) ? ([...payload] as T) : ({ ...(payload as object) } as T); Object.defineProperty(copy, "_toolNameMap", { value: toolNameMap, enumerable: false, configurable: true, writable: true, }); return copy; } function getRequestTargetModel(body: Record): string { const target = body.model; return typeof target === "string" && target.length > 0 ? target : "unknown"; } /** * Hard ceiling on `generationConfig.maxOutputTokens` for Antigravity Cloud Code. * * Ports decolua/9router#779 (lukmanfauzie): VS Code GitHub Copilot Chat in * Agent mode regularly requests 32K–65K output tokens, which the Antigravity * backend rejects with HTTP 400 "Invalid Argument". 16384 matches the * upstream-accepted ceiling confirmed via successful 200 OK runs with * claude-sonnet-4-6 and gemini-3.1-pro-high across both Ask and Agent modes. */ export const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384; function applyAntigravityGenerationDefaults(request: Record): void { const generationConfig = request.generationConfig && typeof request.generationConfig === "object" ? (request.generationConfig as Record) : {}; if (generationConfig.topK === undefined) { generationConfig.topK = 40; } if (generationConfig.topP === undefined) { generationConfig.topP = 1.0; } const thinkingConfig = generationConfig.thinkingConfig && typeof generationConfig.thinkingConfig === "object" ? (generationConfig.thinkingConfig as Record) : null; const thinkingBudget = Number(thinkingConfig?.thinkingBudget); const maxOutputTokens = Number(generationConfig.maxOutputTokens); if ( Number.isFinite(thinkingBudget) && thinkingBudget > 0 && (!Number.isFinite(maxOutputTokens) || maxOutputTokens <= thinkingBudget) ) { generationConfig.maxOutputTokens = Math.floor(thinkingBudget) + 1; } // Final cap (after the thinkingBudget bump may have raised the value): // GitHub Copilot Agent envelopes commonly carry oversized maxOutputTokens // (32K–65K) that trigger upstream 400 "Invalid Argument". Clamp silently // — the cap is provider-driven, not client-driven, and only matters when // the request would otherwise be rejected outright. const finalMax = Number(generationConfig.maxOutputTokens); if (Number.isFinite(finalMax) && finalMax > MAX_ANTIGRAVITY_OUTPUT_TOKENS) { generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS; } request.generationConfig = generationConfig; } // Test-only export so the unit suite can exercise the cap logic in isolation // without spinning up the full executor. export const __test_applyAntigravityGenerationDefaults = applyAntigravityGenerationDefaults; function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } function sanitizeAntigravityGeminiRequest( request: Record ): Record { const clean: Record = {}; if (Array.isArray(request.contents)) { clean.contents = request.contents; } if (asRecord(request.systemInstruction)) { clean.systemInstruction = request.systemInstruction; } clean.generationConfig = asRecord(request.generationConfig) ? { ...(request.generationConfig as Record) } : {}; const geminiTools = buildGeminiTools(request.tools); if (geminiTools) { clean.tools = geminiTools; clean.toolConfig = { functionCallingConfig: { mode: "VALIDATED" } }; } else if (asRecord(request.toolConfig)) { clean.toolConfig = request.toolConfig; } if (typeof request.sessionId === "string") { clean.sessionId = request.sessionId; } // #5003: preserve safetySettings through the Claude-path whitelist so the all-OFF // default (or a caller-supplied value) actually reaches Google Cloud Code. Without // this the field is dropped and Google applies its own safety defaults that // false-flag benign technical prompts as `prohibited_content`. if (Array.isArray(request.safetySettings)) { clean.safetySettings = request.safetySettings; } return clean; } /** * Ported from decolua/9router#2321 (anki1kr): Vertex AI (used by Antigravity for * Claude-branded models) rejects a conversation ending on an assistant turn — * "This model does not support assistant message prefill" — so the request must * always end on a user turn. Upstream patched `openaiToClaudeRequestForAntigravity` * (dead code here, zero callers — see `open-sse/translator/request/openai-to-claude.ts`); * this relocates the same strip to the LIVE Antigravity dispatch path, where Claude * requests are converted to Gemini `contents` (assistant role is `"model"`, not * `"assistant"`). Mirrors the trailing-strip pop-loop already used for Mistral * (#3396), Copilot (#5802), and the CC-bridge in `claudeCodeCompatible.ts`. * * Scoped strictly to the Claude path by the caller (`isClaude` branch only) — native * Gemini models via Antigravity must be unaffected, since Vertex-Claude is the only * documented rejection surface. * * Guard: never strip `contents` down to empty — an empty `contents` array is itself * an invalid request, so at least one entry (even a lone trailing "model" turn) is * always preserved. */ function stripTrailingAntigravityAssistantTurn( request: Record ): Record { const contents = request.contents; if (!Array.isArray(contents) || contents.length === 0) { return request; } while ( contents.length > 1 && (contents[contents.length - 1] as AntigravityContent)?.role === "model" ) { contents.pop(); } return request; } // Test-only export so the unit suite can exercise the strip logic directly. export const __test_stripTrailingAntigravityAssistantTurn = stripTrailingAntigravityAssistantTurn; export class AntigravityExecutor extends BaseExecutor { constructor() { super("antigravity", PROVIDERS.antigravity); } buildUrl(model: string, _stream: boolean, urlIndex = 0): string { void model; const baseUrls = this.getBaseUrls(); const baseUrl = baseUrls[urlIndex] || baseUrls[0]; // Always use streaming endpoint — the non-streaming `generateContent` causes // upstream 400 errors for some models (e.g. gpt-oss-120b-medium) because the // Cloud Code API internally converts to OpenAI format and injects // stream_options without setting stream=true. chatCore already handles // SSE→JSON conversion for non-streaming client requests. return `${baseUrl}/v1internal:streamGenerateContent?alt=sse`; } buildHeaders(credentials: AntigravityCredentials, _stream = true): Record { const raw = { "Content-Type": "application/json", Authorization: `Bearer ${credentials.accessToken}`, "User-Agent": antigravityUserAgent(), Accept: "text/event-stream", "X-OmniRoute-Source": "omniroute", }; // Scrub proxy/fingerprint headers that reveal non-native traffic return scrubProxyAndFingerprintHeaders(raw); } async transformRequest( model: string, body: unknown, _stream: boolean, credentials: AntigravityCredentials, modelIdOverride?: string ): Promise { // Project ID resolution: prefer OAuth-stored projectId over incoming body.project // to avoid stale/wrong client-side values causing 404/403 from Cloud Code endpoints. // Opt-in escape hatch: set OMNIROUTER_ALLOW_BODY_PROJECT_OVERRIDE=1. const normalizeProjectId = (value: unknown): string | null => { if (typeof value !== "string") return null; const trimmedValue = value.trim(); return trimmedValue ? trimmedValue : null; }; const bodyRecord = asRecord(body) ?? {}; const bodyProjectId = normalizeProjectId(bodyRecord.project); const credentialsProjectId = normalizeProjectId(credentials?.projectId); const providerSpecificProjectId = normalizeProjectId( (credentials?.providerSpecificData as Record | undefined)?.projectId ); const allowBodyProjectOverride = process.env.OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE === "1"; // Default: prefer OAuth-stored projectId over incoming body.project to avoid // stale/wrong client-side values causing 404/403 from Cloud Code endpoints. // Opt-in escape hatch: set OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=1. let projectId = allowBodyProjectOverride && bodyProjectId ? bodyProjectId : credentialsProjectId || providerSpecificProjectId || bodyProjectId; // Auto-discover a missing projectId via loadCodeAssist before failing (#2334/#2541). // A freshly re-added Antigravity account can have an empty stored projectId even when // its Google account already owns a Cloud Code project (the OAuth-time loadCodeAssist // returned empty/transiently failed). Mirror the Cloud Code bootstrap to recover it // here — the helper memoizes per access-token, so this is a one-time round-trip. if (!projectId && credentials?.accessToken) { const discovered = await ensureAntigravityProjectAssigned(credentials.accessToken); if (discovered) projectId = discovered; } if (!projectId) { // (#489) Return a structured error instead of throwing — gives the client a clear signal // to show a "Reconnect OAuth" prompt rather than an opaque "Internal Server Error". const errorMsg = "Missing Google projectId for Antigravity account. Auto-discovery via loadCodeAssist " + "found no Cloud Code project. Please reconnect OAuth in Providers → Antigravity (and " + "ensure the Google account has completed Gemini Code Assist onboarding)."; const errorBody = { error: { message: errorMsg, type: "oauth_missing_project_id", code: "missing_project_id", }, }; const resp = new Response(JSON.stringify(errorBody), { status: 422, headers: { "Content-Type": "application/json" }, }); // Returning a Response object signals the executor to stop and forward it return resp as unknown as never; } // Validate projectId is non-empty and not just whitespace const trimmedProjectId = typeof projectId === "string" ? projectId.trim() : projectId; if (!trimmedProjectId) { const resp = new Response( JSON.stringify({ error: { message: "Invalid (empty) Google projectId for Antigravity account. " + "Please reconnect OAuth in Providers → Antigravity.", type: "oauth_missing_project_id", code: "missing_project_id", }, }), { status: 422, headers: { "Content-Type": "application/json" } } ); return resp as unknown as never; } const upstreamModel = await cleanModelName(model, modelIdOverride); const isClaude = upstreamModel.toLowerCase().includes("claude"); const baseBody = bodyRecord; const normalizedBody = shouldStripCloudCodeThinking(this.provider, upstreamModel) ? stripCloudCodeThinkingConfig(baseBody) : baseBody; const normalizedRequest = asRecord(normalizedBody.request); const rawContents = Array.isArray(normalizedRequest?.contents) ? normalizedRequest.contents : []; // Fix contents for Gemini-compatible Cloud Code requests via Antigravity. // Claude-branded Antigravity models use the same streamGenerateContent schema. const normalizedContents: AntigravityContent[] = rawContents.map((content): AntigravityContent => { const c = content as AntigravityChunkContent; let role = typeof c.role === "string" ? c.role : "user"; if (c.parts?.some((p) => p.functionResponse)) { role = "user"; } const hasFunctionCall = c.parts?.some((p) => p.functionCall) || false; const parts = c.parts?.filter((p) => { if (typeof p.text === "string" && p.text === "") return false; if (p.functionCall && !p.functionCall.name) return false; // Only strip if it's NOT our bypass sentinel. // Antigravity models (like Gemini) need this sentinel to bypass 400 errors. return ( !p.thought && (hasFunctionCall || !p.thoughtSignature || p.thoughtSignature === "skip_thought_signature_validator") ); }) || []; return { ...c, role, parts }; }) || []; const contents: AntigravityContent[] = []; for (const c of normalizedContents) { if (!Array.isArray(c.parts) || c.parts.length === 0) continue; if (contents.length > 0 && contents[contents.length - 1].role === c.role) { contents[contents.length - 1].parts.push(...c.parts); } else { contents.push(c); } } const rawTransformedRequest = { ...normalizedRequest, ...(contents.length > 0 && { contents }), sessionId: getAntigravitySessionId( credentials, typeof normalizedRequest?.sessionId === "string" ? normalizedRequest.sessionId : undefined ), // #5003: default to all-OFF safety for parity with the native Gemini paths // (claude-to-gemini / openai-to-gemini both default to DEFAULT_SAFETY_SETTINGS). // Previously this was `undefined`, which JSON.stringify drops, so Google Cloud Code // applied its server-side defaults that false-flag benign technical prompts as // `prohibited_content` (HTTP 200 + blocked body → terminal combo failover). safetySettings: normalizedRequest?.safetySettings ?? DEFAULT_SAFETY_SETTINGS, toolConfig: Array.isArray(normalizedRequest?.tools) && normalizedRequest.tools.length > 0 ? { functionCallingConfig: { mode: "VALIDATED" } } : normalizedRequest?.toolConfig, }; const transformedRequest = isClaude ? stripTrailingAntigravityAssistantTurn(sanitizeAntigravityGeminiRequest(rawTransformedRequest)) : rawTransformedRequest; // Obfuscate sensitive client names in user content (e.g. "OpenCode", "Cursor") const requestContents = transformedRequest.contents; if (Array.isArray(requestContents)) { for (const msg of requestContents) { if (Array.isArray(msg.parts)) { for (const part of msg.parts) { if (typeof part.text === "string") { part.text = obfuscateSensitiveWords(part.text); } } } } } applyAntigravityGenerationDefaults(transformedRequest); const { project: _project, model: _model, userAgent: _userAgent, requestType: _requestType, requestId: _requestId, request: _request, // #1944: output_config (and the legacy output_format) are Anthropic/Claude-Code-only // fields. Google's Cloud Code envelope rejects unknown top-level fields with a 400 // ("Invalid JSON payload received. Unknown name \"output_config\""), which broke every // Claude model served via Antigravity. Drop them so they never reach the envelope. output_config: _outputConfig, output_format: _outputFormat, // #1926: the unified thinking adapter can also set Claude/OpenAI-native thinking fields // at the body root. Google rejects them with `400 Bad input: oneOf at '/' not met` // (or `Unknown name "thinking"`), breaking every reasoning/thinking model served via // Antigravity (e.g. claude-opus-4-x-thinking). Strip the whole thinking family too. thinking: _thinking, reasoning_effort: _reasoningEffort, reasoning: _reasoning, enable_thinking: _enableThinking, thinking_budget: _thinkingBudget, ...passthroughFields } = normalizedBody; const requestType = _requestType === "image_gen" ? "image_gen" : "agent"; const envelope: AntigravityRequestEnvelope = { project: projectId, requestId: generateAntigravityRequestId(), request: transformedRequest, model: upstreamModel, userAgent: getAntigravityEnvelopeUserAgent(credentials), requestType, ...passthroughFields, }; if (requestType === "agent" && envelope.enabledCreditTypes === undefined) { envelope.enabledCreditTypes = ["GOOGLE_ONE_AI"]; } return envelope; } async refreshCredentials( credentials: AntigravityCredentials, log?: ExecutorLog | null ): Promise { if (!credentials.refreshToken) return null; try { const bodyParams: Record = { grant_type: "refresh_token", refresh_token: credentials.refreshToken, }; // Only include non-empty client_id/client_secret — Google OAuth rejects // empty params which raw URLSearchParams produces (buildFormParams semantics). if (this.config.clientId) bodyParams.client_id = this.config.clientId; if (this.config.clientSecret) bodyParams.client_secret = this.config.clientSecret; const response = await fetch(OAUTH_ENDPOINTS.google.token, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": antigravityNativeOAuthUserAgent(), }, body: new URLSearchParams(bodyParams), }); if (!response.ok) { // Detect unrecoverable token (invalid_grant = revoked / expired refresh token) try { const errorBody = (await response.json()) as Record; if (errorBody.error === "invalid_grant") { log?.error?.("TOKEN", "Antigravity refresh token revoked. Re-authentication required."); return { error: "unrecoverable_refresh_error" } as unknown as AntigravityCredentials; } } catch { // not JSON — fall through } return null; } const tokens = (await response.json()) as Record; log?.info?.("TOKEN", "Antigravity refreshed"); return { accessToken: typeof tokens.access_token === "string" ? tokens.access_token : undefined, refreshToken: typeof tokens.refresh_token === "string" && tokens.refresh_token ? tokens.refresh_token : credentials.refreshToken, expiresIn: typeof tokens.expires_in === "number" ? tokens.expires_in : undefined, projectId: credentials.projectId, // Preserve providerSpecificData so a projectId stored there survives the refresh // (the onCredentialsRefreshed DB write) instead of being dropped → 422 (#2480). providerSpecificData: credentials.providerSpecificData, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); log?.error?.("TOKEN", `Antigravity refresh error: ${message}`); return null; } } generateSessionId(): string { return `-${parseInt(randomUUID().replace(/-/g, "").substring(0, 8), 16) % 9_000_000_000_000_000_000}`; } parseRetryHeaders(headers: Headers | null | undefined): number | null { if (!headers?.get) return null; const retryAfter = headers.get("retry-after"); if (retryAfter) { const seconds = parseInt(retryAfter, 10); if (!isNaN(seconds) && seconds > 0) return seconds * 1000; const date = new Date(retryAfter); if (!isNaN(date.getTime())) { const diff = date.getTime() - Date.now(); return diff > 0 ? diff : null; } } const resetAfter = headers.get("x-ratelimit-reset-after"); if (resetAfter) { const seconds = parseInt(resetAfter, 10); if (!isNaN(seconds) && seconds > 0) return seconds * 1000; } const resetTimestamp = headers.get("x-ratelimit-reset"); if (resetTimestamp) { const ts = parseInt(resetTimestamp, 10) * 1000; const diff = ts - Date.now(); return diff > 0 ? diff : null; } return null; } // Parse retry time from Antigravity error message body // Format: "Your quota will reset after 2h7m23s" or "Resets in 160h27m24s" or // "1h30m" or "45m" or "30s". The optional plural ("resets in") must match too (#1308). parseRetryFromErrorMessage(errorMessage: unknown): number | null { if (!errorMessage || typeof errorMessage !== "string") return null; const match = errorMessage.match(/resets? (?:after|in) (\d+h)?(\d+m)?(\d+s)?/i); if (!match) return null; let totalMs = 0; if (match[1]) totalMs += parseInt(match[1]) * 3600 * 1000; // hours if (match[2]) totalMs += parseInt(match[2]) * 60 * 1000; // minutes if (match[3]) totalMs += parseInt(match[3]) * 1000; // seconds // "reset after 0s" = burst/RPM limit, not quota exhaustion. // Return a minimum backoff so the auto-retry loop handles it // instead of falling through to the 24h exhaustion classifier. if (totalMs === 0) return 2_000; // 2s minimum burst-limit backoff return totalMs; } /** * Flatten an Antigravity error JSON + raw body text into a single string so * isTransientAntigravityError can match against body patterns. */ extractErrorMessage(errorJson: unknown, bodyText = ""): string { const candidates: string[] = []; if (errorJson && typeof errorJson === "object") { const obj = errorJson as Record; const errField = obj.error; if (errField && typeof errField === "object") { const msg = (errField as Record).message; if (typeof msg === "string") candidates.push(msg); else if (msg != null) candidates.push(JSON.stringify(msg)); } else if (typeof errField === "string") { candidates.push(errField); } if (typeof obj.message === "string") candidates.push(obj.message); } if (bodyText) candidates.push(bodyText); return candidates.filter(Boolean).join("\n"); } /** * Return true when a status + error message combination should be retried * with exponential backoff instead of immediately failing-over to the next URL. * 429 is always transient. Transient 5xx statuses (500/502/503/504) are also * retried when the body contains a known capacity/traffic/agent pattern. */ isTransientAntigravityError(status: number, message: string): boolean { if (status === HTTP_STATUS.RATE_LIMITED) return true; if (ANTIGRAVITY_TRANSIENT_STATUSES.has(status)) return true; return ANTIGRAVITY_TRANSIENT_ERROR_PATTERNS.some((p) => p.test(message || "")); } /** * Collect an SSE streaming response into a single non-streaming JSON response. * Parses Gemini-format SSE chunks and assembles text content + usage into one * OpenAI-format chat.completion payload. */ collectStreamToResponse( response: Response, model: string, url: string, headers: Record, transformedBody: Record, log?: ExecutorLog | null, signal?: AbortSignal | null ) { if (!response.body) { return Promise.resolve({ response, url, headers, transformedBody }); } const reader = response.body.getReader(); const decoder = new TextDecoder(); const logger = log || undefined; const SSE_COLLECT_TIMEOUT_MS = 120_000; const collect = async () => { const collected: AntigravityCollectedStream = { textContent: "", finishReason: "stop", toolCalls: [], usage: null, remainingCredits: null, }; const partialLine = { value: "" }; let timedOut = false; const timeout = AbortSignal.timeout(SSE_COLLECT_TIMEOUT_MS); try { while (true) { if (signal?.aborted) throw new Error("Request aborted during SSE collection"); const { done, value } = await Promise.race([ reader.read(), new Promise((_, reject) => timeout.addEventListener( "abort", () => reject(new Error("SSE collection timed out")), { once: true } ) ), ]); if (done) break; processAntigravitySSEText( decoder.decode(value, { stream: true }), partialLine, collected, logger ); } } catch (err) { const msg = err?.message || String(err); timedOut = msg.includes("timed out"); log?.warn?.("SSE_COLLECT", `Error collecting SSE stream: ${msg}`); // Cancel the stream to prevent locking the socket in Undici pool try { reader.releaseLock(); } catch (_) {} try { response.body?.cancel().catch(() => {}); } catch (_) {} } finally { try { reader.releaseLock(); } catch (_) {} } processAntigravitySSEText(decoder.decode(), partialLine, collected, logger); flushAntigravitySSEText(partialLine, collected, logger); const result = { id: `chatcmpl-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model, choices: [ { index: 0, message: collected.toolCalls.length > 0 ? { role: "assistant", content: collected.textContent || null, tool_calls: collected.toolCalls, } : { role: "assistant", content: collected.textContent }, finish_reason: timedOut ? "length" : collected.toolCalls.length > 0 ? "tool_calls" : collected.finishReason, }, ], ...(collected.usage && { usage: collected.usage }), // Expose credit balance for upstream consumers (usage service, dashboard) ...(collected.remainingCredits && { _remainingCredits: collected.remainingCredits }), }; const syntheticStatus = timedOut ? 504 : response.status; const syntheticResponse = new Response(JSON.stringify(result), { status: syntheticStatus, statusText: timedOut ? "Gateway Timeout" : response.statusText, headers: [["Content-Type", "application/json"]], }); return { response: syntheticResponse, url, headers, transformedBody }; }; return collect(); } /** * #3786 — Drive the per-request Pro-family upstream-id FALLBACK CHAIN. * * The upstream silently renamed the Gemini 3.1 Pro-high id (HTTP 400 on the old id) and the * live id cannot be known from static analysis (competitor proxies disagree). When the * resolved upstream id has a fallback chain (see ANTIGRAVITY_PRO_FALLBACK_CHAINS) we try the * requested id first and, ONLY on a 400, retry the next candidate until one succeeds (2xx) * or the chain is exhausted — then the original 400 surfaces (sanitized, hard rule #12). * * Off the happy path entirely: a model with no chain, or whose first id is not a 400, makes * exactly the same single call as before (zero extra upstream requests). */ async execute(input: ExecuteInput) { await resolveAntigravityVersion(); // Look up the chain by the NORMALLY-resolved upstream id (honours MITM/static aliases). // If a MITM alias remapped the id away from a known Pro tier, no chain applies → fast path. const resolvedUpstreamId = await cleanModelName(input.model); const chain = getAntigravityModelFallbacks(resolvedUpstreamId); if (chain.length <= 1) { // No fallback chain (flash, claude, plain pro, unknown) → single attempt, unchanged. return this.executeOnce(input); } let firstResult: Awaited> | null = null; for (let i = 0; i < chain.length; i++) { const candidate = chain[i]; const result = await this.executeOnce(input, candidate); // Success (or any non-400) on a candidate → return immediately. if (result.response.status !== HTTP_STATUS.BAD_REQUEST) { return result; } // Remember the FIRST 400 so the exhausted-chain case surfaces the original error. if (i === 0) firstResult = result; const isLast = i === chain.length - 1; if (!isLast) { input.log?.debug?.( "AG_PRO_FALLBACK", `400 on "${candidate}" — retrying with next Pro candidate "${chain[i + 1]}"` ); continue; } // Chain exhausted: surface the FIRST candidate's sanitized 400. input.log?.warn?.( "AG_PRO_FALLBACK", `Pro fallback chain exhausted (all ${chain.length} candidates 400'd) for "${resolvedUpstreamId}"` ); return firstResult ?? result; } // Unreachable (loop always returns), but keeps the type checker happy. return firstResult ?? this.executeOnce(input); } /** * #3786 — Run the request once for a SINGLE resolved upstream model id. The Pro-family * fallback chain in `execute()` calls this per candidate (`modelIdOverride`), retrying the * next id on a 400. `modelIdOverride === undefined` is the normal (non-chain) path and * preserves the prior behavior exactly. Returns the executor result plus the upstream * status of the first response so `execute()` can decide whether to fall through. @internal */ private async executeOnce( { model, body, stream, credentials, signal, log, upstreamExtraHeaders }: ExecuteInput, modelIdOverride?: string ) { await resolveAntigravityVersion(); const fallbackCount = this.getFallbackCount(); let lastError = null; let lastStatus = 0; const MAX_AUTO_RETRIES = 3; const retryAttemptsByUrl: Record = {}; // Track retry attempts per URL // Always stream upstream — buildUrl always returns the streaming endpoint. // For non-streaming clients, we collect the SSE below and return a synthetic // non-streaming Response so chatCore's non-streaming path stays unchanged. const upstreamStream = true; // Account ID for credits tracking. // Use connectionId as the stable cache key — it's available in both the executor // (via credentials.connectionId) and the usage fetcher (via connection.id). // The email-based key was unreliable because email isn't always on the credentials object. const accountId: string = credentials?.connectionId || "unknown"; // Resolve credits mode once per execute() call. "always" injects // enabledCreditTypes: ["GOOGLE_ONE_AI"] on the first request so the // preflight normal call is skipped entirely. const creditsMode = getCreditsMode(); const useCreditsFirst = shouldUseCreditsFirst(credentials?.accessToken || "", creditsMode); const fetchWithReadinessTimeout = async ( url: string, init: RequestInit, timeoutMs = STREAM_READINESS_TIMEOUT_MS ): Promise => { const boundedTimeoutMs = Math.max(0, Math.floor(timeoutMs)); if (boundedTimeoutMs <= 0) { return fetch(url, init); } const timeoutController = new AbortController(); let timeoutId: ReturnType | null = setTimeout(() => { timeoutController.abort(new AntigravityPreResponseTimeoutError(boundedTimeoutMs, url)); }, boundedTimeoutMs); const existingSignal = init.signal instanceof AbortSignal ? init.signal : null; const combinedSignal = existingSignal ? mergeAbortSignals(existingSignal, timeoutController.signal) : timeoutController.signal; try { return await fetch(url, { ...init, signal: combinedSignal }); } catch (error) { if ( timeoutController.signal.aborted && isAntigravityPreResponseTimeout(timeoutController.signal.reason) ) { throw timeoutController.signal.reason; } throw error; } finally { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } } }; for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, upstreamStream, urlIndex); const headers = this.buildHeaders(credentials, upstreamStream); mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); const transformed = await this.transformRequest( model, body, upstreamStream, credentials, modelIdOverride ); let requestToolNameMap: Map | null = null; if (transformed instanceof Response) { return { response: transformed, url, headers, transformedBody: body }; } let transformedBody: Record = transformed; if (transformedBody && typeof transformedBody === "object") { const cloaked = cloakAntigravityToolPayload(transformedBody); transformedBody = cloaked.body; requestToolNameMap = cloaked.toolNameMap; } // Credits-first: inject GOOGLE_ONE_AI upfront so we never try the normal // quota path. If credits are exhausted / disabled shouldUseCreditsFirst() // returns false and we fall back to the legacy retry-on-429 flow. if (useCreditsFirst) { transformedBody = injectCreditsField(transformedBody); log?.debug?.("AG_CREDITS", "Credits-first enabled (ANTIGRAVITY_CREDITS=always)"); } // Initialize retry counter for this URL if (!retryAttemptsByUrl[urlIndex]) { retryAttemptsByUrl[urlIndex] = 0; } try { const serializedRequest = serializeAntigravityRequest( this.provider, headers, transformedBody ); let finalHeaders = serializedRequest.headers; const capture = (h: Record, s: string) => prl.captureCurrentProviderBody(url, h, s, log); const clientProfile = applyAntigravityClientProfileHeaders( finalHeaders, credentials, transformedBody ); log?.debug?.( "TELEMETRY", `[Antigravity] Execute - URL: ${url}, Model: ${model}, Target: ${getRequestTargetModel(transformedBody)}, RetryAttempt: ${retryAttemptsByUrl[urlIndex]}` ); // Dump outgoing headers (mask Authorization) and envelope shape for debugging if (log?.debug) { const safeHeaders = { ...finalHeaders }; if (safeHeaders["Authorization"]) safeHeaders["Authorization"] = "Bearer ***"; log.debug("AG_REQUEST_HEADERS", JSON.stringify(safeHeaders)); const envelope = transformedBody as Record; const requestInner = envelope.request as Record | undefined; log.debug( "AG_REQUEST_ENVELOPE", JSON.stringify({ fieldOrder: Object.keys(envelope), project: envelope.project, requestId: envelope.requestId, model: envelope.model, userAgent: envelope.userAgent, requestType: envelope.requestType, enabledCreditTypes: envelope.enabledCreditTypes, clientProfile, sessionId: requestInner?.sessionId, generationConfig: requestInner?.generationConfig, }) ); } await capture(finalHeaders, serializedRequest.bodyString); let response = await fetchWithReadinessTimeout(url, { method: "POST", headers: finalHeaders, body: getChunkedOrFixedBody(serializedRequest.bodyString, stream), ...(stream ? { duplex: "half" } : {}), signal, }); if (response.status === HTTP_STATUS.FORBIDDEN && finalHeaders["x-goog-user-project"]) { const retryHeaders = { ...finalHeaders }; removeHeaderCaseInsensitive(retryHeaders, "x-goog-user-project"); log?.debug?.("RETRY", "403 with x-goog-user-project, retrying once without it"); await capture(retryHeaders, serializedRequest.bodyString); response = await fetchWithReadinessTimeout(url, { method: "POST", headers: retryHeaders, body: getChunkedOrFixedBody(serializedRequest.bodyString, stream), ...(stream ? { duplex: "half" } : {}), signal, }); finalHeaders = retryHeaders; } if (!response.ok) { log?.warn?.( "TELEMETRY", `[Antigravity] Error Response - URL: ${url}, Status: ${response.status}, Model: ${model}` ); } // Parse retry time for 429/503 responses let retryMs: number | null = null; if ( response.status === HTTP_STATUS.RATE_LIMITED || response.status === HTTP_STATUS.SERVICE_UNAVAILABLE ) { // Try to get retry time from headers first retryMs = this.parseRetryHeaders(response.headers); // If no retry time in headers, try to parse from error message body if (!retryMs) { try { const errorBody = await response.clone().text(); const errorJson = JSON.parse(errorBody); let errorMessage = errorJson?.error?.message || errorJson?.message || ""; if (errorJson?.error?.details && Array.isArray(errorJson.error.details)) { for (const detail of errorJson.error.details) { if (detail?.reason) { errorMessage += ` ${detail.reason}`; } } } // 1. Try to parse explicit retry time from message const parsedRetryMs = this.parseRetryFromErrorMessage(errorMessage); // 2. Classify 429 (pass header-parsed retry hint as fallback // signal — multi-hour Retry-After upgrades rate_limited to // quota_exhausted so the GOOGLE_ONE_AI credits retry fires). const effectiveRetryHintMs = retryMs ?? parsedRetryMs ?? null; const category = classify429(errorMessage); // 3. Decide final retry time BEFORE the credits retry so that // full_quota_exhausted can skip the credits attempt entirely // (avoids ~41s hold on an already-exhausted account) and // persist the cooldown to DB for post-restart routing. const decision: Decision = decide429(category, parsedRetryMs); retryMs = decision.retryAfterMs; log?.debug?.( "AG_429", `Category: ${category}, Decision: ${decision.kind} — ${decision.reason}` ); if (decision.kind === "full_quota_exhausted" && retryMs) { markConnectionQuotaExhausted(accountId, retryMs); } const creditsAlreadyInjected = (transformedBody as { enabledCreditTypes?: unknown }).enabledCreditTypes != null; if (category === "quota_exhausted" && creditsAlreadyInjected) { handleCreditsFailure(credentials?.accessToken || ""); log?.warn?.("AG_CREDITS", "Credits-first request 429'd — credits likely exhausted"); markCreditsExhausted(accountId); } if ( category === "quota_exhausted" && decision.kind !== "full_quota_exhausted" && !creditsAlreadyInjected && shouldRetryWithCredits(credentials?.accessToken || "", creditsMode !== "off") ) { log?.info?.("AG_CREDITS", "Retrying with Google One AI credits"); const creditsBody = injectCreditsField(transformedBody); const serializedCreditsRequest = serializeAntigravityRequest( this.provider, headers, creditsBody ); const finalCreditsHeaders = serializedCreditsRequest.headers; try { await capture(finalCreditsHeaders, serializedCreditsRequest.bodyString); const creditsResp = await fetchWithReadinessTimeout(url, { method: "POST", headers: finalCreditsHeaders, body: getChunkedOrFixedBody(serializedCreditsRequest.bodyString, stream), ...(stream ? { duplex: "half" } : {}), signal, }); if (creditsResp.ok || creditsResp.status !== HTTP_STATUS.RATE_LIMITED) { log?.info?.("AG_CREDITS", `Credits retry succeeded: ${creditsResp.status}`); if (!stream) { const collected = await this.collectStreamToResponse( creditsResp, model, url, finalCreditsHeaders, creditsBody, log, signal ); // Parse _remainingCredits from the synthetic response and cache try { const syntheticJson = await collected.response.clone().json(); const rc = syntheticJson?._remainingCredits; if (Array.isArray(rc)) { const googleCredit = rc.find((c) => c.creditType === "GOOGLE_ONE_AI"); if (googleCredit) { const balance = parseInt(googleCredit.creditAmount, 10); if (!isNaN(balance)) updateAntigravityRemainingCredits(accountId, balance); } } } catch { /**/ } return { ...collected, transformedBody: attachToolNameMap(creditsBody, requestToolNameMap), }; } return { response: creditsResp, url, headers: finalCreditsHeaders, transformedBody: attachToolNameMap(creditsBody, requestToolNameMap), }; } // Credit retry also 429'd handleCreditsFailure(credentials?.accessToken || ""); log?.warn?.("AG_CREDITS", "Credits retry also 429'd"); // Also mark in our legacy exhaustion map to avoid retrying other routes markCreditsExhausted(accountId); } catch (creditsErr) { handleCreditsFailure(credentials?.accessToken || ""); log?.warn?.("AG_CREDITS", `Credits retry failed: ${creditsErr}`); } } } catch (e) { // Ignore parse errors, will fall back to exponential backoff } } // Bounded short-retry: a non-null retryAfterMs ≤ 60s covers nearly every // 429 (decide429 returns 2s/5s/60s defaults), so this branch MUST share the // per-URL attempt counter. Without the bound a persistent 429 loops forever // on the same endpoint/account (urlIndex-- cancels the loop's urlIndex++) and // never returns the 429 to the account-fallback layer in chat.ts. if ( retryMs && retryMs <= LONG_RETRY_THRESHOLD_MS && retryAttemptsByUrl[urlIndex] < MAX_AUTO_RETRIES ) { retryAttemptsByUrl[urlIndex]++; const effectiveRetryMs = Math.min(retryMs, MAX_RETRY_AFTER_MS); log?.debug?.( "RETRY", `${response.status} retry ${retryAttemptsByUrl[urlIndex]}/${MAX_AUTO_RETRIES} with Retry-After: ${Math.ceil(effectiveRetryMs / 1000)}s, waiting...` ); await new Promise((resolve) => setTimeout(resolve, effectiveRetryMs)); urlIndex--; continue; } // Auto retry for 429 (no Retry-After) or transient 5xx errors. // For 5xx we read the body to detect known transient patterns // ("Agent execution terminated due to error", "high traffic", "capacity"). if ((!retryMs || retryMs === 0) && retryAttemptsByUrl[urlIndex] < MAX_AUTO_RETRIES) { let shouldAutoRetry = response.status === HTTP_STATUS.RATE_LIMITED; if (!shouldAutoRetry && ANTIGRAVITY_TRANSIENT_STATUSES.has(response.status)) { try { const errBody = await response.clone().text(); let errJson: unknown = null; try { errJson = errBody ? JSON.parse(errBody) : null; } catch { // non-JSON body — fall through to pattern match against raw text } const errMsg = this.extractErrorMessage(errJson, errBody); shouldAutoRetry = this.isTransientAntigravityError(response.status, errMsg); } catch { // ignore body read errors } } if (shouldAutoRetry) { retryAttemptsByUrl[urlIndex]++; // Exponential backoff: 2s, 4s, 8s… capped per-status const cap = response.status === HTTP_STATUS.RATE_LIMITED ? MAX_RETRY_AFTER_MS : ANTIGRAVITY_TRANSIENT_RETRY_MAX_MS; const backoffMs = Math.min(1000 * 2 ** retryAttemptsByUrl[urlIndex], cap); log?.debug?.( "RETRY", `${response.status} transient auto retry ${retryAttemptsByUrl[urlIndex]}/${MAX_AUTO_RETRIES} after ${backoffMs / 1000}s` ); await new Promise((resolve) => setTimeout(resolve, backoffMs)); urlIndex--; continue; } } log?.debug?.( "RETRY", `${response.status}, Retry-After ${retryMs ? `too long (${Math.ceil(retryMs / 1000)}s)` : "missing"}, trying fallback` ); lastStatus = response.status; if (urlIndex + 1 < fallbackCount) { continue; } } if (this.shouldRetry(response.status, urlIndex)) { log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`); lastStatus = response.status; continue; } // If we have a 429 with long retry time, embed it in response body if ( response.status === HTTP_STATUS.RATE_LIMITED && retryMs && retryMs > LONG_RETRY_THRESHOLD_MS ) { try { const respBody = await response.clone().text(); let obj; try { obj = JSON.parse(respBody); } catch { obj = {}; } obj.retryAfterMs = retryMs; const modifiedBody = JSON.stringify(obj); const modifiedResponse = new Response(modifiedBody, { status: response.status, headers: response.headers, }); return { response: modifiedResponse, url, headers: finalHeaders, transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), }; } catch (err) { log?.warn?.("RETRY", `Failed to embed retryAfterMs: ${err}`); // Fall back to original response } } // For non-streaming clients, collect the SSE stream and return a synthetic // non-streaming Response so chatCore doesn't need to handle SSE conversion. if (!stream) { // #3229: surface a real upstream error instead of masking a 4xx/5xx as an // empty `chat.completion` envelope (collectStreamToResponse synthesizes a // success-shaped body when the upstream returned no SSE data). if (!response.ok) { const rawBody = await response .clone() .text() .catch(() => ""); const errorBody = buildAntigravityUpstreamError( response.status, response.statusText, rawBody ); return { response: new Response(JSON.stringify(errorBody), { status: response.status, headers: { "Content-Type": "application/json" }, }), url, headers: finalHeaders, transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), }; } const collected = await this.collectStreamToResponse( response, model, url, finalHeaders, transformedBody, log, signal ); // When credits were injected (credits-first or credits-retry), the // synthetic body contains _remainingCredits — mirror it into the // balance cache so the dashboard stays fresh. try { const syntheticJson = await collected.response.clone().json(); const rc = syntheticJson?._remainingCredits; if (Array.isArray(rc)) { const googleCredit = rc.find( (c: { creditType?: string }) => c?.creditType === "GOOGLE_ONE_AI" ); if (googleCredit) { const balance = parseInt(googleCredit.creditAmount, 10); if (!isNaN(balance)) updateAntigravityRemainingCredits(accountId, balance); } } } catch { /* balance cache is best-effort */ } return { ...collected, transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), }; } // Streaming path: wrap the response body in a pass-through TransformStream // that extracts remainingCredits from the final SSE chunk(s) without // consuming the stream. The client receives the unmodified SSE data. if (response.body) { // If the downstream client aborts, cancel the upstream fetch body immediately // to release the socket back to the Undici agent pool and prevent memory leaks. if (signal) { const abortHandler = () => { try { response.body?.cancel().catch(() => {}); } catch (_) {} }; if (signal.aborted) { abortHandler(); } else { signal.addEventListener("abort", abortHandler, { once: true }); } } let sseBuffer = ""; const decoder = new TextDecoder(); // Singleton for correct streaming decode const MAX_BUFFER_SIZE = 16 * 1024; // Limit to prevent OOM on large streams const passThrough = new TransformStream( { transform(chunk, controller) { controller.enqueue(chunk); // Accumulate text to scan for remainingCredits try { const text = decoder.decode(chunk, { stream: true }); sseBuffer += text; // Limit buffer size to prevent unbounded growth // Truncate only after a complete newline to avoid splitting SSE lines mid-payload if (sseBuffer.length > MAX_BUFFER_SIZE) { const lastNewline = sseBuffer.lastIndexOf( "\n", sseBuffer.length - MAX_BUFFER_SIZE ); if (lastNewline !== -1) { sseBuffer = sseBuffer.slice(lastNewline + 1); } else { // No newline found in discard region — buffer contains an incomplete SSE line. // Discard it entirely to avoid returning malformed data; the remainingCredits // parser won't find valid data in a truncated line anyway. sseBuffer = ""; } } } catch { /* decoding best-effort */ } }, flush() { // Final decode for any remaining bytes try { const text = decoder.decode(); // Flush pending bytes sseBuffer += text; } catch { /* decoding best-effort */ } // Parse the accumulated SSE data for remainingCredits try { const lines = sseBuffer.split("\n"); for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) continue; const payload = trimmed.slice(5).trim(); if (!payload || payload === "[DONE]") continue; try { const parsed = JSON.parse(payload); if (Array.isArray(parsed?.remainingCredits)) { const googleCredit = parsed.remainingCredits.find((c: unknown) => { const credit = asRecord(c); return credit?.creditType === "GOOGLE_ONE_AI"; }) as AntigravityCreditEntry | undefined; if (googleCredit) { const balance = parseInt(String(googleCredit.creditAmount ?? ""), 10); if (!isNaN(balance)) { updateAntigravityRemainingCredits(accountId, balance); } } } } catch { /* skip malformed lines */ } } } catch { /* credits extraction is best-effort */ } sseBuffer = ""; }, }, { highWaterMark: 16384 }, { highWaterMark: 16384 } ); const tappedBody = response.body.pipeThrough(passThrough); const tappedResponse = new Response(tappedBody, { status: response.status, statusText: response.statusText, headers: response.headers, }); return { response: tappedResponse, url, headers: finalHeaders, transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), }; } return { response, url, headers: finalHeaders, transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), }; } catch (error) { lastError = error; log?.error?.( "TELEMETRY", `[Antigravity] Network/Fetch Error - URL: ${url}, Model: ${model}, Error: ${error instanceof Error ? error.message : String(error)}` ); if (urlIndex + 1 < fallbackCount) { log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`); continue; } throw error; } } throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`); } } export default AntigravityExecutor;