import crypto, { randomUUID } from "crypto"; import { BaseExecutor, mergeUpstreamExtraHeaders, type ExecuteInput, type ExecutorLog, type ProviderCredentials, } from "./base.ts"; import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts"; import { getAntigravityContentHeaders, getAntigravityOAuthUserAgent, } from "../services/antigravityHeaders.ts"; import { classify429, decide429, type Decision } from "../services/antigravity429Engine.ts"; import { lockExactModel } from "../services/accountFallback.ts"; import { 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 { MAX_ANTIGRAVITY_OUTPUT_TOKENS, resolveAntigravityOutputCap, } from "./antigravityOutputCap.ts"; export { MAX_ANTIGRAVITY_OUTPUT_TOKENS } from "./antigravityOutputCap.ts"; import { ensureAntigravityProjectAssigned, ANTIGRAVITY_REQUIRES_MANUAL_PROJECT, } from "../services/antigravityProjectBootstrap.ts"; import { persistDiscoveredAntigravityProjectId } from "../services/antigravityProjectPersist.ts"; import { markAntigravityMissingCloudCodeProject } from "../services/antigravityProjectPersistence.ts"; import { resolveAntigravityModelId, getAntigravityModelFallbacks, } from "../config/antigravityModelAliases.ts"; import { shouldStripCloudCodeThinking, stripCloudCodeThinkingConfig, } from "../services/cloudCodeThinking.ts"; import { buildGeminiTools } from "../translator/helpers/geminiToolsSanitizer.ts"; import { type AntigravityCollectedStream, processAntigravitySSEText, flushAntigravitySSEText, } from "./antigravity/sseCollect.ts"; // processAntigravitySSEPayload re-exported for external importers (tests). export { processAntigravitySSEPayload } from "./antigravity/sseCollect.ts"; import { createCreditsExtractionTransform as createCreditsExtractionTransformImpl, type SsePassthroughResult, } from "./antigravity/streamingPassthrough.ts"; import { toSafeAntigravityLog, finalizeAntigravityRequestBody, sendAntigravityRequest, tryCreditsRetry, tryEmbedLongRetryAfter, buildFinalAntigravityResult, buildAntigravity429ErrorMessage, markCreditsExhausted, isAbortError, type SafeAntigravityLog, } from "./antigravity/executeAttempt.ts"; import { handleAntigravityFallbackChainError, handleAntigravityFallback400, } from "./antigravity/proFallbackChain.ts"; import { getAntigravityClientProfile, resolveAntigravityClientVersion, } from "../services/antigravityClientProfile.ts"; import { generateAntigravityRequestId, getAntigravityEnvelopeUserAgent, getAntigravitySessionId, } from "../services/antigravityIdentity.ts"; const MAX_RETRY_AFTER_MS = 60_000; const LONG_RETRY_THRESHOLD_MS = 60_000; // 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; // Bounded per-URL auto-retry count for both the Retry-After-driven short retry and // the no-Retry-After transient/429 backoff loop in executeOnce(). const MAX_AUTO_RETRIES = 3; 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, ]); const ANTIGRAVITY_UNSUPPORTED_SAFETY_CATEGORIES = new Set([ "HARM_CATEGORY_CIVIC_INTEGRITY", ]); // 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; } export 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 AntigravityRequestEnvelope = Record & { project: string; model?: string; userAgent: "antigravity"; requestType: "agent" | "image_gen"; requestId: string; request: Record; enabledCreditTypes?: string[]; }; 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 {} } /** * Pass-through TransformStream that extracts `remainingCredits` from SSE * data without consuming the stream (the downstream client receives the * unmodified bytes). Thin wrapper around the pure implementation in * streamingPassthrough.ts, injecting this executor's credit-balance cache * writer so the two modules don't import each other. See that module's * doc comment for the full parameter behavior. * @internal Exported for unit testing only. */ export function createCreditsExtractionTransform( accountId: string, bufferSize = 0 ): TransformStream { return createCreditsExtractionTransformImpl( accountId, updateAntigravityRemainingCredits, bufferSize ); } /** * 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 applyAntigravityGenerationDefaults( request: Record, modelId?: string | null ): 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 cap = resolveAntigravityOutputCap(modelId); const finalMax = Number(generationConfig.maxOutputTokens); if (Number.isFinite(finalMax) && finalMax > cap) { generationConfig.maxOutputTokens = cap; } 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; } /** * Known competing-agent identity sentences that Antigravity's server-side * filter flags, answering with a 429 RESOURCE_EXHAUSTED (port of * decolua/9router b566b20, generalized). Only the identity sentence is * removed — surrounding instruction text is untouched. */ const COMPETITIVE_AGENT_PROMPT_PATTERNS: RegExp[] = [ /\byou are a claude agent\b[^\n]*/i, /\bbuilt on anthropic's claude agent sdk\b[^\n]*/i, /\byou are claude code\b[^\n]*/i, /\byou are an ai assistant created by anthropic\b[^\n]*/i, ]; /** * Strip competing-agent identity sentences from systemInstruction.parts. * Returns the original reference when nothing matched (no allocation). */ export function stripCompetitiveAgentPrompts(systemInstruction: unknown): unknown { const record = asRecord(systemInstruction); const parts = Array.isArray(record?.parts) ? (record.parts as Array>) : []; if (parts.length === 0) return systemInstruction; let changed = false; const newParts = parts.map((part) => { 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(); if (stripped !== text) { changed = true; text = stripped; } } return text === part.text ? part : { ...part, text }; }); return changed ? { ...record, parts: newParts } : systemInstruction; } function getAntigravitySafetySettings(safetySettings: unknown): unknown[] | undefined { if (!Array.isArray(safetySettings)) return undefined; return safetySettings.filter((setting) => { const category = asRecord(setting)?.category; return typeof category !== "string" || !ANTIGRAVITY_UNSUPPORTED_SAFETY_CATEGORIES.has(category); }); } function sanitizeAntigravityGeminiRequest( request: Record ): Record { const clean: Record = {}; if (Array.isArray(request.contents)) { clean.contents = request.contents; } if (asRecord(request.systemInstruction)) { // #10420: strip competing-agent identity sentences (e.g. "You are a // Claude agent, built on Anthropic's Claude Agent SDK.") that Antigravity // flags and answers with 429 RESOURCE_EXHAUSTED. clean.systemInstruction = stripCompetitiveAgentPrompts(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; } // Preserve only caller-supplied safetySettings through the Claude-path whitelist. // Missing settings stay absent so OmniRoute does not silently weaken upstream safety. 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`. * * Wired in by the caller for both the Claude path (`isClaude`) and native Gemini * models (`isGemini`, #10104) — newer Gemini endpoints reject a trailing `model` turn * with the same "ending with a model turn" class of 400 that Claude hits via Vertex. * Other model families routed through Antigravity are left untouched. * * 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; } /** * Newer Antigravity Gemini chat families reject a request ending on a model turn. * Keep this explicit rather than matching every model containing "gemini": image * generation has a separate request contract, and the older 2.5 family is not part * of the rejection evidence for #10104. */ function isAntigravityGeminiChatModel(upstreamModel: string): boolean { const normalizedModel = upstreamModel.toLowerCase(); if (/(?:^|-)image(?:-|$)/.test(normalizedModel)) { return false; } return /^gemini-(?:3(?:\.\d+)?(?:-[a-z0-9-]+)?|pro-agent)$/.test(normalizedModel); } // Test-only export so the unit suite can exercise the strip logic directly. export const __test_stripTrailingAntigravityAssistantTurn = stripTrailingAntigravityAssistantTurn; type AntigravityCreditsRetryState = { attempted: boolean }; /** Base per-url-index attempt context, before the request has been sent. */ type AntigravityAttemptContext = { url: string; model: string; /** Pre-serialization headers (built by buildHeaders + mergeUpstreamExtraHeaders) — the * credits-retry re-serializes from these, NOT from `finalHeaders` (already fingerprinted). */ headers: Record; transformedBody: Record; credentials: AntigravityCredentials; stream: boolean; signal: AbortSignal | null | undefined; log: SafeAntigravityLog; accountId: string; creditsMode: ReturnType; creditsRetryState: AntigravityCreditsRetryState; urlIndex: number; retryAttemptsByUrl: Record; fallbackCount: number; }; /** Context threaded through the 429/503 handling helpers — adds the sent response. */ type AntigravityRateLimitContext = AntigravityAttemptContext & { response: Response; finalHeaders: Record; }; /** * Outcome of handling a 429/503 response — tells executeOnce()'s loop what to do next. * `lastStatus` mirrors the original inline code, which only updated the outer * `lastStatus` variable when NOT retrying the same url (i.e. on retryNextUrl/fallthrough, * never on the bounded-short-retry or transient-auto-retry same-url paths). */ type AntigravityRateLimitOutcome = | { action: "return"; result: SsePassthroughResult } | { action: "retrySameUrl" } | { action: "retryNextUrl"; lastStatus: number } | { action: "fallthrough"; retryMs: number | null; lastStatus: number }; /** Outcome of one full per-url attempt in executeOnce() — return a result, or retry. */ type AntigravityAttemptOutcome = | { action: "return"; result: SsePassthroughResult } | { action: "retry"; sameUrl: boolean; lastStatus?: number }; export class AntigravityExecutor extends BaseExecutor { constructor() { super("antigravity", PROVIDERS.antigravity); } override shouldRetry(status: number, urlIndex: number): boolean { return ( (status === HTTP_STATUS.RATE_LIMITED || status === HTTP_STATUS.NOT_FOUND || status === HTTP_STATUS.BAD_GATEWAY || status === HTTP_STATUS.SERVICE_UNAVAILABLE || status === HTTP_STATUS.GATEWAY_TIMEOUT) && urlIndex + 1 < this.getFallbackCount() ); } 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 clientProfile = getAntigravityClientProfile(credentials); const raw = { ...getAntigravityContentHeaders(clientProfile, credentials.accessToken), Accept: "text/event-stream", }; // Scrub proxy/fingerprint headers that reveal non-native traffic return scrubProxyAndFingerprintHeaders(raw); } async transformRequest( model: string, body: unknown, _stream: boolean, credentials: AntigravityCredentials, modelIdOverride?: string, signal?: AbortSignal ): 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. let requiresManualProject = false; if (!projectId && credentials?.accessToken) { const discovered = await ensureAntigravityProjectAssigned( credentials.accessToken, fetch, getAntigravityClientProfile(credentials), signal ); if (discovered && discovered !== ANTIGRAVITY_REQUIRES_MANUAL_PROJECT) { projectId = discovered; // #8491: persist the recovered id so it survives the next token refresh // or process restart instead of being silently rediscovered every time. await persistDiscoveredAntigravityProjectId( credentials.connectionId, discovered, credentials.providerSpecificData ); } requiresManualProject = discovered === ANTIGRAVITY_REQUIRES_MANUAL_PROJECT; } if (!projectId) { markAntigravityMissingCloudCodeProject(credentials?.connectionId); if (requiresManualProject) { // Google no longer auto-creates GCP projects for standard-tier // accounts (tracked in #8491): fail fast with a clear instruction // instead of the generic 422 — a fabricated/omitted id only earns a // delayed 429 RESOURCE_EXHAUSTED from Google's quota check. const errorBody = { error: { message: "GCP_PROJECT_REQUIRED: Google Antigravity now requires a free GCP Project ID. " + "Create one at console.cloud.google.com and enter it in Providers → Antigravity " + "(connection settings → Project ID). Automatic project creation is no longer " + "available for personal accounts.", type: "gcp_project_required", code: "gcp_project_required", }, }; // 422, not 403: chatCore's generic "401/403 → refresh credentials and // retry" path would otherwise hit Google's OAuth token endpoint on // every request from an affected account — pointless, since refreshing // the token cannot create a GCP project. 422 also matches the sibling // missing_project_id error, which the client already maps to a clear // "action needed" prompt. 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; } // (#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"); // #10104: newer Gemini endpoints reject a request ending on a `model` turn with // HTTP 400 "Requests ending with a model turn are not supported" — the same // rejection surface Claude hits via Vertex (see stripTrailingAntigravityAssistantTurn's // doc comment above). Native Gemini models routed through Antigravity (`agy/gemini-*`, // e.g. the Gemini 3.x Flash/Pro tiers from PR #8013's catalog) need the same guarded // strip. Scoped to models whose id names Gemini so unrelated model families are // untouched; the strip itself never empties `contents` (see the guard above). const isGemini = isAntigravityGeminiChatModel(upstreamModel); 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 safetySettings = getAntigravitySafetySettings(normalizedRequest?.safetySettings); const rawTransformedRequest = { ...normalizedRequest, ...(contents.length > 0 && { contents }), sessionId: getAntigravitySessionId( credentials, typeof normalizedRequest?.sessionId === "string" ? normalizedRequest.sessionId : undefined ), ...(safetySettings !== undefined && { safetySettings }), toolConfig: Array.isArray(normalizedRequest?.tools) && normalizedRequest.tools.length > 0 ? { functionCallingConfig: { mode: "VALIDATED" } } : normalizedRequest?.toolConfig, }; // Note: sanitizeAntigravityGeminiRequest() applies a Claude-only field whitelist // (dropping fields native Gemini requests may legitimately carry), so the Gemini // branch only runs the trailing-turn strip — never the sanitize/whitelist step. const transformedRequest = isClaude ? stripTrailingAntigravityAssistantTurn( sanitizeAntigravityGeminiRequest(rawTransformedRequest) ) : isGemini ? stripTrailingAntigravityAssistantTurn(rawTransformedRequest) : rawTransformedRequest; applyAntigravityGenerationDefaults(transformedRequest, upstreamModel); 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, enabledCreditTypes: _enabledCreditTypes, ...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, }; 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": getAntigravityOAuthUserAgent(getAntigravityClientProfile(credentials)), }, 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"); const newAccessToken = typeof tokens.access_token === "string" ? tokens.access_token : undefined; // Discover projectId if the stored value is empty. The initial OAuth exchange // may have failed to populate it (network timeout, account not yet onboarded to // Gemini Code Assist). The runtime transformRequest path already does this, but // a proactive discovery here prevents 422 errors on the next request when the // per-token memoization cache is invalidated by the new access token. let projectId = credentials.projectId?.trim() || ""; if (!projectId && newAccessToken) { try { const discovered = await ensureAntigravityProjectAssigned( newAccessToken, fetch, getAntigravityClientProfile(credentials), AbortSignal.timeout(8_000) ); if (discovered) { projectId = discovered; await persistDiscoveredAntigravityProjectId( credentials.connectionId, discovered, credentials.providerSpecificData ); const okMsg = `Antigravity projectId discovered during refresh: ${discovered}`; log?.info?.("TOKEN", okMsg); } } catch (discoveryError) { // Best-effort: if discovery fails, the runtime path will retry on next request. const msg = discoveryError instanceof Error ? discoveryError.message : String(discoveryError); log?.warn?.("TOKEN", `Antigravity projectId discovery during refresh failed: ${msg}`); } } return { accessToken: newAccessToken, 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, // 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. * * @deprecated Use the non-streaming SSE path in chatCore instead, which calls * parseSSEToGeminiResponse() from sseParser/geminiResponse.ts. This method is * retained only for backward compatibility and may be removed in a future release. */ 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; // Guard against indefinite hangs when the upstream sends headers but // stalls on the body. Inherit the global FETCH_TIMEOUT_MS (default 600 s, // overridable via env) so reasoning-heavy models (gemini-3.1-pro-high on // large prompts) are not killed by a hardcoded 120 s ceiling. const SSE_COLLECT_TIMEOUT_MS = FETCH_TIMEOUT_MS; 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 resolveAntigravityClientVersion(getAntigravityClientProfile(input.credentials)); // 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]; let result: Awaited>; try { result = await this.executeOnce(input, candidate); } catch (error) { const outcome = handleAntigravityFallbackChainError( input, error, candidate, i, chain, firstResult, resolvedUpstreamId ); switch (outcome.action) { case "throw": throw outcome.error; case "return": return outcome.result; default: continue; } } // 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 (!firstResult) firstResult = result; const outcome400 = handleAntigravityFallback400( input, result, firstResult, candidate, i, chain, resolvedUpstreamId ); if (outcome400.action === "return") return outcome400.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 resolveAntigravityClientVersion(getAntigravityClientProfile(credentials)); const fallbackCount = this.getFallbackCount(); const l = toSafeAntigravityLog(log); let lastError = null; let lastStatus = 0; 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 creditsRetryState: AntigravityCreditsRetryState = { attempted: false }; 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, signal ?? undefined ); if (transformed instanceof Response) { return { response: transformed, url, headers, transformedBody: body }; } const transformedBody = finalizeAntigravityRequestBody(transformed, useCreditsFirst, l); // Initialize retry counter for this URL if (!retryAttemptsByUrl[urlIndex]) { retryAttemptsByUrl[urlIndex] = 0; } try { const outcome = await this.runAntigravityAttempt({ url, model, headers, transformedBody, credentials, stream, signal, log: l, accountId, creditsMode, creditsRetryState, urlIndex, retryAttemptsByUrl, fallbackCount, }); if (outcome.action === "return") return outcome.result; if (outcome.lastStatus !== undefined) lastStatus = outcome.lastStatus; if (outcome.sameUrl) urlIndex--; continue; } catch (error) { if (signal?.aborted || isAbortError(error)) { throw signal?.reason ?? error; } lastError = error; l.error( "TELEMETRY", `[Antigravity] Network/Fetch Error - URL: ${url}, Model: ${model}, Error: ${error instanceof Error ? error.message : String(error)}` ); if (urlIndex + 1 < fallbackCount) { l.debug("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`); continue; } throw error; } } throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`); } /** * Run one full per-url-index attempt: send the request, handle a 429/503 (retry * same/next url, or a Google One AI credits retry), fall back on other retryable * statuses, optionally embed a long Retry-After, then build the final non-streaming * or streaming result. Returns a result to hand back from execute(), or a retry * instruction for executeOnce()'s loop to act on (continue, optionally urlIndex--). */ private async runAntigravityAttempt( ctx: AntigravityAttemptContext ): Promise { const { url, model, headers, transformedBody, credentials, stream, signal, log, accountId, urlIndex, retryAttemptsByUrl, fallbackCount, } = ctx; const { response, finalHeaders } = await sendAntigravityRequest( this.provider, url, model, headers, transformedBody, credentials, stream, signal, log, retryAttemptsByUrl[urlIndex] ); let retryMs: number | null = null; if ( response.status === HTTP_STATUS.RATE_LIMITED || response.status === HTTP_STATUS.SERVICE_UNAVAILABLE ) { const rateLimitOutcome = await this.handleAntigravityRateLimit({ ...ctx, response, finalHeaders, }); if (rateLimitOutcome.action === "return") { return { action: "return", result: rateLimitOutcome.result }; } if (rateLimitOutcome.action === "retrySameUrl") return { action: "retry", sameUrl: true }; if (rateLimitOutcome.action === "retryNextUrl") { return { action: "retry", sameUrl: false, lastStatus: rateLimitOutcome.lastStatus }; } // Only "fallthrough" remains: last url, no more retries — proceed below with // the resolved retryMs so a long Retry-After can still be embedded in the body. retryMs = rateLimitOutcome.retryMs; } if (this.shouldRetry(response.status, urlIndex)) { log.debug("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`); return { action: "retry", sameUrl: false, lastStatus: response.status }; } // If we have a 429 with long retry time, embed it in response body const embedded = await tryEmbedLongRetryAfter( response, retryMs, url, finalHeaders, transformedBody, log ); if (embedded) return { action: "return", result: embedded }; const result = await this.buildAntigravityAttemptResult( model, stream, response, url, finalHeaders, transformedBody, accountId, signal, log ); return { action: "return", result }; } /** * #3786 — Non-streaming callers (stream: false) keep the buffered * collect-to-JSON contract: `execute()` (including the Pro-family * fallback-chain retry loop) inspects `result.response` directly and * expects a synthesized `chat.completion` JSON body, not a raw SSE * pass-through. Passthrough is reserved for actual streaming clients * (buildFinalAntigravityResult's stream:true branch), where the client * itself drains the SSE bytes — collectStreamToResponse already uses * FETCH_TIMEOUT_MS (no hardcoded 120s ceiling), so long-thinking models * are not penalized by buffering here. */ private async buildAntigravityAttemptResult( model: string, stream: boolean, response: Response, url: string, finalHeaders: Record, transformedBody: Record, accountId: string, signal: AbortSignal | null | undefined, log: SafeAntigravityLog ): Promise { if (!stream && response.ok && response.body) { return this.collectStreamToResponse( response, model, url, finalHeaders, transformedBody, log, signal ); } return buildFinalAntigravityResult( stream, response, url, finalHeaders, transformedBody, accountId, signal, updateAntigravityRemainingCredits ); } /** * Handle a 429/503 response for one URL-index attempt: resolve the retry-after * time (headers, then error-body classification + Google-One-AI credits retry), * then decide whether to retry the SAME url, fall back to the NEXT url, or (on * the last url with no more retries left) fall through with the resolved retryMs * so the caller can still embed a long Retry-After in the final response body. */ async handleAntigravityRateLimit( ctx: AntigravityRateLimitContext ): Promise { const { response, log, urlIndex, retryAttemptsByUrl, fallbackCount } = ctx; // Try to get retry time from headers first let retryMs: number | null = this.parseRetryHeaders(response.headers); // If no retry time in headers, try to parse from error message body let switchAuth = false; if (!retryMs) { const resolved = await this.tryResolveRetryFromErrorBody(ctx); if (resolved.kind === "return") return { action: "return", result: resolved.result }; retryMs = resolved.retryMs; switchAuth = resolved.switchAuth; } // 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 && !switchAuth && 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)); return { action: "retrySameUrl" }; } // 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) { const shouldAutoRetry = await this.shouldAutoRetryTransient(response); 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)); return { action: "retrySameUrl" }; } } log.debug( "RETRY", `${response.status}, Retry-After ${retryMs ? `too long (${Math.ceil(retryMs / 1000)}s)` : "missing"}, trying fallback` ); if (urlIndex + 1 < fallbackCount) { return { action: "retryNextUrl", lastStatus: response.status }; } return { action: "fallthrough", retryMs, lastStatus: response.status }; } /** * Parse the 429/503 response body to classify the failure and (for * quota_exhausted, non-full-exhaustion cases) attempt a Google One AI * credits retry. Returns the resolved retryMs, or an early "return" result * when the credits retry itself produced a response to hand back to the client. */ private async tryResolveRetryFromErrorBody( ctx: AntigravityRateLimitContext ): Promise< | { kind: "return"; result: SsePassthroughResult } | { kind: "resolved"; retryMs: number | null; switchAuth: boolean } > { const { response, url, model, headers, transformedBody, credentials, stream, signal, log, accountId, creditsMode, creditsRetryState, } = ctx; try { const errorBody = await response.clone().text(); const errorJson = JSON.parse(errorBody); const errorMessage = buildAntigravity429ErrorMessage(errorJson); // 1. Try to parse explicit retry time from message const parsedRetryMs = this.parseRetryFromErrorMessage(errorMessage); // 2. Classify 429, then decide the final retry time BEFORE the credits retry so // full_quota_exhausted can skip the credits attempt entirely (avoids ~41s hold // on an already-exhausted account) and locks only this exact model. const category = classify429(errorMessage); const decision: Decision = decide429(category, parsedRetryMs); const retryMs = decision.retryAfterMs; log.debug("AG_429", `Category: ${category}, Decision: ${decision.kind} — ${decision.reason}`); const creditsAlreadyInjected = (transformedBody as { enabledCreditTypes?: unknown }).enabledCreditTypes != null; const creditsRetryEligible = category === "quota_exhausted" && !creditsAlreadyInjected && !creditsRetryState.attempted && shouldRetryWithCredits(credentials?.accessToken || "", creditsMode); // Retry mode gets one credits attempt before the exact-model lock is persisted. if (decision.kind === "full_quota_exhausted" && retryMs && !creditsRetryEligible) { lockExactModel(this.provider, accountId, model, "quota_exhausted", retryMs); } if (category === "quota_exhausted" && creditsAlreadyInjected) { handleCreditsFailure(credentials?.accessToken || ""); log.warn("AG_CREDITS", "Credits-first request 429'd — credits likely exhausted"); markCreditsExhausted(accountId); } if (creditsRetryEligible) { creditsRetryState.attempted = true; const creditsResult = await tryCreditsRetry( this.provider, url, headers, transformedBody, credentials, stream, signal, log, accountId, updateAntigravityRemainingCredits ); if (creditsResult) return { kind: "return", result: creditsResult }; if (retryMs) markConnectionQuotaExhausted(accountId, retryMs); } return { kind: "resolved", retryMs, switchAuth: decision.kind === "short_cooldown_switch_auth", }; } catch (error) { if (signal?.aborted || isAbortError(error)) { throw signal?.reason ?? error; } // Ignore parse errors, will fall back to exponential backoff return { kind: "resolved", retryMs: null, switchAuth: false }; } } /** * True for 429 always; for transient 5xx (500/502/503/504) only when the body * matches a known capacity/traffic/agent-terminated pattern. */ private async shouldAutoRetryTransient(response: Response): Promise { if (response.status === HTTP_STATUS.RATE_LIMITED) return true; if (!ANTIGRAVITY_TRANSIENT_STATUSES.has(response.status)) return false; 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); return this.isTransientAntigravityError(response.status, errMsg); } catch { // ignore body read errors return false; } } } export default AntigravityExecutor;