diff --git a/packages/opencode/src/plugin.ts b/packages/opencode/src/plugin.ts index 79c7ccc..40c4c2d 100644 --- a/packages/opencode/src/plugin.ts +++ b/packages/opencode/src/plugin.ts @@ -1,3901 +1 @@ -import { exec } from "node:child_process"; -import crypto from "node:crypto"; -import { tool } from "@opencode-ai/plugin"; -import { - ANTIGRAVITY_DEFAULT_PROJECT_ID, - ANTIGRAVITY_ENDPOINT_FALLBACKS, - ANTIGRAVITY_ENDPOINT_PROD, - ANTIGRAVITY_PROVIDER_ID, - type HeaderStyle, -} from "./constants"; -import { authorizeAntigravity, exchangeAntigravity } from "./antigravity/oauth"; -import type { AntigravityTokenExchangeResult } from "./antigravity/oauth"; -import { accessTokenExpired, isOAuthAuth, parseRefreshParts, formatRefreshParts } from "./plugin/auth"; -import { promptAddAnotherAccount, promptLoginMode, promptProjectId } from "./plugin/cli"; -import { fetchWithAgyCliTransport } from "./plugin/agy-transport"; -import { buildFingerprintHeaders, getSessionFingerprint } from "./plugin/fingerprint"; -import { clearProvisionFailedKeys, ensureProjectContext } from "./plugin/project"; -import { - startAntigravityDebugRequest, - logAntigravityDebugResponse, - logAccountContext, - logRateLimitEvent, - logRateLimitSnapshot, - logResponseBody, - logModelFamily, - isDebugEnabled, - getLogFilePath, - initializeDebug, -} from "./plugin/debug"; -import { - buildThinkingWarmupBody, - getLastCacheStats, - isGenerativeLanguageRequest, - prepareAntigravityRequest, - transformAntigravityResponse, -} from "./plugin/request"; -import { resolveModelWithTier } from "./plugin/transform/model-resolver"; -import { - isEmptyResponseBody, - createSyntheticErrorResponse, -} from "./plugin/request-helpers"; -import { AntigravityTokenRefreshError, refreshAccessToken } from "./plugin/token"; -import { startOAuthListener, type OAuthListener } from "./plugin/server"; -import { clearAccounts, loadAccounts, saveAccounts, saveAccountsReplace } from "./plugin/storage"; -import { AccountManager, type ModelFamily, parseRateLimitReason, calculateBackoffMs, computeSoftQuotaCacheTtlMs, resolveQuotaGroup } from "./plugin/accounts"; -import { createAutoUpdateCheckerHook } from "./hooks/auto-update-checker"; -import { buildAuthFromStoredAccount, detectAuthStorageDrift } from "./plugin/auth-drift"; -import { createAuthDoctorReport, formatAuthDoctorReport } from "./plugin/auth-doctor"; -import { loadConfig, initRuntimeConfig, type AntigravityConfig } from "./plugin/config"; -import { createSessionRecoveryHook, getRecoverySuccessToast } from "./plugin/recovery"; -import { checkAccountsQuota } from "./plugin/quota"; -import { formatCachedQuotaWithStatus, classifyGroupStatus, formatQuotaStatusBadge } from "./plugin/ui/quota-status"; -import { initDiskSignatureCache } from "./plugin/cache"; -import { createProactiveRefreshQueue, type ProactiveRefreshQueue } from "./plugin/refresh-queue"; -import { initLogger, createLogger } from "./plugin/logger"; -import { initHealthTracker, getHealthTracker, initTokenTracker, getTokenTracker } from "./plugin/rotation"; -import { getAntigravityVersionResolution, initAntigravityVersion } from "./plugin/version"; -import { executeSearch } from "./plugin/search"; -import { resolvePromptContext } from "./plugin/prompt-context"; -import { - dumpGeminiRequest, - executeGeminiDumpCommand, - GEMINI_DUMP_COMMAND_NAME, - noteGeminiDumpResponse, - parseGeminiDumpCommandAction, - setGeminiDumpEnabled, -} from "./plugin/gemini-dump"; -import { getAntigravityOpencodeModelIds, OPENCODE_MODEL_DEFINITIONS } from "./plugin/model-registry"; -import type { - GetAuth, - LoaderResult, - PluginClient, - PluginContext, - PluginResult, - ProjectContextResult, - Provider, -} from "./plugin/types"; - -const MAX_OAUTH_ACCOUNTS = 10; -const MAX_WARMUP_SESSIONS = 1000; -const MAX_WARMUP_RETRIES = 2; -const MAX_TOTAL_CAPACITY_RETRIES = 4; -const CAPACITY_BACKOFF_TIERS_MS = [5000, 10000, 20000, 30000, 60000]; - -function isCapacityRetryBudgetExhausted(totalCapacityRetries: number): boolean { - return totalCapacityRetries >= MAX_TOTAL_CAPACITY_RETRIES; -} - -function getCapacityBackoffDelay(consecutiveFailures: number): number { - const index = Math.min(consecutiveFailures, CAPACITY_BACKOFF_TIERS_MS.length - 1); - return CAPACITY_BACKOFF_TIERS_MS[Math.max(0, index)] ?? 5000; -} -const warmupAttemptedSessionIds = new Set(); -const warmupSucceededSessionIds = new Set(); - -// Track if this plugin instance is running in a child session (subagent, background task) -// Used to filter toasts based on toast_scope config -let isChildSession = false; -let childSessionParentID: string | undefined = undefined; - -const log = createLogger("plugin"); - -// Module-level toast debounce to persist across requests (fixes toast spam) -const rateLimitToastCooldowns = new Map(); -const RATE_LIMIT_TOAST_COOLDOWN_MS = 5000; -const MAX_TOAST_COOLDOWN_ENTRIES = 100; - -// Track if "all accounts blocked" toasts were shown to prevent spam in while loop -let softQuotaToastShown = false; -let rateLimitToastShown = false; - -// Module-level reference to AccountManager for access from auth.login -let activeAccountManager: import("./plugin/accounts").AccountManager | null = null; - -function cleanupToastCooldowns(): void { - if (rateLimitToastCooldowns.size > MAX_TOAST_COOLDOWN_ENTRIES) { - const now = Date.now(); - for (const [key, time] of rateLimitToastCooldowns) { - if (now - time > RATE_LIMIT_TOAST_COOLDOWN_MS * 2) { - rateLimitToastCooldowns.delete(key); - } - } - } -} - -function shouldShowRateLimitToast(message: string): boolean { - cleanupToastCooldowns(); - const toastKey = message.replace(/\d+/g, "X"); - const lastShown = rateLimitToastCooldowns.get(toastKey) ?? 0; - const now = Date.now(); - if (now - lastShown < RATE_LIMIT_TOAST_COOLDOWN_MS) { - return false; - } - rateLimitToastCooldowns.set(toastKey, now); - return true; -} - -function resetAllAccountsBlockedToasts(): void { - softQuotaToastShown = false; - rateLimitToastShown = false; -} - -const quotaRefreshInProgressByEmail = new Set(); - -async function triggerAsyncQuotaRefreshForAccount( - accountManager: AccountManager, - accountIndex: number, - client: PluginClient, - providerId: string, - intervalMinutes: number, -): Promise { - if (intervalMinutes <= 0) return; - - const accounts = accountManager.getAccounts(); - const account = accounts[accountIndex]; - if (!account || account.enabled === false) return; - - const accountKey = account.email ?? `idx-${accountIndex}`; - if (quotaRefreshInProgressByEmail.has(accountKey)) return; - - const intervalMs = intervalMinutes * 60 * 1000; - const age = account.cachedQuotaUpdatedAt != null - ? Date.now() - account.cachedQuotaUpdatedAt - : Infinity; - - if (age < intervalMs) return; - - quotaRefreshInProgressByEmail.add(accountKey); - - try { - const accountsForCheck = accountManager.getAccountsForQuotaCheck(); - const singleAccount = accountsForCheck[accountIndex]; - if (!singleAccount) { - quotaRefreshInProgressByEmail.delete(accountKey); - return; - } - - const results = await checkAccountsQuota([singleAccount], client, providerId); - - if (results[0]?.status === "ok" && results[0]?.quota?.groups) { - accountManager.updateQuotaCache(accountIndex, results[0].quota.groups); - accountManager.requestSaveToDisk(); - } - } catch (err) { - log.debug(`quota-refresh-failed email=${accountKey}`, { error: String(err) }); - } finally { - quotaRefreshInProgressByEmail.delete(accountKey); - } -} - -function trackWarmupAttempt(sessionId: string): boolean { - if (warmupSucceededSessionIds.has(sessionId)) { - return false; - } - if (warmupAttemptedSessionIds.size >= MAX_WARMUP_SESSIONS) { - const first = warmupAttemptedSessionIds.values().next().value; - if (first) { - warmupAttemptedSessionIds.delete(first); - warmupSucceededSessionIds.delete(first); - } - } - const attempts = getWarmupAttemptCount(sessionId); - if (attempts >= MAX_WARMUP_RETRIES) { - return false; - } - warmupAttemptedSessionIds.add(sessionId); - return true; -} - -function getWarmupAttemptCount(sessionId: string): number { - return warmupAttemptedSessionIds.has(sessionId) ? 1 : 0; -} - -function markWarmupSuccess(sessionId: string): void { - warmupSucceededSessionIds.add(sessionId); - if (warmupSucceededSessionIds.size >= MAX_WARMUP_SESSIONS) { - const first = warmupSucceededSessionIds.values().next().value; - if (first) warmupSucceededSessionIds.delete(first); - } -} - -function clearWarmupAttempt(sessionId: string): void { - warmupAttemptedSessionIds.delete(sessionId); -} - -function isWSL(): boolean { - if (process.platform !== "linux") return false; - try { - const { readFileSync } = require("node:fs"); - const release = readFileSync("/proc/version", "utf8").toLowerCase(); - return release.includes("microsoft") || release.includes("wsl"); - } catch { - return false; - } -} - -function isWSL2(): boolean { - if (!isWSL()) return false; - try { - const { readFileSync } = require("node:fs"); - const version = readFileSync("/proc/version", "utf8").toLowerCase(); - return version.includes("wsl2") || version.includes("microsoft-standard"); - } catch { - return false; - } -} - -function isRemoteEnvironment(): boolean { - if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) { - return true; - } - if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) { - return true; - } - if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && !isWSL()) { - return true; - } - return false; -} - -function shouldSkipLocalServer(): boolean { - return isWSL2() || isRemoteEnvironment(); -} - -async function openBrowser(url: string): Promise { - try { - if (process.platform === "darwin") { - exec(`open "${url}"`); - return true; - } - if (process.platform === "win32") { - exec(`start "" "${url}"`); - return true; - } - if (isWSL()) { - try { - exec(`wslview "${url}"`); - return true; - } catch {} - } - if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { - return false; - } - exec(`xdg-open "${url}"`); - return true; - } catch { - return false; - } -} - -type VerificationProbeResult = { - status: "ok" | "blocked" | "error"; - message: string; - verifyUrl?: string; -}; - -function decodeEscapedText(input: string): string { - return input - .replace(/&/g, "&") - .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))); -} - -function normalizeGoogleVerificationUrl(rawUrl: string): string | undefined { - const normalized = decodeEscapedText(rawUrl).trim(); - if (!normalized) { - return undefined; - } - try { - const parsed = new URL(normalized); - if (parsed.hostname !== "accounts.google.com") { - return undefined; - } - return parsed.toString(); - } catch { - return undefined; - } -} - -function selectBestVerificationUrl(urls: string[]): string | undefined { - const unique = Array.from(new Set(urls.map((url) => normalizeGoogleVerificationUrl(url)).filter(Boolean) as string[])); - if (unique.length === 0) { - return undefined; - } - unique.sort((a, b) => { - const score = (value: string): number => { - let total = 0; - if (value.includes("plt=")) total += 4; - if (value.includes("/signin/continue")) total += 3; - if (value.includes("continue=")) total += 2; - if (value.includes("service=cloudcode")) total += 1; - return total; - }; - return score(b) - score(a); - }); - return unique[0]; -} - -function extractVerificationErrorDetails(bodyText: string): { - validationRequired: boolean; - message?: string; - verifyUrl?: string; -} { - const decodedBody = decodeEscapedText(bodyText); - const lowerBody = decodedBody.toLowerCase(); - let validationRequired = lowerBody.includes("validation_required"); - let message: string | undefined; - const verificationUrls = new Set(); - - const collectUrlsFromText = (text: string): void => { - for (const match of text.matchAll(/https:\/\/accounts\.google\.com\/[^\s"'<>]+/gi)) { - if (match[0]) { - verificationUrls.add(match[0]); - } - } - }; - - collectUrlsFromText(decodedBody); - - const payloads: unknown[] = []; - const trimmed = decodedBody.trim(); - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { - try { - payloads.push(JSON.parse(trimmed)); - } catch { - } - } - - for (const rawLine of decodedBody.split("\n")) { - const line = rawLine.trim(); - if (!line.startsWith("data:")) { - continue; - } - const payloadText = line.slice(5).trim(); - if (!payloadText || payloadText === "[DONE]") { - continue; - } - try { - payloads.push(JSON.parse(payloadText)); - } catch { - collectUrlsFromText(payloadText); - } - } - - const visited = new Set(); - const walk = (value: unknown, key?: string): void => { - if (typeof value === "string") { - const normalizedValue = decodeEscapedText(value); - const lowerValue = normalizedValue.toLowerCase(); - const lowerKey = key?.toLowerCase() ?? ""; - - if (lowerValue.includes("validation_required")) { - validationRequired = true; - } - if ( - !message && - (lowerKey.includes("message") || lowerKey.includes("detail") || lowerKey.includes("description")) - ) { - message = normalizedValue; - } - if ( - lowerKey.includes("validation_url") || - lowerKey.includes("verify_url") || - lowerKey.includes("verification_url") || - lowerKey === "url" - ) { - verificationUrls.add(normalizedValue); - } - collectUrlsFromText(normalizedValue); - return; - } - - if (!value || typeof value !== "object" || visited.has(value)) { - return; - } - - visited.add(value); - - if (Array.isArray(value)) { - for (const item of value) { - walk(item); - } - return; - } - - for (const [childKey, childValue] of Object.entries(value as Record)) { - walk(childValue, childKey); - } - }; - - for (const payload of payloads) { - walk(payload); - } - - if (!validationRequired) { - validationRequired = - lowerBody.includes("verification required") || - lowerBody.includes("verify your account") || - lowerBody.includes("account verification"); - } - - if (!message) { - const fallback = decodedBody - .split("\n") - .map((line) => line.trim()) - .find((line) => line && !line.startsWith("data:") && /(verify|validation|required)/i.test(line)); - if (fallback) { - message = fallback; - } - } - - return { - validationRequired, - message, - verifyUrl: selectBestVerificationUrl([...verificationUrls]), - }; -} - -async function verifyAccountAccess( - account: { - refreshToken: string; - email?: string; - projectId?: string; - managedProjectId?: string; - }, - client: PluginClient, - providerId: string, -): Promise { - const parsed = parseRefreshParts(account.refreshToken); - if (!parsed.refreshToken) { - return { status: "error", message: "Missing refresh token for selected account." }; - } - - const auth = { - type: "oauth" as const, - refresh: formatRefreshParts({ - refreshToken: parsed.refreshToken, - projectId: parsed.projectId ?? account.projectId, - managedProjectId: parsed.managedProjectId ?? account.managedProjectId, - }), - access: "", - expires: 0, - }; - - let refreshedAuth: Awaited>; - try { - refreshedAuth = await refreshAccessToken(auth, client, providerId); - } catch (error) { - if (error instanceof AntigravityTokenRefreshError) { - return { status: "error", message: error.message }; - } - return { status: "error", message: `Token refresh failed: ${String(error)}` }; - } - - if (!refreshedAuth?.access) { - return { status: "error", message: "Could not refresh access token for this account." }; - } - - const projectId = - parsed.managedProjectId ?? - parsed.projectId ?? - account.managedProjectId ?? - account.projectId ?? - ANTIGRAVITY_DEFAULT_PROJECT_ID; - - const fingerprintHeaders = buildFingerprintHeaders(getSessionFingerprint()); - const headers: Record = { - "User-Agent": fingerprintHeaders["User-Agent"] ?? getSessionFingerprint().userAgent, - Authorization: `Bearer ${refreshedAuth.access}`, - "Content-Type": "application/json", - "Accept-Encoding": "gzip", - }; - - const requestBody = { - project: projectId, - requestId: `agent/${crypto.randomUUID()}/${Date.now()}/${crypto.randomUUID()}/2`, - request: { - model: "gemini-3-flash", - contents: [{ role: "user", parts: [{ text: "ping" }] }], - generationConfig: { maxOutputTokens: 1, temperature: 0 }, - }, - model: "gemini-3-flash", - userAgent: "antigravity", - requestType: "agent", - }; - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 20000); - - let response: Response; - try { - response = await fetchWithAgyCliTransport(`${ANTIGRAVITY_ENDPOINT_PROD}/v1internal:streamGenerateContent?alt=sse`, { - method: "POST", - headers, - body: JSON.stringify(requestBody), - }, { signal: controller.signal }); - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - return { status: "error", message: "Verification check timed out." }; - } - return { status: "error", message: `Verification check failed: ${String(error)}` }; - } finally { - clearTimeout(timeoutId); - } - - let responseBody = ""; - try { - responseBody = await response.text(); - } catch { - responseBody = ""; - } - - if (response.ok) { - return { status: "ok", message: "Account verification check passed." }; - } - - const extracted = extractVerificationErrorDetails(responseBody); - if (response.status === 403 && extracted.validationRequired) { - return { - status: "blocked", - message: extracted.message ?? "Google requires additional account verification.", - verifyUrl: extracted.verifyUrl, - }; - } - - const fallbackMessage = extracted.message ?? `Request failed (${response.status} ${response.statusText}).`; - return { - status: "error", - message: fallbackMessage, - }; -} - -async function promptAccountIndexForVerification( - accounts: Array<{ email?: string; index: number }>, -): Promise { - const { createInterface } = await import("node:readline/promises"); - const { stdin, stdout } = await import("node:process"); - const rl = createInterface({ input: stdin, output: stdout }); - try { - console.log("\nSelect an account to verify:"); - for (const account of accounts) { - const label = account.email || `Account ${account.index + 1}`; - console.log(` ${account.index + 1}. ${label}`); - } - console.log(""); - - while (true) { - const answer = (await rl.question("Account number (leave blank to cancel): ")).trim(); - if (!answer) { - return undefined; - } - const parsedIndex = Number(answer); - if (!Number.isInteger(parsedIndex)) { - console.log("Please enter a valid account number."); - continue; - } - const normalizedIndex = parsedIndex - 1; - const selected = accounts.find((account) => account.index === normalizedIndex); - if (!selected) { - console.log("Please enter a number from the list above."); - continue; - } - return selected.index; - } - } finally { - rl.close(); - } -} - -async function promptOpenVerificationUrl(): Promise { - const answer = (await promptOAuthCallbackValue("Open verification URL in your browser now? [Y/n]: ")).trim().toLowerCase(); - return answer === "" || answer === "y" || answer === "yes"; -} - -type VerificationStoredAccount = { - enabled?: boolean; - verificationRequired?: boolean; - verificationRequiredAt?: number; - verificationRequiredReason?: string; - verificationUrl?: string; -}; - -function markStoredAccountVerificationRequired( - account: VerificationStoredAccount, - reason: string, - verifyUrl?: string, -): boolean { - let changed = false; - const wasVerificationRequired = account.verificationRequired === true; - - if (!wasVerificationRequired) { - account.verificationRequired = true; - changed = true; - } - - if (!wasVerificationRequired || account.verificationRequiredAt === undefined) { - account.verificationRequiredAt = Date.now(); - changed = true; - } - - const normalizedReason = reason.trim(); - if (account.verificationRequiredReason !== normalizedReason) { - account.verificationRequiredReason = normalizedReason; - changed = true; - } - - const normalizedUrl = verifyUrl?.trim(); - if (normalizedUrl && account.verificationUrl !== normalizedUrl) { - account.verificationUrl = normalizedUrl; - changed = true; - } - - if (account.enabled !== false) { - account.enabled = false; - changed = true; - } - - return changed; -} - -function clearStoredAccountVerificationRequired( - account: VerificationStoredAccount, - enableIfRequired = false, -): { changed: boolean; wasVerificationRequired: boolean } { - const wasVerificationRequired = account.verificationRequired === true; - let changed = false; - - if (account.verificationRequired !== false) { - account.verificationRequired = false; - changed = true; - } - if (account.verificationRequiredAt !== undefined) { - account.verificationRequiredAt = undefined; - changed = true; - } - if (account.verificationRequiredReason !== undefined) { - account.verificationRequiredReason = undefined; - changed = true; - } - if (account.verificationUrl !== undefined) { - account.verificationUrl = undefined; - changed = true; - } - - if (enableIfRequired && wasVerificationRequired && account.enabled === false) { - account.enabled = true; - changed = true; - } - - return { changed, wasVerificationRequired }; -} - -async function promptOAuthCallbackValue(message: string): Promise { - const { createInterface } = await import("node:readline/promises"); - const { stdin, stdout } = await import("node:process"); - const rl = createInterface({ input: stdin, output: stdout }); - try { - return (await rl.question(message)).trim(); - } finally { - rl.close(); - } -} - -type OAuthCallbackParams = { code: string; state: string }; - -function getStateFromAuthorizationUrl(authorizationUrl: string): string { - try { - return new URL(authorizationUrl).searchParams.get("state") ?? ""; - } catch { - return ""; - } -} - -function extractOAuthCallbackParams(url: URL): OAuthCallbackParams | null { - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - if (!code || !state) { - return null; - } - return { code, state }; -} - -function parseOAuthCallbackInput( - value: string, - fallbackState: string, -): OAuthCallbackParams | { error: string } { - const trimmed = value.trim(); - if (!trimmed) { - return { error: "Missing authorization code" }; - } - - try { - const url = new URL(trimmed); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state") ?? fallbackState; - - if (!code) { - return { error: "Missing code in callback URL" }; - } - if (!state) { - return { error: "Missing state in callback URL" }; - } - - return { code, state }; - } catch { - if (!fallbackState) { - return { error: "Missing state. Paste the full redirect URL instead of only the code." }; - } - - return { code: trimmed, state: fallbackState }; - } -} - -async function promptManualOAuthInput( - fallbackState: string, -): Promise { - console.log("1. Open the URL above in your browser and complete Google sign-in."); - console.log("2. After approving, copy the full redirected localhost URL from the address bar."); - console.log("3. Paste it back here.\n"); - - const callbackInput = await promptOAuthCallbackValue( - "Paste the redirect URL (or just the code) here: ", - ); - const params = parseOAuthCallbackInput(callbackInput, fallbackState); - if ("error" in params) { - return { type: "failed", error: params.error }; - } - - return exchangeAntigravity(params.code, params.state); -} - -function clampInt(value: number, min: number, max: number): number { - if (!Number.isFinite(value)) { - return min; - } - return Math.min(max, Math.max(min, Math.floor(value))); -} - -async function persistAccountPool( - results: Array>, - replaceAll: boolean = false, -): Promise { - if (results.length === 0) { - return; - } - - const now = Date.now(); - - // If replaceAll is true (fresh login), start with empty accounts - // Otherwise, load existing accounts and merge - const stored = replaceAll ? null : await loadAccounts(); - const accounts = stored?.accounts ? [...stored.accounts] : []; - - const indexByRefreshToken = new Map(); - const indexByEmail = new Map(); - for (let i = 0; i < accounts.length; i++) { - const acc = accounts[i]; - if (acc?.refreshToken) { - indexByRefreshToken.set(acc.refreshToken, i); - } - if (acc?.email) { - indexByEmail.set(acc.email, i); - } - } - - for (const result of results) { - const parts = parseRefreshParts(result.refresh); - if (!parts.refreshToken) { - continue; - } - - // First, check for existing account by email (prevents duplicates when refresh token changes) - // Only use email-based deduplication if the new account has an email - const existingByEmail = result.email ? indexByEmail.get(result.email) : undefined; - const existingByToken = indexByRefreshToken.get(parts.refreshToken); - - // Prefer email-based match to handle refresh token rotation - const existingIndex = existingByEmail ?? existingByToken; - - if (existingIndex === undefined) { - // New account - add it - const newIndex = accounts.length; - indexByRefreshToken.set(parts.refreshToken, newIndex); - if (result.email) { - indexByEmail.set(result.email, newIndex); - } - accounts.push({ - email: result.email, - refreshToken: parts.refreshToken, - projectId: parts.projectId, - managedProjectId: parts.managedProjectId, - addedAt: now, - lastUsed: now, - enabled: true, - }); - continue; - } - - const existing = accounts[existingIndex]; - if (!existing) { - continue; - } - - // Update existing account (this handles both email match and token match cases) - // When email matches but token differs, this effectively replaces the old token - const oldToken = existing.refreshToken; - accounts[existingIndex] = { - ...existing, - email: result.email ?? existing.email, - refreshToken: parts.refreshToken, - projectId: parts.projectId ?? existing.projectId, - managedProjectId: parts.managedProjectId ?? existing.managedProjectId, - lastUsed: now, - }; - - // Update the token index if the token changed - if (oldToken !== parts.refreshToken) { - indexByRefreshToken.delete(oldToken); - indexByRefreshToken.set(parts.refreshToken, existingIndex); - } - } - - if (accounts.length === 0) { - return; - } - - // For fresh logins, always start at index 0 - const activeIndex = replaceAll - ? 0 - : (typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex) ? stored.activeIndex : 0); - - await saveAccounts({ - version: 4, - accounts, - activeIndex: clampInt(activeIndex, 0, accounts.length - 1), - activeIndexByFamily: { - claude: clampInt(activeIndex, 0, accounts.length - 1), - gemini: clampInt(activeIndex, 0, accounts.length - 1), - }, - }); -} - -function buildAuthSuccessFromStoredAccount(account: { - refreshToken: string; - projectId?: string; - managedProjectId?: string; - email?: string; -}): Extract { - const refresh = formatRefreshParts({ - refreshToken: account.refreshToken, - projectId: account.projectId, - managedProjectId: account.managedProjectId, - }); - - return { - type: "success", - refresh, - access: "", - expires: 0, - email: account.email, - projectId: account.projectId ?? "", - }; -} - -function formatCachedQuotaSummary(account: { cachedQuota?: Record }): string | undefined { - const quota = account.cachedQuota; - if (!quota) { - return undefined; - } - - // Use the quota-status module for status-aware formatting - return formatCachedQuotaWithStatus(quota); -} - -function retryAfterMsFromResponse(response: Response, defaultRetryMs: number = 60_000): number { - const retryAfterMsHeader = response.headers.get("retry-after-ms"); - if (retryAfterMsHeader) { - const parsed = Number.parseInt(retryAfterMsHeader, 10); - if (!Number.isNaN(parsed) && parsed > 0) { - return parsed; - } - } - - const retryAfterHeader = response.headers.get("retry-after"); - if (retryAfterHeader) { - const parsed = Number.parseInt(retryAfterHeader, 10); - if (!Number.isNaN(parsed) && parsed > 0) { - return parsed * 1000; - } - } - - return defaultRetryMs; -} - -/** - * Parse Go-style duration strings to milliseconds. - * Supports compound durations: "1h16m0.667s", "1.5s", "200ms", "5m30s" - * - * @param duration - Duration string in Go format - * @returns Duration in milliseconds, or null if parsing fails - */ -function parseDurationToMs(duration: string): number | null { - // Handle simple formats first for backwards compatibility - const simpleMatch = duration.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i); - if (simpleMatch) { - const value = parseFloat(simpleMatch[1]!); - const unit = (simpleMatch[2] || "s").toLowerCase(); - switch (unit) { - case "h": return value * 3600 * 1000; - case "m": return value * 60 * 1000; - case "s": return value * 1000; - case "ms": return value; - default: return value * 1000; - } - } - - // Parse compound Go-style durations: "1h16m0.667s", "5m30s", etc. - const compoundRegex = /(\d+(?:\.\d+)?)(h|m(?!s)|s|ms)/gi; - let totalMs = 0; - let matchFound = false; - let match; - - while ((match = compoundRegex.exec(duration)) !== null) { - matchFound = true; - const value = parseFloat(match[1]!); - const unit = match[2]!.toLowerCase(); - switch (unit) { - case "h": totalMs += value * 3600 * 1000; break; - case "m": totalMs += value * 60 * 1000; break; - case "s": totalMs += value * 1000; break; - case "ms": totalMs += value; break; - } - } - - return matchFound ? totalMs : null; -} - -interface RateLimitBodyInfo { - retryDelayMs: number | null; - message?: string; - quotaResetTime?: string; - reason?: string; -} - -function extractRateLimitBodyInfo(body: unknown): RateLimitBodyInfo { - if (!body || typeof body !== "object") { - return { retryDelayMs: null }; - } - - const error = (body as { error?: unknown }).error; - const message = error && typeof error === "object" - ? (error as { message?: string }).message - : undefined; - - const details = error && typeof error === "object" - ? (error as { details?: unknown[] }).details - : undefined; - - let reason: string | undefined; - if (Array.isArray(details)) { - for (const detail of details) { - if (!detail || typeof detail !== "object") continue; - const type = (detail as { "@type"?: string })["@type"]; - if (typeof type === "string" && type.includes("google.rpc.ErrorInfo")) { - const detailReason = (detail as { reason?: string }).reason; - if (typeof detailReason === "string") { - reason = detailReason; - break; - } - } - } - - for (const detail of details) { - if (!detail || typeof detail !== "object") continue; - const type = (detail as { "@type"?: string })["@type"]; - if (typeof type === "string" && type.includes("google.rpc.RetryInfo")) { - const retryDelay = (detail as { retryDelay?: string }).retryDelay; - if (typeof retryDelay === "string") { - const retryDelayMs = parseDurationToMs(retryDelay); - if (retryDelayMs !== null) { - return { retryDelayMs, message, reason }; - } - } - } - } - - for (const detail of details) { - if (!detail || typeof detail !== "object") continue; - const metadata = (detail as { metadata?: Record }).metadata; - if (metadata && typeof metadata === "object") { - const quotaResetDelay = metadata.quotaResetDelay; - const quotaResetTime = metadata.quotaResetTimeStamp; - if (typeof quotaResetDelay === "string") { - const quotaResetDelayMs = parseDurationToMs(quotaResetDelay); - if (quotaResetDelayMs !== null) { - return { retryDelayMs: quotaResetDelayMs, message, quotaResetTime, reason }; - } - } - } - } - } - - if (message) { - const afterMatch = message.match(/reset after\s+([0-9hms.]+)/i); - const rawDuration = afterMatch?.[1]; - if (rawDuration) { - const parsed = parseDurationToMs(rawDuration); - if (parsed !== null) { - return { retryDelayMs: parsed, message, reason }; - } - } - } - - return { retryDelayMs: null, message, reason }; -} - -async function extractRetryInfoFromBody(response: Response): Promise { - try { - const text = await response.clone().text(); - try { - const parsed = JSON.parse(text) as unknown; - return extractRateLimitBodyInfo(parsed); - } catch { - return { retryDelayMs: null }; - } - } catch { - return { retryDelayMs: null }; - } -} - -function formatWaitTime(ms: number): string { - if (ms < 1000) return `${ms}ms`; - const seconds = Math.ceil(ms / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - if (minutes < 60) { - return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; - } - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; -} - -// Progressive rate limit retry delays -const FIRST_RETRY_DELAY_MS = 1000; // 1s - first 429 quick retry on same account - -/** - * Rate limit state tracking with time-window deduplication. - * - * Problem: When multiple subagents hit 429 simultaneously, each would increment - * the consecutive counter, causing incorrect exponential backoff (5 concurrent - * 429s = 2^5 backoff instead of 2^1). - * - * Solution: Track per account+quota with deduplication window. Multiple 429s - * within RATE_LIMIT_DEDUP_WINDOW_MS are treated as a single event. - */ -const RATE_LIMIT_DEDUP_WINDOW_MS = 2000; // 2 seconds - concurrent requests within this window are deduplicated -const RATE_LIMIT_STATE_RESET_MS = 120_000; // Reset consecutive counter after 2 minutes of no 429s - -interface RateLimitState { - consecutive429: number; - lastAt: number; - quotaKey: string; // Track which quota this state is for -} - -// Key format: `${accountIndex}:${quotaKey}` for per-account-per-quota tracking -const rateLimitStateByAccountQuota = new Map(); - -// Track empty response retry attempts (ported from LLM-API-Key-Proxy) -const emptyResponseAttempts = new Map(); - -/** - * Get rate limit backoff with time-window deduplication. - * - * @param accountIndex - The account index - * @param quotaKey - The quota key (e.g., "gemini-cli", "gemini-antigravity", "claude") - * @param serverRetryAfterMs - Server-provided retry delay (if any) - * @param maxBackoffMs - Maximum backoff delay in milliseconds (default 60000) - * @returns { attempt, delayMs, isDuplicate } - isDuplicate=true if within dedup window - */ -function getRateLimitBackoff( - accountIndex: number, - quotaKey: string, - serverRetryAfterMs: number | null, - maxBackoffMs: number = 60_000 -): { attempt: number; delayMs: number; isDuplicate: boolean } { - const now = Date.now(); - const stateKey = `${accountIndex}:${quotaKey}`; - const previous = rateLimitStateByAccountQuota.get(stateKey); - - // Check if this is a duplicate 429 within the dedup window - if (previous && (now - previous.lastAt < RATE_LIMIT_DEDUP_WINDOW_MS)) { - // Same rate limit event from concurrent request - don't increment - const baseDelay = serverRetryAfterMs ?? 1000; - const backoffDelay = Math.min(baseDelay * Math.pow(2, previous.consecutive429 - 1), maxBackoffMs); - return { - attempt: previous.consecutive429, - delayMs: Math.max(baseDelay, backoffDelay), - isDuplicate: true - }; - } - - // Check if we should reset (no 429 for 2 minutes) or increment - const attempt = previous && (now - previous.lastAt < RATE_LIMIT_STATE_RESET_MS) - ? previous.consecutive429 + 1 - : 1; - - rateLimitStateByAccountQuota.set(stateKey, { - consecutive429: attempt, - lastAt: now, - quotaKey - }); - - const baseDelay = serverRetryAfterMs ?? 1000; - const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxBackoffMs); - return { attempt, delayMs: Math.max(baseDelay, backoffDelay), isDuplicate: false }; -} - -/** - * Reset rate limit state for an account+quota combination. - * Only resets the specific quota, not all quotas for the account. - */ -function resetRateLimitState(accountIndex: number, quotaKey: string): void { - const stateKey = `${accountIndex}:${quotaKey}`; - rateLimitStateByAccountQuota.delete(stateKey); -} - -/** - * Reset all rate limit state for an account (all quotas). - * Used when account is completely healthy. - */ -function resetAllRateLimitStateForAccount(accountIndex: number): void { - for (const key of rateLimitStateByAccountQuota.keys()) { - if (key.startsWith(`${accountIndex}:`)) { - rateLimitStateByAccountQuota.delete(key); - } - } -} - -function headerStyleToQuotaKey(headerStyle: HeaderStyle, family: ModelFamily): string { - if (family === "claude") return "claude"; - return headerStyle === "antigravity" ? "gemini-antigravity" : "gemini-cli"; -} - -// Track consecutive non-429 failures per account to prevent infinite loops -const accountFailureState = new Map(); -const MAX_CONSECUTIVE_FAILURES = 5; -const FAILURE_COOLDOWN_MS = 30_000; // 30 seconds cooldown after max failures -const FAILURE_STATE_RESET_MS = 120_000; // Reset failure count after 2 minutes of no failures - -function trackAccountFailure(accountIndex: number): { failures: number; shouldCooldown: boolean; cooldownMs: number } { - const now = Date.now(); - const previous = accountFailureState.get(accountIndex); - - // Reset if last failure was more than 2 minutes ago - const failures = previous && (now - previous.lastFailureAt < FAILURE_STATE_RESET_MS) - ? previous.consecutiveFailures + 1 - : 1; - - accountFailureState.set(accountIndex, { consecutiveFailures: failures, lastFailureAt: now }); - - const shouldCooldown = failures >= MAX_CONSECUTIVE_FAILURES; - const cooldownMs = shouldCooldown ? FAILURE_COOLDOWN_MS : 0; - - return { failures, shouldCooldown, cooldownMs }; -} - -function resetAccountFailureState(accountIndex: number): void { - accountFailureState.delete(accountIndex); -} - -/** - * Sleep for a given number of milliseconds, respecting an abort signal. - */ -const HANDLED_COMMAND_SENTINEL = "ANTIGRAVITY_COMMAND_HANDLED"; - -async function sendIgnoredMessage( - client: PluginClient, - sessionID: string, - text: string, -): Promise { - const session = client.session as { - promptAsync?: (input: unknown) => Promise; - prompt?: (input: unknown) => Promise | unknown; - } | undefined; - const promptContext = await resolvePromptContext(client, sessionID); - const request = { - path: { id: sessionID }, - body: { - noReply: true, - parts: [{ type: "text", text, ignored: true }], - ...(promptContext?.agent ? { agent: promptContext.agent } : {}), - ...(promptContext?.model ? { model: promptContext.model } : {}), - ...(promptContext?.variant ? { variant: promptContext.variant } : {}), - }, - }; - - if (typeof session?.promptAsync === "function") { - await session.promptAsync(request); - return; - } - - if (typeof session?.prompt === "function") { - await Promise.resolve(session.prompt(request)); - return; - } - - throw new Error("OpenCode session prompt API is unavailable for ignored replies."); -} - -function throwHandledCommandSentinel(): never { - throw new Error(HANDLED_COMMAND_SENTINEL); -} - -function sleep(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(signal.reason instanceof Error ? signal.reason : new Error("Aborted")); - return; - } - - const timeout = setTimeout(() => { - cleanup(); - resolve(); - }, ms); - - const onAbort = () => { - cleanup(); - reject(signal?.reason instanceof Error ? signal.reason : new Error("Aborted")); - }; - - const cleanup = () => { - clearTimeout(timeout); - signal?.removeEventListener("abort", onAbort); - }; - - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} - -/** - * Creates an Antigravity OAuth plugin for a specific provider ID. - */ -export const createAntigravityPlugin = (providerId: string) => async ( - { client, directory }: PluginContext, -): Promise => { - // Load configuration from files and environment variables - const config = loadConfig(directory); - initRuntimeConfig(config); - - // Cached getAuth function for tool access - let cachedGetAuth: GetAuth | null = null; - - // Initialize debug with config - initializeDebug(config); - - // Initialize structured logger for TUI integration - initLogger(client); - - // Fetch latest Antigravity version from remote API (non-blocking, falls back to hardcoded) - await initAntigravityVersion(); - - // Initialize health tracker for hybrid strategy - if (config.health_score) { - initHealthTracker({ - initial: config.health_score.initial, - successReward: config.health_score.success_reward, - rateLimitPenalty: config.health_score.rate_limit_penalty, - failurePenalty: config.health_score.failure_penalty, - recoveryRatePerHour: config.health_score.recovery_rate_per_hour, - minUsable: config.health_score.min_usable, - maxScore: config.health_score.max_score, - }); - } - - // Initialize token tracker for hybrid strategy - if (config.token_bucket) { - initTokenTracker({ - maxTokens: config.token_bucket.max_tokens, - regenerationRatePerMinute: config.token_bucket.regeneration_rate_per_minute, - initialTokens: config.token_bucket.initial_tokens, - }); - } - - // Initialize disk signature cache if keep_thinking is enabled - // This integrates with the in-memory cacheSignature/getCachedSignature functions - if (config.keep_thinking) { - initDiskSignatureCache(config.signature_cache); - } - - // Initialize session recovery hook with full context - const sessionRecovery = createSessionRecoveryHook({ client, directory }, config); - - const updateChecker = createAutoUpdateCheckerHook(client, directory, { - showStartupToast: true, - autoUpdate: config.auto_update, - }); - - // Event handler for session recovery and updates - const eventHandler = async (input: { event: { type: string; properties?: unknown } }) => { - // Forward to update checker - await updateChecker.event(input); - - // Track if this is a child session (subagent, background task) - // This is used to filter toasts based on toast_scope config - if (input.event.type === "session.created") { - // Log previous session's quota summary before resetting for new session - const prevSummary = activeAccountManager?.getSessionSummary() - if (prevSummary && (prevSummary.totalClaude > 0 || prevSummary.totalGemini > 0)) { - log.debug("prev-session-quota-summary", { - durationMinutes: prevSummary.durationMinutes, - totalClaude: prevSummary.totalClaude, - totalGemini: prevSummary.totalGemini, - requestsPerHour: prevSummary.requestsPerHour, - accountsUsed: prevSummary.accountsUsed, - }) - } - - const props = input.event.properties as { info?: { parentID?: string } } | undefined; if (props?.info?.parentID) { - isChildSession = true; - childSessionParentID = props.info.parentID; - log.debug("child-session-detected", { parentID: props.info.parentID }); - } else { - // Reset for root sessions - important when plugin instance is reused - isChildSession = false; - childSessionParentID = undefined; - log.debug("root-session-detected", {}); - } - } - - // Handle session recovery - if (sessionRecovery && input.event.type === "session.error") { - const props = input.event.properties as Record | undefined; - const sessionID = props?.sessionID as string | undefined; - const messageID = props?.messageID as string | undefined; - const error = props?.error; - - if (sessionRecovery.isRecoverableError(error)) { - const messageInfo = { - id: messageID, - role: "assistant" as const, - sessionID, - error, - }; - - // handleSessionRecovery now does the actual fix (injects tool_result, etc.) - const recovered = await sessionRecovery.handleSessionRecovery(messageInfo); - - // Only send "continue" AFTER successful tool_result_missing recovery - // (thinking recoveries already resume inside handleSessionRecovery) - if (recovered && sessionID && config.auto_resume) { - // For tool_result_missing, we need to send continue after injecting tool_results - await client.session.prompt({ - path: { id: sessionID }, - body: { parts: [{ type: "text", text: config.resume_text }] }, - query: { directory }, - }).catch(() => {}); - - // Show success toast (respects toast_scope for child sessions) - const successToast = getRecoverySuccessToast(); - log.debug("recovery-toast", { ...successToast, isChildSession, toastScope: config.toast_scope }); - if (!(config.toast_scope === "root_only" && isChildSession)) { - await client.tui.showToast({ - body: { - title: successToast.title, - message: successToast.message, - variant: "success", - }, - }).catch(() => {}); - } - } - } - } - }; - - // Create google_search tool with access to auth context - const googleSearchTool = tool({ - description: "Search the web using Google Search and analyze URLs. Returns real-time information from the internet with source citations. Use this when you need up-to-date information about current events, recent developments, or any topic that may have changed. You can also provide specific URLs to analyze. IMPORTANT: If the user mentions or provides any URLs in their query, you MUST extract those URLs and pass them in the 'urls' parameter for direct analysis.", - args: { - query: tool.schema.string().describe("The search query or question to answer using web search"), - urls: tool.schema.array(tool.schema.string()).optional().describe("List of specific URLs to fetch and analyze. IMPORTANT: Always extract and include any URLs mentioned by the user in their query here."), - thinking: tool.schema.boolean().optional().default(true).describe("Enable deep thinking for more thorough analysis (default: true)"), - }, - async execute(args, ctx) { - log.debug("Google Search tool called", { query: args.query, urlCount: args.urls?.length ?? 0 }); - - // Get current auth context - const auth = cachedGetAuth ? await cachedGetAuth() : null; - if (!auth || !isOAuthAuth(auth)) { - return "Error: Not authenticated with Antigravity. Please run `opencode auth login` to authenticate."; - } - - // Get access token and project ID - const parts = parseRefreshParts(auth.refresh); - const projectId = parts.managedProjectId || parts.projectId || "unknown"; - - // Ensure we have a valid access token - let accessToken = auth.access; - if (!accessToken || accessTokenExpired(auth)) { - try { - const refreshed = await refreshAccessToken(auth, client, providerId); - accessToken = refreshed?.access; - } catch (error) { - return `Error: Failed to refresh access token: ${error instanceof Error ? error.message : String(error)}`; - } - } - - if (!accessToken) { - return "Error: No valid access token available. Please run `opencode auth login` to re-authenticate."; - } - - return executeSearch( - { - query: args.query, - urls: args.urls, - thinking: args.thinking, - }, - accessToken, - projectId, - ctx.abort, - ); - }, - }); - - return { - config: async (opencodeConfig: Record) => { - applyAntigravityProviderCatalog(opencodeConfig, providerId); - const mutableConfig = opencodeConfig as Record & { - command?: Record; - }; - mutableConfig.command = { - ...(mutableConfig.command ?? {}), - [GEMINI_DUMP_COMMAND_NAME]: { - template: GEMINI_DUMP_COMMAND_NAME, - description: "Show or toggle Gemini/Antigravity wire dump capture for debugging.", - }, - }; - }, - "command.execute.before": async (input: { command: string; arguments: string; sessionID: string }) => { - if (input.command !== GEMINI_DUMP_COMMAND_NAME) return; - - const action = parseGeminiDumpCommandAction(input.arguments); - if (action.type === "enable" || action.type === "disable") { - setGeminiDumpEnabled(action.type === "enable"); - } - - await sendIgnoredMessage( - client, - input.sessionID, - executeGeminiDumpCommand({ argumentsText: input.arguments }), - ); - throwHandledCommandSentinel(); - }, - event: eventHandler, - tool: { - google_search: googleSearchTool, - }, - auth: { - provider: providerId, - loader: async (getAuth: GetAuth, provider: Provider): Promise> => { - // Cache getAuth for tool access - cachedGetAuth = getAuth; - - let auth = await getAuth(); - - // If OpenCode lost its OAuth auth but account storage is still usable, - // restore auth.json from the active stored account instead of deleting - // the account pool. This repairs Desktop/TUI auth drift without network I/O. - if (!isOAuthAuth(auth)) { - const storedAccounts = await loadAccounts(); - const drift = detectAuthStorageDrift(auth, storedAccounts); - if (drift.status === "restorable" && drift.account) { - auth = buildAuthFromStoredAccount(drift.account); - try { - await client.auth.set({ - path: { id: providerId }, - body: { - type: "oauth", - refresh: auth.refresh, - access: auth.access ?? "", - expires: auth.expires ?? 0, - }, - }); - log.info("Restored Antigravity OAuth auth from account storage", { - reason: drift.reason, - email: drift.account.email, - }); - } catch (storeError) { - log.warn("Failed to restore Antigravity OAuth auth from account storage", { - error: String(storeError), - }); - } - } - } - - // If OpenCode has no valid OAuth auth and no stored account can restore it, - // clear stale account storage and let OpenCode fall back to normal auth setup. - if (!isOAuthAuth(auth)) { - try { - await clearAccounts(); - } catch { - // ignore - } - return {}; - } - - // Validate that stored accounts are in sync with OpenCode's auth - // If OpenCode's refresh token doesn't match any stored account, clear stale storage - const authParts = parseRefreshParts(auth.refresh); - const storedAccounts = await loadAccounts(); - - // Note: AccountManager now ensures the current auth is always included in accounts - - const accountManager = await AccountManager.loadFromDisk(auth); - activeAccountManager = accountManager; - if (accountManager.getAccountCount() > 0) { - accountManager.requestSaveToDisk(); - } - - // Initialize proactive token refresh queue (ported from LLM-API-Key-Proxy) - let refreshQueue: ProactiveRefreshQueue | null = null; - if (config.proactive_token_refresh && accountManager.getAccountCount() > 0) { - refreshQueue = createProactiveRefreshQueue(client, providerId, { - enabled: config.proactive_token_refresh, - bufferSeconds: config.proactive_refresh_buffer_seconds, - checkIntervalSeconds: config.proactive_refresh_check_interval_seconds, - }); - refreshQueue.setAccountManager(accountManager); - refreshQueue.start(); - } - - if (isDebugEnabled()) { - const logPath = getLogFilePath(); - if (logPath) { - try { - await client.tui.showToast({ - body: { message: `Debug log: ${logPath}`, variant: "info" }, - }); - } catch { - // TUI may not be available - } - } - } - - if (provider.models) { - for (const model of Object.values(provider.models)) { - if (model) { - model.cost = { input: 0, output: 0 }; - } - } - } - - return { - apiKey: "", - async fetch(input, init) { if (!isGenerativeLanguageRequest(input)) { - return fetch(input, init); - } - - const latestAuth = await getAuth(); - if (!isOAuthAuth(latestAuth)) { - return fetch(input, init); - } - - // Normalize Request/URL inputs to (urlString, init) so the - // string-based transform pipeline sees the real method/headers/body. - // Without this, fetch(new Request(...)) would carry its payload on the - // Request object where our string path can't read it. - if (typeof input !== "string") { - if (input instanceof Request) { - const req = input; - const headers = new Headers(req.headers); - if (init?.headers) { - new Headers(init.headers).forEach((v, k) => headers.set(k, v)); - } - const bodyBuffer = req.body ? await req.clone().arrayBuffer() : undefined; - init = { - method: init?.method ?? req.method, - headers, - body: init?.body ?? (bodyBuffer ? Buffer.from(bodyBuffer) : undefined), - signal: init?.signal ?? req.signal, - }; - input = req.url; - } else { - input = String((input as URL).href ?? input); - } - } - - if (accountManager.getAccountCount() === 0) { - return createSyntheticErrorResponse( - "No Antigravity accounts configured. Run `opencode auth login`.", - "unknown", - ); - } - - const urlString = toUrlString(input); - const family = getModelFamilyFromUrl(urlString); - const model = extractModelFromUrl(urlString); - const debugLines: string[] = []; - const pushDebug = (line: string) => { - if (!isDebugEnabled()) return; - debugLines.push(line); - }; - pushDebug(`request=${urlString}`); - const cachedStats = getLastCacheStats() - if (cachedStats) { - const label = cachedStats.hitRate > 0 ? "HIT" : "MISS" - pushDebug(`[Cache] ${label} model=${cachedStats.model} read=${cachedStats.read} total=${cachedStats.total} hitRate=${cachedStats.hitRate}%`) - } - - type FailureContext = { response: Response; - streaming: boolean; - debugContext: ReturnType; - requestedModel?: string; - projectId?: string; - endpoint?: string; - effectiveModel?: string; - sessionId?: string; - toolDebugMissing?: number; - toolDebugSummary?: string; - toolDebugPayload?: string; - dumpContext?: ReturnType; - }; - - let lastFailure: FailureContext | null = null; - let lastError: Error | null = null; - const abortSignal = init?.signal ?? undefined; - - // Helper to check if request was aborted - const checkAborted = () => { - if (abortSignal?.aborted) { - throw abortSignal.reason instanceof Error ? abortSignal.reason : new Error("Aborted"); - } - }; - - // Use while(true) loop to handle rate limits with backoff - // This ensures we wait and retry when all accounts are rate-limited - const quietMode = config.quiet_mode; - const toastScope = config.toast_scope; - - // Helper to show toast without blocking on abort (respects quiet_mode and toast_scope) - const showToast = async (message: string, variant: "info" | "warning" | "success" | "error") => { - // Always log to debug regardless of toast filtering - log.debug("toast", { message, variant, isChildSession, toastScope }); - - if (quietMode) return; - if (abortSignal?.aborted) return; - - // Filter toasts for child sessions when toast_scope is "root_only" - if (toastScope === "root_only" && isChildSession) { - log.debug("toast-suppressed-child-session", { message, variant, parentID: childSessionParentID }); - return; - } - - if (variant === "warning" && message.toLowerCase().includes("rate")) { - if (!shouldShowRateLimitToast(message)) { - return; - } - } - - try { - await client.tui.showToast({ - body: { message, variant }, - }); - } catch { - // TUI may not be available - } - }; - - const hasOtherAccountWithAntigravity = (currentAccount: any): boolean => { - if (family !== "gemini") return false; - // Use AccountManager method which properly checks for disabled/cooling-down accounts - return accountManager.hasOtherAccountWithAntigravityAvailable(currentAccount.index, family, model); - }; - - let accountSwitchCount = 0; - const maxAccountSwitches = config.max_account_switches ?? 2; - let previousAccountIndex = -1; - let needsCacheWarmup = false; - - while (true) { - // Check for abort at the start of each iteration - checkAborted(); - const accountCount = accountManager.getAccountCount(); - const routingDecision = resolveHeaderRoutingDecision(urlString, family, config); - const { - cliFirst, - preferredHeaderStyle, - explicitQuota, - allowQuotaFallback, - } = routingDecision; - - if (accountCount === 0) { - return createSyntheticErrorResponse( - "No Antigravity accounts available. Run `opencode auth login`.", - model ?? "unknown", - ); - } - - const softQuotaCacheTtlMs = computeSoftQuotaCacheTtlMs( - config.soft_quota_cache_ttl_minutes, - config.quota_refresh_interval_minutes, - ); - - let account = accountManager.getCurrentOrNextForFamily( - family, - model, - config.account_selection_strategy, - preferredHeaderStyle, - config.pid_offset_enabled, - config.soft_quota_threshold_percent, - softQuotaCacheTtlMs, - ); - - if (!account && allowQuotaFallback) { - const alternateHeaderStyle: HeaderStyle = - preferredHeaderStyle === "antigravity" ? "gemini-cli" : "antigravity"; - account = accountManager.getCurrentOrNextForFamily( - family, - model, - config.account_selection_strategy, - alternateHeaderStyle, - config.pid_offset_enabled, - config.soft_quota_threshold_percent, - softQuotaCacheTtlMs, - ); - if (account) { - pushDebug( - `selected-by-fallback idx=${account.index} preferred=${preferredHeaderStyle} alternate=${alternateHeaderStyle}`, - ); - } - } - - if (!account) { - if (accountManager.areAllAccountsOverSoftQuota(family, config.soft_quota_threshold_percent, softQuotaCacheTtlMs, model)) { - const threshold = config.soft_quota_threshold_percent; - const softQuotaWaitMs = accountManager.getMinWaitTimeForSoftQuota(family, threshold, softQuotaCacheTtlMs, model); - const maxWaitMs = (config.max_rate_limit_wait_seconds ?? 300) * 1000; - - if (softQuotaWaitMs === null || (maxWaitMs > 0 && softQuotaWaitMs > maxWaitMs)) { - const waitTimeFormatted = softQuotaWaitMs ? formatWaitTime(softQuotaWaitMs) : "unknown"; - await showToast( - `All accounts over ${threshold}% quota threshold. Resets in ${waitTimeFormatted}.`, - "error" - ); - return createSyntheticErrorResponse( - `Quota protection: All ${accountCount} account(s) are over ${threshold}% usage for ${family}. ` + - `Quota resets in ${waitTimeFormatted}. ` + - `Add more accounts, wait for quota reset, or set soft_quota_threshold_percent: 100 to disable.`, - model ?? "unknown", - ); - } - - const waitSecValue = Math.max(1, Math.ceil(softQuotaWaitMs / 1000)); - pushDebug(`all-over-soft-quota family=${family} accounts=${accountCount} waitMs=${softQuotaWaitMs}`); - - if (!softQuotaToastShown) { - await showToast(`All ${accountCount} account(s) over ${threshold}% quota. Waiting ${formatWaitTime(softQuotaWaitMs)}...`, "warning"); - softQuotaToastShown = true; - } - - await sleep(softQuotaWaitMs, abortSignal); - continue; - } - - const strictWait = !allowQuotaFallback; - // All accounts are rate-limited - wait and retry - const waitMs = accountManager.getMinWaitTimeForFamily( - family, - model, - preferredHeaderStyle, - strictWait, - ) || 60_000; - const waitSecValue = Math.max(1, Math.ceil(waitMs / 1000)); - - pushDebug(`all-rate-limited family=${family} accounts=${accountCount} waitMs=${waitMs}`); - if (isDebugEnabled()) { - logAccountContext("All accounts rate-limited", { - index: -1, - family, - totalAccounts: accountCount, - }); - logRateLimitSnapshot(family, accountManager.getAccountsSnapshot()); - } - - // If wait time exceeds max threshold, return error immediately instead of hanging - // 0 means disabled (wait indefinitely) - const maxWaitMs = (config.max_rate_limit_wait_seconds ?? 300) * 1000; - if (maxWaitMs > 0 && waitMs > maxWaitMs) { - const waitTimeFormatted = formatWaitTime(waitMs); - await showToast( - `Rate limited for ${waitTimeFormatted}. Try again later or add another account.`, - "error" - ); - - // Return a proper rate limit error response - return createSyntheticErrorResponse( - `All ${accountCount} account(s) rate-limited for ${family}. ` + - `Quota resets in ${waitTimeFormatted}. ` + - `Add more accounts with \`opencode auth login\` or wait and retry.`, - model ?? "unknown", - ); - } - - if (!rateLimitToastShown) { - await showToast(`All ${accountCount} account(s) rate-limited for ${family}. Waiting ${waitSecValue}s...`, "warning"); - rateLimitToastShown = true; - } - - // Wait for the rate-limit cooldown to expire, then retry - await sleep(waitMs, abortSignal); - continue; - } - - // Account is available - reset the toast flag - resetAllAccountsBlockedToasts(); - - pushDebug( - `selected idx=${account.index} email=${account.email ?? ""} family=${family} accounts=${accountCount} strategy=${config.account_selection_strategy}`, - ); - - if (previousAccountIndex >= 0 && previousAccountIndex !== account.index) { - needsCacheWarmup = config.cache_warmup_on_switch; - pushDebug(`account-switch: ${previousAccountIndex} → ${account.index}, warmup=${needsCacheWarmup}`); - } - previousAccountIndex = account.index; - accountManager.recordSessionUsage(account.index); - if (isDebugEnabled()) { - logAccountContext("Selected", { - index: account.index, - email: account.email, - family, - totalAccounts: accountCount, - rateLimitState: account.rateLimitResetTimes, - }); - } - - // Show toast when switching to a different account (debounced, quiet_mode handled by showToast) - if (accountCount > 1 && accountManager.shouldShowAccountToast(account.index)) { - const accountLabel = account.email || `Account ${account.index + 1}`; - // Calculate position among enabled accounts (not absolute index) - const enabledAccounts = accountManager.getEnabledAccounts(); - const enabledPosition = enabledAccounts.findIndex(a => a.index === account.index) + 1; - await showToast( - `Using ${accountLabel} (${enabledPosition}/${accountCount})`, - "info" - ); - accountManager.markToastShown(account.index); - } - - accountManager.requestSaveToDisk(); - - let authRecord = accountManager.toAuthDetails(account); - - if (accessTokenExpired(authRecord)) { - try { - const refreshed = await refreshAccessToken(authRecord, client, providerId); - if (!refreshed) { - const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index); - getHealthTracker().recordFailure(account.index); - lastError = new Error("Antigravity token refresh failed"); - if (shouldCooldown) { - accountManager.markAccountCoolingDown(account, cooldownMs, "auth-failure"); - accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model); - pushDebug(`token-refresh-failed: cooldown ${cooldownMs}ms after ${failures} failures`); - } - continue; - } - resetAccountFailureState(account.index); - accountManager.updateFromAuth(account, refreshed); - authRecord = refreshed; - try { - await accountManager.saveToDisk(); - } catch (error) { - log.error("Failed to persist refreshed auth", { error: String(error) }); - } - } catch (error) { - if (error instanceof AntigravityTokenRefreshError && error.code === "invalid_grant") { - const removed = accountManager.removeAccount(account); - if (removed) { - log.warn("Removed revoked account from pool - reauthenticate via `opencode auth login`"); - try { - // Replace (not merge) so the revoked account is not - // resurrected from disk by mergeAccountStorage. - await accountManager.saveToDiskReplace(); - } catch (persistError) { - log.error("Failed to persist revoked account removal", { error: String(persistError) }); - } - } - - if (accountManager.getAccountCount() === 0) { - try { - await client.auth.set({ - path: { id: providerId }, - body: { type: "oauth", refresh: "", access: "", expires: 0 }, - }); - } catch (storeError) { - log.error("Failed to clear stored Antigravity OAuth credentials", { error: String(storeError) }); - } - - return createSyntheticErrorResponse( - "All Antigravity accounts have invalid refresh tokens. Run `opencode auth login` and reauthenticate.", - model ?? "unknown", - ); - } - - lastError = error; - continue; - } - - const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index); - getHealthTracker().recordFailure(account.index); - lastError = error instanceof Error ? error : new Error(String(error)); - if (shouldCooldown) { - accountManager.markAccountCoolingDown(account, cooldownMs, "auth-failure"); - accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model); - pushDebug(`token-refresh-error: cooldown ${cooldownMs}ms after ${failures} failures`); - } - continue; - } - } - - const accessToken = authRecord.access; - if (!accessToken) { - lastError = new Error("Missing access token"); - if (accountCount <= 1) { - return createSyntheticErrorResponse( - "Missing access token. Run `opencode auth login` to reauthenticate.", - model ?? "unknown", - ); - } - continue; - } - - let projectContext: ProjectContextResult; - try { - projectContext = await ensureProjectContext(authRecord); - resetAccountFailureState(account.index); - } catch (error) { - const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index); - getHealthTracker().recordFailure(account.index); - lastError = error instanceof Error ? error : new Error(String(error)); - if (shouldCooldown) { - accountManager.markAccountCoolingDown(account, cooldownMs, "project-error"); - accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model); - pushDebug(`project-context-error: cooldown ${cooldownMs}ms after ${failures} failures`); - } - continue; - } - - if (projectContext.auth.refresh !== authRecord.refresh || - projectContext.auth.access !== authRecord.access) { - accountManager.updateFromAuth(account, projectContext.auth); - authRecord = projectContext.auth; - try { - await accountManager.saveToDisk(); - } catch (error) { - log.error("Failed to persist project context", { error: String(error) }); - } - } - - const runThinkingWarmup = async ( - prepared: ReturnType, - projectId: string, - ): Promise => { - if (!config.thinking_warmup) { - return; - } - if (!prepared.needsSignedThinkingWarmup || !prepared.sessionId) { - return; - } - if (!trackWarmupAttempt(prepared.sessionId)) { - return; - } - - const warmupBody = buildThinkingWarmupBody( - typeof prepared.init.body === "string" ? prepared.init.body : undefined, - Boolean(prepared.effectiveModel?.toLowerCase().includes("claude") && prepared.effectiveModel?.toLowerCase().includes("thinking")), - ); - if (!warmupBody) { - return; - } - - const warmupUrl = toWarmupStreamUrl(prepared.request); - const warmupHeaders = new Headers(prepared.init.headers ?? {}); - warmupHeaders.set("accept", "text/event-stream"); - - const warmupInit: RequestInit = { - ...prepared.init, - method: prepared.init.method ?? "POST", - headers: warmupHeaders, - body: warmupBody, - }; - - const warmupDebugContext = startAntigravityDebugRequest({ - originalUrl: warmupUrl, - resolvedUrl: warmupUrl, - method: warmupInit.method, - headers: warmupHeaders, - body: warmupBody, - streaming: true, - projectId, - }); - - try { - pushDebug("thinking-warmup: start"); - const warmupResponse = prepared.headerStyle === "antigravity" - ? await fetchWithAgyCliTransport(warmupUrl, warmupInit, { signal: abortSignal, onDebug: pushDebug }) - : await fetch(warmupUrl, warmupInit); - const transformed = await transformAntigravityResponse( - warmupResponse, - true, - warmupDebugContext, - prepared.requestedModel, - projectId, - warmupUrl, - prepared.effectiveModel, - prepared.sessionId, - ); - await transformed.text(); - markWarmupSuccess(prepared.sessionId); - pushDebug("thinking-warmup: done"); - } catch (error) { - clearWarmupAttempt(prepared.sessionId); - pushDebug( - `thinking-warmup: failed ${error instanceof Error ? error.message : String(error)}`, - ); - } - }; - - const runCacheWarmupProbe = async ( - prepared: ReturnType, - ): Promise => { - if (!needsCacheWarmup) return; - needsCacheWarmup = false; - - const bodyStr = typeof prepared.init.body === "string" ? prepared.init.body : undefined; - if (!bodyStr) return; - - try { - pushDebug("cache-warmup-probe: start"); - - // Send the exact same body as the real request — the server-side cache - // key includes the full request payload (systemInstruction, tools, - // generationConfig, thinkingConfig, contents). Stripping any field - // produces a different hash → cache MISS on the first real request. - // The probe aborts after the first SSE chunk, so output generation - // cost is negligible regardless of maxOutputTokens settings. - const probeInit = { - ...prepared.init, - method: "POST", - body: bodyStr, - }; - const probeResponse = prepared.headerStyle === "antigravity" - ? await fetchWithAgyCliTransport(toUrlString(prepared.request), probeInit, { signal: abortSignal, onDebug: pushDebug }) - : await fetch(toUrlString(prepared.request), probeInit); - - if (probeResponse.body) { - const reader = probeResponse.body.getReader(); - // Read first chunk to confirm server processed the prefix, then abort - await reader.read(); - await reader.cancel(); - } - - const status = probeResponse.status; - if (status >= 400) { - // Log error body for diagnosis - let errorSnippet = ""; - try { - const errText = await probeResponse.text().catch(() => ""); - errorSnippet = errText.slice(0, 200); - } catch { /* ignore */ } - pushDebug(`cache-warmup-probe: done status=${status}${errorSnippet ? ` error=${errorSnippet}` : ""}`); - } else { - pushDebug(`cache-warmup-probe: done status=${status} (aborted after first chunk)`); - } - } catch (error) { - pushDebug( - `cache-warmup-probe: failed ${error instanceof Error ? error.message : String(error)}`, - ); - } - }; - - // Track total API requests made for this single user message - let apiRequestCount = 0; - - // Try endpoint fallbacks with single header style based on model suffix - let shouldSwitchAccount = false; - // Determine header style from model suffix: - // - Models with antigravity- prefix -> use Antigravity quota - // - Gemini models without explicit prefix -> follow cli_first - // - Claude models -> always use Antigravity - let headerStyle = preferredHeaderStyle; - pushDebug(`headerStyle=${headerStyle} explicit=${explicitQuota}`); - if (account.fingerprint) { - pushDebug(`fingerprint: deviceId=${account.fingerprint.deviceId.slice(0, 8)}...`); - } - - // Check if this header style is rate-limited for this account - if (accountManager.isRateLimitedForHeaderStyle(account, family, headerStyle, model)) { - // Antigravity-first fallback: exhaust antigravity across ALL accounts before gemini-cli - if (allowQuotaFallback && family === "gemini" && headerStyle === "antigravity") { - // Check if ANY other account has antigravity available - if (accountManager.hasOtherAccountWithAntigravityAvailable(account.index, family, model)) { - // Switch to another account with antigravity (preserve antigravity priority) - pushDebug(`antigravity rate-limited on account ${account.index}, but available on other accounts. Switching.`); - shouldSwitchAccount = true; - } else { - // All accounts exhausted antigravity - fall back to gemini-cli on this account - const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); - const fallbackStyle = resolveQuotaFallbackHeaderStyle({ - family, - headerStyle, - alternateStyle, - }); - if (fallbackStyle) { - await showToast( - `Antigravity quota exhausted on all accounts. Using Gemini CLI quota.`, - "warning" - ); - headerStyle = fallbackStyle; - pushDebug(`all-accounts antigravity exhausted, quota fallback: ${headerStyle}`); - } else { - shouldSwitchAccount = true; - } - } - } else if (allowQuotaFallback && family === "gemini") { - // gemini-cli rate-limited - try alternate style (antigravity) on same account - const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); - const fallbackStyle = resolveQuotaFallbackHeaderStyle({ - family, - headerStyle, - alternateStyle, - }); - if (fallbackStyle) { - const quotaName = headerStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity"; - const altQuotaName = fallbackStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity"; - await showToast( - `${quotaName} quota exhausted, using ${altQuotaName} quota`, - "warning" - ); - headerStyle = fallbackStyle; - pushDebug(`quota fallback: ${headerStyle}`); - } else { - shouldSwitchAccount = true; - } - } else { - shouldSwitchAccount = true; - } - } - - // Bound transient capacity retries across all while-loop iterations. Without - // this total guard, per-endpoint capacityRetryCount can reset after the - // endpoint loop restarts and keep OpenCode waiting before any step-start. - let totalCapacityRetries = 0; - - while (!shouldSwitchAccount) { - - // Flag to force thinking recovery on retry after API error - let forceThinkingRecovery = false; - - // Track if token was consumed (for hybrid strategy refund on error) - let tokenConsumed = false; - - // Track capacity retries per endpoint to prevent infinite loops - let capacityRetryCount = 0; - let lastEndpointIndex = -1; - - for (let i = 0; i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length; i++) { // Reset capacity retry counter when switching to a new endpoint - if (i !== lastEndpointIndex) { - capacityRetryCount = 0; - lastEndpointIndex = i; - } - - const currentEndpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i]; - - // Skip sandbox endpoints for Gemini CLI models - they only work with Antigravity quota - // Gemini CLI models must use production endpoint (cloudcode-pa.googleapis.com) - if (headerStyle === "gemini-cli" && currentEndpoint !== ANTIGRAVITY_ENDPOINT_PROD) { - pushDebug(`Skipping sandbox endpoint ${currentEndpoint} for gemini-cli headerStyle`); - continue; - } - - try { - const prepared = prepareAntigravityRequest( - input, - init, - accessToken, - projectContext.effectiveProjectId, - currentEndpoint, - headerStyle, - forceThinkingRecovery, - { - claudeToolHardening: config.claude_tool_hardening, - claudePromptAutoCaching: config.claude_prompt_auto_caching, - fingerprint: account.fingerprint, - }, - ); - - const originalUrl = toUrlString(input); - const resolvedUrl = toUrlString(prepared.request); - pushDebug(`endpoint=${currentEndpoint}`); - pushDebug(`resolved=${resolvedUrl}`); - const debugContext = startAntigravityDebugRequest({ - originalUrl, - resolvedUrl, - method: prepared.init.method, - headers: prepared.init.headers, - body: prepared.init.body, - streaming: prepared.streaming, - projectId: projectContext.effectiveProjectId, - }); - const dumpContext = dumpGeminiRequest({ - originalUrl, - resolvedUrl, - method: prepared.init.method, - headers: prepared.init.headers, - body: prepared.init.body, - streaming: prepared.streaming, - requestedModel: prepared.requestedModel, - effectiveModel: prepared.effectiveModel, - sessionId: prepared.sessionId, - projectId: projectContext.effectiveProjectId, - }); - - const createFailureContext = (failureResponse: Response): FailureContext => ({ - response: failureResponse, - streaming: prepared.streaming, - debugContext, - requestedModel: prepared.requestedModel, - projectId: prepared.projectId, - endpoint: prepared.endpoint, - effectiveModel: prepared.effectiveModel, - sessionId: prepared.sessionId, - toolDebugMissing: prepared.toolDebugMissing, - toolDebugSummary: prepared.toolDebugSummary, - toolDebugPayload: prepared.toolDebugPayload, - dumpContext, - }); - - await runThinkingWarmup(prepared, projectContext.effectiveProjectId); - - await runCacheWarmupProbe(prepared); - - if (config.request_jitter_max_ms > 0) { - const jitterMs = Math.floor(Math.random() * config.request_jitter_max_ms); - if (jitterMs > 0) { - await sleep(jitterMs, abortSignal); - } - } - - // Consume token for hybrid strategy - // Refunded later if request fails (429 or network error) - if (config.account_selection_strategy === 'hybrid') { - tokenConsumed = getTokenTracker().consume(account.index); - } - - pushDebug(`dispatching request via ${prepared.headerStyle} transport`); - const response = prepared.headerStyle === "antigravity" - ? await fetchWithAgyCliTransport(toUrlString(prepared.request), prepared.init, { signal: abortSignal, onDebug: pushDebug }) - : await fetch(prepared.request, prepared.init); - apiRequestCount++; - accountManager.recordRequest(account.index, family) - const requestCounts = accountManager.getDailyRequestCounts(account.index) - if (requestCounts) { - pushDebug(`[Quota] account=${account.index} ${family}_today=${requestCounts[family]} total_${family}_today=${accountManager.getTotalDailyRequests(family)}`) - } - pushDebug(`status=${response.status} ${response.statusText} (api_request #${apiRequestCount})`); - noteGeminiDumpResponse(dumpContext, response); - - // Handle 429 rate limit (or Service Overloaded) with improved logic - if (response.status === 429 || response.status === 503 || response.status === 529) { - // Refund token on rate limit - if (tokenConsumed) { - getTokenTracker().refund(account.index); - tokenConsumed = false; - } - - const defaultRetryMs = (config.default_retry_after_seconds ?? 60) * 1000; - const maxBackoffMs = (config.max_backoff_seconds ?? 60) * 1000; - const headerRetryMs = retryAfterMsFromResponse(response, defaultRetryMs); - const bodyInfo = await extractRetryInfoFromBody(response); - const serverRetryMs = bodyInfo.retryDelayMs ?? headerRetryMs; - - // [Enhanced Parsing] Pass status to handling logic - const rateLimitReason = parseRateLimitReason(bodyInfo.reason, bodyInfo.message, response.status); - - // STRATEGY 1: CAPACITY / SERVER ERROR (Transient) - // Goal: Wait and Retry SAME Account. DO NOT LOCK. - // We handle this FIRST to avoid calling getRateLimitBackoff() and polluting the global rate limit state for transient errors. - if (rateLimitReason === "MODEL_CAPACITY_EXHAUSTED" || rateLimitReason === "SERVER_ERROR") { - totalCapacityRetries++; - if (isCapacityRetryBudgetExhausted(totalCapacityRetries)) { - pushDebug(`Total capacity retries (${MAX_TOTAL_CAPACITY_RETRIES}) exhausted, switching account`); - lastFailure = createFailureContext(response); - shouldSwitchAccount = true; - break; - } - - // Exponential backoff with jitter for capacity errors: 1s → 2s → 4s → 8s (max) - // Matches Antigravity-Manager's ExponentialBackoff(1s, 8s) - const baseDelayMs = 1000; - const maxDelayMs = 8000; - const exponentialDelay = Math.min(baseDelayMs * Math.pow(2, capacityRetryCount), maxDelayMs); - // Add ±10% jitter to prevent thundering herd - const jitter = exponentialDelay * (0.9 + Math.random() * 0.2); - const waitMs = Math.round(jitter); - const waitSec = Math.round(waitMs / 1000); - - pushDebug(`Server busy (${rateLimitReason}) on account ${account.index}, exponential backoff ${waitMs}ms (attempt ${capacityRetryCount + 1}, total ${totalCapacityRetries}/${MAX_TOTAL_CAPACITY_RETRIES})`); - - await showToast( - `ā³ Server busy (${response.status}). Retrying in ${waitSec}s...`, - "warning", - ); - - await sleep(waitMs, abortSignal); - - // CRITICAL FIX: Decrement i so that the loop 'continue' retries the SAME endpoint index - // (i++ in the loop will bring it back to the current index) - // But limit retries to prevent infinite loops (Greptile feedback) - if (capacityRetryCount < 1) { - capacityRetryCount++; - i -= 1; - continue; - } else { - pushDebug(`Max capacity retries (1) exhausted for endpoint ${currentEndpoint}, regenerating fingerprint...`); // Regenerate fingerprint to get fresh device identity before trying next endpoint - const newFingerprint = accountManager.regenerateAccountFingerprint(account.index); - if (newFingerprint) { - pushDebug(`Fingerprint regenerated for account ${account.index}`); - } - continue; - } - } - - // STRATEGY 2: RATE LIMIT EXCEEDED (RPM) / QUOTA EXHAUSTED / UNKNOWN - // Goal: Lock and Rotate (Standard Logic) - - // Only now do we call getRateLimitBackoff, which increments the global failure tracker - const quotaKey = headerStyleToQuotaKey(headerStyle, family); - const { attempt, delayMs, isDuplicate } = getRateLimitBackoff(account.index, quotaKey, serverRetryMs); - - // Calculate potential backoffs - const smartBackoffMs = calculateBackoffMs(rateLimitReason, account.consecutiveFailures ?? 0, serverRetryMs); - const effectiveDelayMs = Math.max(delayMs, smartBackoffMs); - - pushDebug( - `429 idx=${account.index} email=${account.email ?? ""} family=${family} delayMs=${effectiveDelayMs} attempt=${attempt} reason=${rateLimitReason}`, - ); - if (bodyInfo.message) { - pushDebug(`429 message=${bodyInfo.message}`); - } - if (bodyInfo.quotaResetTime) { - pushDebug(`429 quotaResetTime=${bodyInfo.quotaResetTime}`); - } - if (bodyInfo.reason) { - pushDebug(`429 reason=${bodyInfo.reason}`); - } - - logRateLimitEvent( - account.index, - account.email, - family, - response.status, - effectiveDelayMs, - bodyInfo, - ); - - await logResponseBody(debugContext, response, 429); - - getHealthTracker().recordRateLimit(account.index); - - const accountLabel = account.email || `Account ${account.index + 1}`; - - // Progressive retry for standard 429s: 1st 429 → 1s then switch (if enabled) or retry same - if (attempt === 1 && rateLimitReason !== "QUOTA_EXHAUSTED") { - await showToast(`Rate limited. Quick retry in 1s...`, "warning"); - await sleep(FIRST_RETRY_DELAY_MS, abortSignal); - - // CacheFirst mode: wait for same account if within threshold (preserves prompt cache) - if (config.scheduling_mode === 'cache_first') { - const maxCacheFirstWaitMs = config.max_cache_first_wait_seconds * 1000; - // effectiveDelayMs is the backoff calculated for this account - if (effectiveDelayMs <= maxCacheFirstWaitMs) { - pushDebug(`cache_first: waiting ${effectiveDelayMs}ms for same account to recover`); - await showToast(`ā³ Waiting ${Math.ceil(effectiveDelayMs / 1000)}s for same account (prompt cache preserved)...`, "info"); - accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs); - await sleep(effectiveDelayMs, abortSignal); - // Retry same endpoint after wait - i -= 1; - continue; - } - // Wait time exceeds threshold, fall through to switch - pushDebug(`cache_first: wait ${effectiveDelayMs}ms exceeds max ${maxCacheFirstWaitMs}ms, switching account`); - } - - if (config.switch_on_first_rate_limit && accountCount > 1) { - accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs, config.failure_ttl_seconds * 1000); - shouldSwitchAccount = true; - break; - } - - // Same endpoint retry for first RPM hit - i -= 1; - continue; - } - - accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs, config.failure_ttl_seconds * 1000); - - accountManager.requestSaveToDisk(); - - const switchAccountDelayMs = config.switch_account_delay_ms ?? 500; - - // For Gemini, preserve preferred quota across accounts before fallback - if (family === "gemini") { - if (headerStyle === "antigravity") { - // Check if any other account has Antigravity quota for this model - if (hasOtherAccountWithAntigravity(account)) { - pushDebug(`antigravity exhausted on account ${account.index}, but available on others. Switching account.`); - await showToast(`Rate limited again. Switching account in ${formatWaitTime(switchAccountDelayMs)}...`, "warning"); - await sleep(switchAccountDelayMs, abortSignal); - shouldSwitchAccount = true; - break; - } - - // All accounts exhausted for Antigravity on THIS model. - // Before falling back to gemini-cli, check if it's the last option (automatic fallback) - if (allowQuotaFallback) { - const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); - const fallbackStyle = resolveQuotaFallbackHeaderStyle({ - family, - headerStyle, - alternateStyle, - }); - if (fallbackStyle) { - const safeModelName = model || "this model"; - await showToast( - `Antigravity quota exhausted for ${safeModelName}. Switching to Gemini CLI quota...`, - "warning" - ); - headerStyle = fallbackStyle; - pushDebug(`quota fallback: ${headerStyle}`); - continue; - } - } - } else if (headerStyle === "gemini-cli") { - if (allowQuotaFallback) { - const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); - const fallbackStyle = resolveQuotaFallbackHeaderStyle({ - family, - headerStyle, - alternateStyle, - }); - if (fallbackStyle) { - const safeModelName = model || "this model"; - await showToast( - `Gemini CLI quota exhausted for ${safeModelName}. Switching to Antigravity quota...`, - "warning" - ); - headerStyle = fallbackStyle; - pushDebug(`quota fallback: ${headerStyle}`); - continue; - } - } - } - } - - const quotaName = headerStyle === "antigravity" ? "Antigravity" : "Gemini CLI"; - - if (accountCount > 1) { - const quotaMsg = bodyInfo.quotaResetTime - ? ` (quota resets ${bodyInfo.quotaResetTime})` - : ``; - await showToast(`Rate limited again. Switching account in ${formatWaitTime(switchAccountDelayMs)}...${quotaMsg}`, "warning"); - await sleep(switchAccountDelayMs, abortSignal); - } else { - // Single account: exponential backoff (1s, 2s, 4s, 8s... max 60s) - const expBackoffMs = Math.min(FIRST_RETRY_DELAY_MS * Math.pow(2, attempt - 1), 60000); - const expBackoffFormatted = expBackoffMs >= 1000 ? `${Math.round(expBackoffMs / 1000)}s` : `${expBackoffMs}ms`; - await showToast(`Rate limited. Retrying in ${expBackoffFormatted} (attempt ${attempt})...`, "warning"); - await sleep(expBackoffMs, abortSignal); - } - - lastFailure = createFailureContext(response); - shouldSwitchAccount = true; - break; - } - - // Success - reset rate limit backoff state for this quota - const quotaKey = headerStyleToQuotaKey(headerStyle, family); - resetRateLimitState(account.index, quotaKey); - resetAccountFailureState(account.index); - - if (response.status === 403) { - const errorBodyText = await response.clone().text().catch(() => ""); - const extracted = extractVerificationErrorDetails(errorBodyText); - - if (extracted.validationRequired) { - const verificationReason = extracted.message ?? "Google requires account verification."; - const cooldownMs = 10 * 60 * 1000; - - accountManager.markAccountVerificationRequired(account.index, verificationReason, extracted.verifyUrl); - accountManager.markAccountCoolingDown(account, cooldownMs, "validation-required"); - accountManager.markRateLimited(account, cooldownMs, family, headerStyle, model); - - const label = account.email || `Account ${account.index + 1}`; - if (accountManager.shouldShowAccountToast(account.index, 60000)) { - await showToast( - `⚠ ${label} needs verification. Run 'opencode auth login' and use Verify accounts.`, - "warning", - ); - accountManager.markToastShown(account.index); - } - - pushDebug(`verification-required: disabled account ${account.index}`); - getHealthTracker().recordFailure(account.index); - - lastFailure = createFailureContext(response); - shouldSwitchAccount = true; - break; - } - } - - const shouldRetryEndpoint = ( - response.status === 403 || - response.status === 404 || - response.status >= 500 - ); - - if (shouldRetryEndpoint && i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) { - await logResponseBody(debugContext, response, response.status); - lastFailure = createFailureContext(response); - continue; - } - - // Success or non-retryable error - return the response - if (response.ok) { - account.consecutiveFailures = 0; - getHealthTracker().recordSuccess(account.index); - accountManager.markAccountUsed(account.index); - - void triggerAsyncQuotaRefreshForAccount( - accountManager, - account.index, - client, - providerId, - config.quota_refresh_interval_minutes, - ); - - // Proactive rotation: if current account quota is low, pre-switch - // to a warm-cache account so the NEXT request avoids a cold cache miss - const proactiveThreshold = config.proactive_rotation_threshold_percent ?? 20; - if (proactiveThreshold > 0 && accountManager.shouldProactivelyRotate( - family, - model, - proactiveThreshold, - softQuotaCacheTtlMs, - )) { - const rotated = accountManager.proactivelyRotateForFamily( - family, - model, - headerStyle, - config.soft_quota_threshold_percent, - softQuotaCacheTtlMs, - ); - if (rotated) { - const remaining = account.cachedQuota?.[resolveQuotaGroup(family, model)]?.remainingFraction; - const remainingPct = remaining != null ? `${(remaining * 100).toFixed(1)}%` : "?"; - pushDebug(`[ProactiveRotation] account ${account.index} quota ${remainingPct} < ${proactiveThreshold}%, pre-switched to account ${rotated.index} for next request`); - pushDebug(`[ProactiveRotation] ${account.index} → ${rotated.index} (warm=${accountManager.wasUsedInSession(rotated.index)})`); - } - } - } logAntigravityDebugResponse(debugContext, response, { - note: response.ok ? "Success" : `Error ${response.status}`, - }); - if (response.ok && !prepared.streaming) { - await logResponseBody(debugContext, response, response.status); - } - if (!response.ok) { - await logResponseBody(debugContext, response, response.status); - - // Handle 400 "Prompt too long" with synthetic response to avoid session lock - if (response.status === 400) { - const cloned = response.clone(); - const bodyText = await cloned.text(); - if (bodyText.includes("Prompt is too long") || bodyText.includes("prompt_too_long")) { - await showToast( - "Context too long - use /compact to reduce size", - "warning" - ); - const errorMessage = `[Antigravity Error] Context is too long for this model.\n\nPlease use /compact to reduce context size, then retry your request.\n\nAlternatively, you can:\n- Use /clear to start fresh\n- Use /undo to remove recent messages\n- Switch to a model with larger context window`; - return createSyntheticErrorResponse(errorMessage, prepared.requestedModel); - } - } - } - - // Empty response retry logic (ported from LLM-API-Key-Proxy) - // For non-streaming responses, check if the response body is empty - // and retry if so (up to config.empty_response_max_attempts times) - if (response.ok && !prepared.streaming) { - const maxAttempts = config.empty_response_max_attempts ?? 4; - const retryDelayMs = config.empty_response_retry_delay_ms ?? 2000; - - // Clone to check body without consuming original - const clonedForCheck = response.clone(); - const bodyText = await clonedForCheck.text(); - - if (isEmptyResponseBody(bodyText)) { - // Track empty response attempts per request - const emptyAttemptKey = `${prepared.sessionId ?? "none"}:${prepared.effectiveModel ?? "unknown"}`; - const currentAttempts = (emptyResponseAttempts.get(emptyAttemptKey) ?? 0) + 1; - emptyResponseAttempts.set(emptyAttemptKey, currentAttempts); - - pushDebug(`empty-response: attempt ${currentAttempts}/${maxAttempts}`); - - if (currentAttempts < maxAttempts) { - await showToast( - `Empty response received. Retrying (${currentAttempts}/${maxAttempts})...`, - "warning" - ); - await sleep(retryDelayMs, abortSignal); - continue; // Retry the endpoint loop - } - - // Clean up and return a synthetic response after max attempts - emptyResponseAttempts.delete(emptyAttemptKey); - return createSyntheticErrorResponse( - `Empty response after ${currentAttempts} attempts for model ${prepared.effectiveModel ?? "unknown"}.`, - prepared.effectiveModel ?? "unknown", - ); - } - - // Clean up successful attempt tracking - const emptyAttemptKeyClean = `${prepared.sessionId ?? "none"}:${prepared.effectiveModel ?? "unknown"}`; - emptyResponseAttempts.delete(emptyAttemptKeyClean); - } - - const transformedResponse = await transformAntigravityResponse( - response, - prepared.streaming, - debugContext, - prepared.requestedModel, - prepared.projectId, - prepared.endpoint, - prepared.effectiveModel, - prepared.sessionId, - prepared.toolDebugMissing, - prepared.toolDebugSummary, - prepared.toolDebugPayload, - debugLines, - dumpContext, - ); - - // Check for context errors and show appropriate toast - const contextError = transformedResponse.headers.get("x-antigravity-context-error"); - if (contextError) { - if (contextError === "prompt_too_long") { - await showToast( - "Context too long - use /compact to reduce size, or trim your request", - "warning" - ); - } else if (contextError === "tool_pairing") { - await showToast( - "Tool call/result mismatch - use /compact to fix, or /undo last message", - "warning" - ); - } - } - - if (apiRequestCount > 1) { - pushDebug(`[Quota] Total API requests for this user message: ${apiRequestCount} (${apiRequestCount - 1} retries)`); - } - const dailyCounts = accountManager.getDailyRequestCounts(account.index) - if (dailyCounts) { - pushDebug(`[Quota] Account ${account.index} (${account.email ?? "unknown"}) today: claude=${dailyCounts.claude} gemini=${dailyCounts.gemini}`) - } - const totalToday = accountManager.getTotalDailyRequests(family) - pushDebug(`[Quota] Total ${family} requests today (all accounts): ${totalToday}`) - - // Post-request quota state: show cached remaining quota for this account - const cachedQuota = account.cachedQuota - if (cachedQuota) { - const quotaFamily = family === "claude" ? "claude" : "gemini-flash" - const groupQuota = cachedQuota[quotaFamily] - if (groupQuota?.remainingFraction != null) { - const pct = Math.round(groupQuota.remainingFraction * 100) - pushDebug(`[Quota] Account ${account.index} cached ${quotaFamily} remaining: ${pct}%${groupQuota.resetTime ? ` (resets ${groupQuota.resetTime})` : ""}`) - } - } - - // Quota consumption rate estimation - const sessionSummary = accountManager.getSessionSummary() - if (sessionSummary.durationMinutes >= 1) { - const familyTotal = family === "claude" ? sessionSummary.totalClaude : sessionSummary.totalGemini - if (familyTotal > 0) { - const ratePerHour = sessionSummary.requestsPerHour - pushDebug(`[Quota] Session: ${sessionSummary.durationMinutes}min, ${familyTotal} ${family} reqs, ~${ratePerHour} reqs/hr, ${sessionSummary.accountsUsed} accounts used`) - } - } - - return transformedResponse; - } catch (error) { - // Refund token on network/API error (only if consumed) - if (tokenConsumed) { - getTokenTracker().refund(account.index); - tokenConsumed = false; - } - - // Handle recoverable thinking errors - retry with forced recovery - if (error instanceof Error && error.message === "THINKING_RECOVERY_NEEDED") { - // Only retry once with forced recovery to avoid infinite loops - if (!forceThinkingRecovery) { - pushDebug("thinking-recovery: API error detected, retrying with forced recovery"); - forceThinkingRecovery = true; - i = -1; // Will become 0 after loop increment, restart endpoint loop - continue; - } - - // Already tried with forced recovery, give up and return error - const recoveryError = error as any; - const originalError = recoveryError.originalError || { error: { message: "Thinking recovery triggered" } }; - - const recoveryMessage = `${originalError.error?.message || "Session recovery failed"}\n\n[RECOVERY] Thinking block corruption could not be resolved. Try starting a new session.`; - - return new Response(JSON.stringify({ - type: "error", - error: { - type: "unrecoverable_error", - message: recoveryMessage - } - }), { - status: 400, - headers: { "Content-Type": "application/json" } - }); - } - - if (i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) { - lastError = error instanceof Error ? error : new Error(String(error)); - continue; - } - - // All endpoints failed for this account - track failure and try next account - const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index); - lastError = error instanceof Error ? error : new Error(String(error)); - if (shouldCooldown) { - accountManager.markAccountCoolingDown(account, cooldownMs, "network-error"); - accountManager.markRateLimited(account, cooldownMs, family, headerStyle, model); - pushDebug(`endpoint-error: cooldown ${cooldownMs}ms after ${failures} failures`); - } - shouldSwitchAccount = true; - break; - } - } - } // end headerStyleLoop - - if (shouldSwitchAccount) { - accountSwitchCount++; - - // Cap account switches to prevent cascading quota waste - if (accountSwitchCount > maxAccountSwitches) { - pushDebug(`account-switch-cap: exceeded max_account_switches=${maxAccountSwitches}, giving up`); - if (lastFailure) { - return transformAntigravityResponse( - lastFailure.response, - lastFailure.streaming, - lastFailure.debugContext, - lastFailure.requestedModel, - lastFailure.projectId, - lastFailure.endpoint, - lastFailure.effectiveModel, - lastFailure.sessionId, - lastFailure.toolDebugMissing, - lastFailure.toolDebugSummary, - lastFailure.toolDebugPayload, - debugLines, - lastFailure.dumpContext, - ); - } - return createSyntheticErrorResponse( - lastError?.message || `Exceeded max account switches (${maxAccountSwitches}). All accounts rate-limited.`, - model ?? "unknown", - ); - } - - // Avoid tight retry loops when there's only one account. - if (accountCount <= 1) { if (lastFailure) { - return transformAntigravityResponse( - lastFailure.response, - lastFailure.streaming, - lastFailure.debugContext, - lastFailure.requestedModel, - lastFailure.projectId, - lastFailure.endpoint, - lastFailure.effectiveModel, - lastFailure.sessionId, - lastFailure.toolDebugMissing, - lastFailure.toolDebugSummary, - lastFailure.toolDebugPayload, - debugLines, - lastFailure.dumpContext, - ); - } - - return createSyntheticErrorResponse( - lastError?.message || "All Antigravity endpoints failed", - model ?? "unknown", - ); - } - - continue; - } - - // If we get here without returning, something went wrong - if (lastFailure) { - return transformAntigravityResponse( - lastFailure.response, - lastFailure.streaming, - lastFailure.debugContext, - lastFailure.requestedModel, - lastFailure.projectId, - lastFailure.endpoint, - lastFailure.effectiveModel, - lastFailure.sessionId, - lastFailure.toolDebugMissing, - lastFailure.toolDebugSummary, - lastFailure.toolDebugPayload, - debugLines, - lastFailure.dumpContext, - ); - } - - return createSyntheticErrorResponse( - lastError?.message || "All Antigravity accounts failed", - model ?? "unknown", - ); - } - }, - }; - }, - methods: [ - { - label: "OAuth with Google (Antigravity)", - type: "oauth", - authorize: async (inputs?: Record) => { - const isHeadless = !!( - process.env.SSH_CONNECTION || - process.env.SSH_CLIENT || - process.env.SSH_TTY || - process.env.OPENCODE_HEADLESS - ); - - // CLI flow (`opencode auth login`) passes an inputs object. - if (inputs) { - const accounts: Array> = []; - const noBrowser = inputs.noBrowser === "true" || inputs["no-browser"] === "true"; - const useManualMode = noBrowser || shouldSkipLocalServer(); - - // Check for existing accounts and prompt user for login mode - let startFresh = true; - let refreshAccountIndex: number | undefined; - const existingStorage = await loadAccounts(); - if (existingStorage && existingStorage.accounts.length > 0) { - let menuResult; - while (true) { - const now = Date.now(); - const existingAccounts = existingStorage.accounts.map((acc, idx) => { - let status: 'active' | 'rate-limited' | 'expired' | 'verification-required' | 'unknown' = 'unknown'; - - if (acc.verificationRequired) { - status = 'verification-required'; - } else { - const rateLimits = acc.rateLimitResetTimes; - if (rateLimits) { - const isRateLimited = Object.values(rateLimits).some( - (resetTime) => typeof resetTime === 'number' && resetTime > now - ); - if (isRateLimited) { - status = 'rate-limited'; - } else { - status = 'active'; - } - } else { - status = 'active'; - } - - if (acc.coolingDownUntil && acc.coolingDownUntil > now) { - status = 'rate-limited'; - } - } - - const cooldownMs = (acc.coolingDownUntil && acc.coolingDownUntil > now) - ? acc.coolingDownUntil - now - : undefined; - - // Age-gate quota data: ignore cached quota older than 60 minutes - // to prevent stale exhaustion data from persisting in the UI. - // AccountManager applies the same protection during account selection. - const DISPLAY_QUOTA_MAX_AGE_MS = 60 * 60 * 1000; - const quotaIsStale = acc.cachedQuotaUpdatedAt == null - || (now - acc.cachedQuotaUpdatedAt) > DISPLAY_QUOTA_MAX_AGE_MS; - const displayQuota = quotaIsStale ? undefined : acc.cachedQuota; - const displayPerModelQuota = quotaIsStale ? undefined : acc.cachedPerModelQuota; - - if (status === 'active' && displayQuota) { - const groups = Object.values(displayQuota); - const allExhausted = groups.length > 0 && groups.every( - (group) => typeof group.remainingFraction === "number" && group.remainingFraction <= 0, - ); - if (allExhausted) { - status = 'rate-limited'; - } - } - - return { - email: acc.email, - index: idx, - addedAt: acc.addedAt, - lastUsed: acc.lastUsed, - status, - isCurrentAccount: idx === (existingStorage.activeIndex ?? 0), - enabled: acc.enabled !== false, - quotaSummary: quotaIsStale ? undefined : formatCachedQuotaSummary(acc), - cooldownMs, - cooldownReason: cooldownMs ? acc.cooldownReason : undefined, - cachedQuota: displayQuota, - cachedPerModelQuota: displayPerModelQuota, - fingerprintHistory: acc.fingerprintHistory, - }; }); - - menuResult = await promptLoginMode(existingAccounts); - - if (menuResult.mode === "check") { - console.log("\nšŸ“Š Checking quotas for all accounts...\n"); - clearProvisionFailedKeys(); - const results = await checkAccountsQuota(existingStorage.accounts, client, providerId); - let storageUpdated = false; - - for (const res of results) { - const label = res.email || `Account ${res.index + 1}`; - const disabledStr = res.disabled ? " (disabled)" : ""; - console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); - console.log(` ${label}${disabledStr}`); - console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); - - if (res.status === "error") { - console.log(` āŒ Error: ${res.error}\n`); - continue; - } - - // ANSI color codes - const colors = { - red: '\x1b[31m', - orange: '\x1b[33m', // Yellow/orange - green: '\x1b[32m', - reset: '\x1b[0m', - }; - - // Get color based on remaining percentage - const getColor = (remaining?: number): string => { - if (typeof remaining !== 'number') return colors.reset; - if (remaining < 0.2) return colors.red; - if (remaining < 0.6) return colors.orange; - return colors.green; - }; - - // Helper to create colored progress bar - const createProgressBar = (remaining?: number, width: number = 20): string => { - if (typeof remaining !== 'number') return 'ā–‘'.repeat(width) + ' ???'; - const filled = Math.round(remaining * width); - const empty = width - filled; - const color = getColor(remaining); - const bar = `${color}${'ā–ˆ'.repeat(filled)}${colors.reset}${'ā–‘'.repeat(empty)}`; - const pct = `${color}${Math.round(remaining * 100)}%${colors.reset}`.padStart(4 + color.length + colors.reset.length); - return `${bar} ${pct}`; - }; - - // Helper to format reset time with days support - const formatReset = (resetTime?: string, remainingFraction?: number): string => { - if (!resetTime) return ''; - const ms = Date.parse(resetTime) - Date.now(); - if (ms <= 0) { - // If quota is 0% and reset time is in the past, the model is - // likely paywalled / permanently unavailable on this quota pool - return remainingFraction !== undefined && remainingFraction <= 0 - ? ' (paid only)' - : ' (resetting...)'; - } - const hours = ms / (1000 * 60 * 60); - if (hours >= 24) { - const days = Math.floor(hours / 24); - const remainingHours = Math.floor(hours % 24); - if (remainingHours > 0) { - return ` (resets in ${days}d ${remainingHours}h)`; - } - return ` (resets in ${days}d)`; - } - return ` (resets in ${formatWaitTime(ms)})`; - }; - - // Display Gemini CLI Quota first (as requested - swap order) - const hasGeminiCli = res.geminiCliQuota && res.geminiCliQuota.models.length > 0; - console.log(`\n ā”Œā”€ Gemini CLI Quota`); - if (!hasGeminiCli) { - const errorMsg = res.geminiCliQuota?.error || "No Gemini CLI quota available"; - console.log(` │ └─ ${errorMsg}`); - } else { - const models = res.geminiCliQuota!.models; - models.forEach((model, idx) => { - const isLast = idx === models.length - 1; - const connector = isLast ? "└─" : "ā”œā”€"; - const bar = createProgressBar(model.remainingFraction); - const reset = formatReset(model.resetTime, model.remainingFraction); - const status = classifyGroupStatus({ remainingFraction: model.remainingFraction, resetTime: model.resetTime, modelCount: 1 }); - const badge = formatQuotaStatusBadge(status); - const modelName = model.modelId.padEnd(29); - console.log(` │ ${connector} ${modelName} ${bar} ${badge}${reset}`); - }); } - - // Display Antigravity Quota second - const hasAntigravity = res.quota && Object.keys(res.quota.groups).length > 0; - console.log(` │`); - console.log(` └─ Antigravity Quota`); - if (!hasAntigravity) { - const errorMsg = res.quota?.error || "No quota information available"; - console.log(` └─ ${errorMsg}`); - } else { - const groups = res.quota!.groups; - const groupEntries = [ - { name: "Claude", data: groups.claude }, - { name: "Gemini 3 Pro", data: groups["gemini-pro"] }, - { name: "Gemini 3 Flash", data: groups["gemini-flash"] }, - ].filter(g => g.data); - - groupEntries.forEach((g, idx) => { - const isLast = idx === groupEntries.length - 1; - const connector = isLast ? "└─" : "ā”œā”€"; - const bar = createProgressBar(g.data!.remainingFraction); - const reset = formatReset(g.data!.resetTime, g.data!.remainingFraction); - const status = classifyGroupStatus(g.data!); - const badge = formatQuotaStatusBadge(status); - const modelName = g.name.padEnd(29); - console.log(` ${connector} ${modelName} ${bar} ${badge}${reset}`); - }); } - console.log(""); - - // Cache quota data for soft quota protection - if (res.quota?.groups) { - const acc = existingStorage.accounts[res.index]; - if (acc) { - acc.cachedQuota = res.quota.groups; - acc.cachedPerModelQuota = res.quota.perModel; - acc.cachedQuotaUpdatedAt = Date.now(); - storageUpdated = true; - } - } - - if (res.updatedAccount) { - existingStorage.accounts[res.index] = { - ...res.updatedAccount, - cachedQuota: res.quota?.groups, - cachedPerModelQuota: res.quota?.perModel, - cachedQuotaUpdatedAt: Date.now(), - }; - storageUpdated = true; - } } - if (storageUpdated) { - await saveAccounts(existingStorage); - } - console.log(""); - continue; - } - - if (menuResult.mode === "doctor") { - const auth = cachedGetAuth ? await cachedGetAuth().catch(() => undefined) : undefined; - const versionResolution = getAntigravityVersionResolution(); - const report = createAuthDoctorReport({ - auth, - storage: existingStorage, - runtime: { - antigravityVersion: versionResolution.version, - antigravityVersionSource: versionResolution.source, - }, - }); - console.log(`\n${formatAuthDoctorReport(report)}\n`); - continue; - } - - if (menuResult.mode === "manage") { - if (menuResult.toggleAccountIndex !== undefined) { - const acc = existingStorage.accounts[menuResult.toggleAccountIndex]; - if (acc) { - acc.enabled = acc.enabled === false; - await saveAccounts(existingStorage); - activeAccountManager?.setAccountEnabled(menuResult.toggleAccountIndex, acc.enabled); - console.log(`\nAccount ${acc.email || menuResult.toggleAccountIndex + 1} ${acc.enabled ? 'enabled' : 'disabled'}.\n`); - } - } - continue; - } - - if (menuResult.mode === "verify" || menuResult.mode === "verify-all") { - const verifyAll = menuResult.mode === "verify-all" || menuResult.verifyAll === true; - - if (verifyAll) { - if (existingStorage.accounts.length === 0) { - console.log("\nNo accounts available to verify.\n"); - continue; - } - - console.log(`\nChecking verification status for ${existingStorage.accounts.length} account(s)...\n`); - - let okCount = 0; - let blockedCount = 0; - let errorCount = 0; - let storageUpdated = false; - - const blockedResults: Array<{ label: string; message: string; verifyUrl?: string }> = []; - - for (let i = 0; i < existingStorage.accounts.length; i++) { - const account = existingStorage.accounts[i]; - if (!account) continue; - - const label = account.email || `Account ${i + 1}`; - process.stdout.write(`- [${i + 1}/${existingStorage.accounts.length}] ${label} ... `); - - const verification = await verifyAccountAccess(account, client, providerId); - if (verification.status === "ok") { - const { changed, wasVerificationRequired } = clearStoredAccountVerificationRequired(account, true); - if (changed) { - storageUpdated = true; - } - activeAccountManager?.clearAccountVerificationRequired(i, wasVerificationRequired); - okCount += 1; - console.log("ok"); - continue; - } - - if (verification.status === "blocked") { - const changed = markStoredAccountVerificationRequired( - account, - verification.message, - verification.verifyUrl, - ); - if (changed) { - storageUpdated = true; - } - activeAccountManager?.markAccountVerificationRequired(i, verification.message, verification.verifyUrl); - - blockedCount += 1; - console.log("needs verification"); - const verifyUrl = verification.verifyUrl ?? account.verificationUrl; - blockedResults.push({ - label, - message: verification.message, - verifyUrl, - }); - continue; - } - - errorCount += 1; - console.log(`error (${verification.message})`); - } - - if (storageUpdated) { - await saveAccounts(existingStorage); - } - - console.log(`\nVerification summary: ${okCount} ready, ${blockedCount} need verification, ${errorCount} errors.`); - - if (blockedResults.length > 0) { - console.log("\nAccounts needing verification:"); - for (const result of blockedResults) { - console.log(`\n- ${result.label}`); - console.log(` ${result.message}`); - if (result.verifyUrl) { - console.log(` URL: ${result.verifyUrl}`); - } else { - console.log(" URL: not provided by API response"); - } - } - console.log(""); - } else { - console.log(""); - } - - continue; - } - - let verifyAccountIndex = menuResult.verifyAccountIndex; - if (verifyAccountIndex === undefined) { - verifyAccountIndex = await promptAccountIndexForVerification(existingAccounts); - } - - if (verifyAccountIndex === undefined) { - console.log("\nVerification cancelled.\n"); - continue; - } - - const account = existingStorage.accounts[verifyAccountIndex]; - if (!account) { - console.log(`\nAccount ${verifyAccountIndex + 1} not found.\n`); - continue; - } - - const label = account.email || `Account ${verifyAccountIndex + 1}`; - console.log(`\nChecking verification status for ${label}...\n`); - - const verification = await verifyAccountAccess(account, client, providerId); - - if (verification.status === "ok") { - const { changed, wasVerificationRequired } = clearStoredAccountVerificationRequired(account, true); - if (changed) { - await saveAccounts(existingStorage); - } - activeAccountManager?.clearAccountVerificationRequired(verifyAccountIndex, wasVerificationRequired); - - if (wasVerificationRequired) { - console.log(`āœ“ ${label} is ready for requests and has been re-enabled.\n`); - } else { - console.log(`āœ“ ${label} is ready for requests.\n`); - } - continue; - } - - if (verification.status === "blocked") { - const changed = markStoredAccountVerificationRequired( - account, - verification.message, - verification.verifyUrl, - ); - if (changed) { - await saveAccounts(existingStorage); - } - activeAccountManager?.markAccountVerificationRequired( - verifyAccountIndex, - verification.message, - verification.verifyUrl, - ); - - const verifyUrl = verification.verifyUrl ?? account.verificationUrl; - console.log(`⚠ ${label} needs Google verification before it can be used.`); - if (verification.message) { - console.log(verification.message); - } - console.log(`${label} has been disabled until verification is completed.`); - if (verifyUrl) { - console.log(`\nVerification URL:\n${verifyUrl}\n`); - if (await promptOpenVerificationUrl()) { - const opened = await openBrowser(verifyUrl); - if (opened) { - console.log("Opened verification URL in your browser.\n"); - } else { - console.log("Could not open browser automatically. Please open the URL manually.\n"); - } - } - } else { - console.log("No verification URL was returned. Try re-authenticating this account.\n"); - } - continue; - } - - console.log(`āœ— ${label}: ${verification.message}\n`); - continue; - } - - break; - } - - if (menuResult.mode === "cancel") { - return { - url: "", - instructions: "Authentication cancelled", - method: "auto", - callback: async () => ({ type: "failed", error: "Authentication cancelled" }), - }; - } - - if (menuResult.deleteAccountIndex !== undefined) { - const updatedAccounts = existingStorage.accounts.filter( - (_, idx) => idx !== menuResult.deleteAccountIndex - ); - // Use saveAccountsReplace to bypass merge (otherwise deleted account gets merged back) - await saveAccountsReplace({ - version: 4, - accounts: updatedAccounts, - activeIndex: 0, - activeIndexByFamily: { claude: 0, gemini: 0 }, - }); - // Sync in-memory state so deleted account stops being used immediately - activeAccountManager?.removeAccountByIndex(menuResult.deleteAccountIndex); - console.log("\nAccount deleted.\n"); - - if (updatedAccounts.length > 0) { - const fallbackAccount = updatedAccounts[0]; - if (fallbackAccount?.refreshToken) { - const fallbackResult = buildAuthSuccessFromStoredAccount(fallbackAccount); - try { - await client.auth.set({ - path: { id: providerId }, - body: { type: "oauth", refresh: fallbackResult.refresh, access: "", expires: 0 }, - }); - } catch (storeError) { - log.error("Failed to update stored Antigravity OAuth credentials", { error: String(storeError) }); - } - - const label = fallbackAccount.email || `Account ${1}`; - return { - url: "", - instructions: `Account deleted. Using ${label} for future requests.`, - method: "auto", - callback: async () => fallbackResult, - }; - } - } - - try { - await client.auth.set({ - path: { id: providerId }, - body: { type: "oauth", refresh: "", access: "", expires: 0 }, - }); - } catch (storeError) { - log.error("Failed to clear stored Antigravity OAuth credentials", { error: String(storeError) }); - } - - return { - url: "", - instructions: "All accounts deleted. Run `opencode auth login` to reauthenticate.", - method: "auto", - callback: async () => ({ - type: "failed", - error: "All accounts deleted. Reauthentication required.", - }), - }; - } - - if (menuResult.refreshAccountIndex !== undefined) { - refreshAccountIndex = menuResult.refreshAccountIndex; - const refreshEmail = existingStorage.accounts[refreshAccountIndex]?.email; - console.log(`\nRe-authenticating ${refreshEmail || 'account'}...\n`); - startFresh = false; - } - - if (menuResult.deleteAll) { - await clearAccounts(); - console.log("\nAll accounts deleted.\n"); - startFresh = true; - try { - await client.auth.set({ - path: { id: providerId }, - body: { type: "oauth", refresh: "", access: "", expires: 0 }, - }); - } catch (storeError) { - log.error("Failed to clear stored Antigravity OAuth credentials", { error: String(storeError) }); - } - } else { - startFresh = menuResult.mode === "fresh"; - } - - if (startFresh && !menuResult.deleteAll) { - console.log("\nStarting fresh - existing accounts will be replaced.\n"); - } else if (!startFresh) { - console.log("\nAdding to existing accounts.\n"); - } - } - - while (accounts.length < MAX_OAUTH_ACCOUNTS) { - console.log(`\n=== Antigravity OAuth (Account ${accounts.length + 1}) ===`); - - const projectId = await promptProjectId(); - - const result = await (async (): Promise => { - const authorization = await authorizeAntigravity(projectId); - const fallbackState = getStateFromAuthorizationUrl(authorization.url); - - console.log("\nOAuth URL:\n" + authorization.url + "\n"); - - if (useManualMode) { - const browserOpened = await openBrowser(authorization.url); - if (!browserOpened) { - console.log("Could not open browser automatically."); - console.log("Please open the URL above manually in your local browser.\n"); - } - return promptManualOAuthInput(fallbackState); - } - - let listener: OAuthListener | null = null; - if (!isHeadless) { - try { - listener = await startOAuthListener(); - } catch { - listener = null; - } - } - - if (!isHeadless) { - await openBrowser(authorization.url); - } - - if (listener) { - try { - const SOFT_TIMEOUT_MS = 30000; - const callbackPromise = listener.waitForCallback(); - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error("SOFT_TIMEOUT")), SOFT_TIMEOUT_MS) - ); - - let callbackUrl: URL; - try { - callbackUrl = await Promise.race([callbackPromise, timeoutPromise]); - } catch (err) { - if (err instanceof Error && err.message === "SOFT_TIMEOUT") { - console.log("\nā³ Automatic callback not received after 30 seconds."); - console.log("You can paste the redirect URL manually.\n"); - console.log("OAuth URL (in case you need it again):"); - console.log(authorization.url + "\n"); - - try { - await listener.close(); - } catch {} - - return promptManualOAuthInput(fallbackState); - } - throw err; - } - - const params = extractOAuthCallbackParams(callbackUrl); - if (!params) { - return { type: "failed", error: "Missing code or state in callback URL" }; - } - - return exchangeAntigravity(params.code, params.state); - } catch (error) { - if (error instanceof Error && error.message !== "SOFT_TIMEOUT") { - return { - type: "failed", - error: error.message, - }; - } - return { - type: "failed", - error: error instanceof Error ? error.message : "Unknown error", - }; - } finally { - try { - await listener.close(); - } catch {} - } - } - - return promptManualOAuthInput(fallbackState); - })(); - - if (result.type === "failed") { - if (accounts.length === 0) { - return { - url: "", - instructions: `Authentication failed: ${result.error}`, - method: "auto", - callback: async () => result, - }; - } - - console.warn( - `[opencode-antigravity-auth] Skipping failed account ${accounts.length + 1}: ${result.error}`, - ); - break; - } - - accounts.push(result); - - try { - await client.tui.showToast({ - body: { - message: `Account ${accounts.length} authenticated${result.email ? ` (${result.email})` : ""}`, - variant: "success", - }, - }); - } catch { - } - - try { - if (refreshAccountIndex !== undefined) { - const currentStorage = await loadAccounts(); - if (currentStorage) { - const updatedAccounts = [...currentStorage.accounts]; - const parts = parseRefreshParts(result.refresh); - if (parts.refreshToken) { - updatedAccounts[refreshAccountIndex] = { - email: result.email ?? updatedAccounts[refreshAccountIndex]?.email, - refreshToken: parts.refreshToken, - projectId: parts.projectId ?? updatedAccounts[refreshAccountIndex]?.projectId, - managedProjectId: parts.managedProjectId ?? updatedAccounts[refreshAccountIndex]?.managedProjectId, - addedAt: updatedAccounts[refreshAccountIndex]?.addedAt ?? Date.now(), - lastUsed: Date.now(), - }; - await saveAccounts({ - version: 4, - accounts: updatedAccounts, - activeIndex: currentStorage.activeIndex, - activeIndexByFamily: currentStorage.activeIndexByFamily, - }); - } - } - } else { - const isFirstAccount = accounts.length === 1; - await persistAccountPool([result], isFirstAccount && startFresh); - } - } catch { - } - - if (refreshAccountIndex !== undefined) { - break; - } - - if (accounts.length >= MAX_OAUTH_ACCOUNTS) { - break; - } - - // Get the actual deduplicated account count from storage for the prompt - let currentAccountCount = accounts.length; - try { - const currentStorage = await loadAccounts(); - if (currentStorage) { - currentAccountCount = currentStorage.accounts.length; - } - } catch { - // Fall back to accounts.length if we can't read storage - } - - const addAnother = await promptAddAnotherAccount(currentAccountCount); - if (!addAnother) { - break; - } - } - - const primary = accounts[0]; - if (!primary) { - return { - url: "", - instructions: "Authentication cancelled", - method: "auto", - callback: async () => ({ type: "failed", error: "Authentication cancelled" }), - }; - } - - let actualAccountCount = accounts.length; - try { - const finalStorage = await loadAccounts(); - if (finalStorage) { - actualAccountCount = finalStorage.accounts.length; - } - } catch { - } - - const successMessage = refreshAccountIndex !== undefined - ? `Token refreshed successfully.` - : `Multi-account setup complete (${actualAccountCount} account(s)).`; - - return { - url: "", - instructions: successMessage, - method: "auto", - callback: async (): Promise => primary, - }; - } - - // TUI flow (`/connect`) does not support per-account prompts. - // Default to adding new accounts (non-destructive). - // Users can run `opencode auth logout` first if they want a fresh start. - const projectId = ""; - - // Check existing accounts count for toast message - const existingStorage = await loadAccounts(); - const existingCount = existingStorage?.accounts.length ?? 0; - - const useManualFlow = isHeadless || shouldSkipLocalServer(); - - let listener: OAuthListener | null = null; - if (!useManualFlow) { - try { - listener = await startOAuthListener(); - } catch { - listener = null; - } - } - - const authorization = await authorizeAntigravity(projectId); - const fallbackState = getStateFromAuthorizationUrl(authorization.url); - - if (!useManualFlow) { - const browserOpened = await openBrowser(authorization.url); - if (!browserOpened) { - listener?.close().catch(() => {}); - listener = null; - } - } - - if (listener) { - return { - url: authorization.url, - instructions: - "Complete sign-in in your browser. We'll automatically detect the redirect back to localhost.", - method: "auto", - callback: async (): Promise => { - const CALLBACK_TIMEOUT_MS = 30000; - try { - const callbackPromise = listener.waitForCallback(); - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error("CALLBACK_TIMEOUT")), CALLBACK_TIMEOUT_MS), - ); - - let callbackUrl: URL; - try { - callbackUrl = await Promise.race([callbackPromise, timeoutPromise]); - } catch (err) { - if (err instanceof Error && err.message === "CALLBACK_TIMEOUT") { - return { - type: "failed", - error: "Callback timeout - please use CLI with --no-browser flag for manual input", - }; - } - throw err; - } - - const params = extractOAuthCallbackParams(callbackUrl); - if (!params) { - return { type: "failed", error: "Missing code or state in callback URL" }; - } - - const result = await exchangeAntigravity(params.code, params.state); - if (result.type === "success") { - try { - await persistAccountPool([result], false); - } catch { - } - - const newTotal = existingCount + 1; - const toastMessage = existingCount > 0 - ? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total` - : `Authenticated${result.email ? ` (${result.email})` : ""}`; - - try { - await client.tui.showToast({ - body: { - message: toastMessage, - variant: "success", - }, - }); - } catch { - } - } - - return result; - } catch (error) { - return { - type: "failed", - error: error instanceof Error ? error.message : "Unknown error", - }; - } finally { - try { - await listener.close(); - } catch { - } - } - }, - }; - } - - return { - url: authorization.url, - instructions: - "Visit the URL above, complete OAuth, then paste either the full redirect URL or the authorization code.", - method: "code", - callback: async (codeInput: string): Promise => { - const params = parseOAuthCallbackInput(codeInput, fallbackState); - if ("error" in params) { - return { type: "failed", error: params.error }; - } - - const result = await exchangeAntigravity(params.code, params.state); - if (result.type === "success") { - try { - // TUI flow adds to existing accounts (non-destructive) - await persistAccountPool([result], false); - } catch { - // ignore - } - - // Show appropriate toast message - const newTotal = existingCount + 1; - const toastMessage = existingCount > 0 - ? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total` - : `Authenticated${result.email ? ` (${result.email})` : ""}`; - - try { - await client.tui.showToast({ - body: { - message: toastMessage, - variant: "success", - }, - }); - } catch { - // TUI may not be available - } - } - - return result; - }, - }; - }, - }, - { - label: "Manually enter API Key", - type: "api", - }, - ], - }, - }; -}; - -export const AntigravityCLIOAuthPlugin = createAntigravityPlugin(ANTIGRAVITY_PROVIDER_ID); -export const GoogleOAuthPlugin = AntigravityCLIOAuthPlugin; - -function toUrlString(value: RequestInfo): string { - if (typeof value === "string") { - return value; - } - const candidate = (value as Request).url; - if (candidate) { - return candidate; - } - return value.toString(); -} - -function toWarmupStreamUrl(value: RequestInfo): string { - const urlString = toUrlString(value); - try { - const url = new URL(urlString); - if (!url.pathname.includes(":streamGenerateContent")) { - url.pathname = url.pathname.replace(":generateContent", ":streamGenerateContent"); - } - url.searchParams.set("alt", "sse"); - return url.toString(); - } catch { - return urlString; - } -} - -function extractModelFromUrl(urlString: string): string | null { - const match = urlString.match(/\/models\/([^:\/?]+)(?::\w+)?/); - return match?.[1] ?? null; -} - -function extractModelFromUrlWithSuffix(urlString: string): string | null { - const match = urlString.match(/\/models\/([^:\/\?]+)/); - return match?.[1] ?? null; -} - -function getModelFamilyFromUrl(urlString: string): ModelFamily { - const model = extractModelFromUrl(urlString); - let family: ModelFamily = "gemini"; - if (model && model.includes("claude")) { - family = "claude"; - } - if (isDebugEnabled()) { - logModelFamily(urlString, model, family); - } - return family; -} - -function resolveQuotaFallbackHeaderStyle(input: { - family: ModelFamily; - headerStyle: HeaderStyle; - alternateStyle: HeaderStyle | null; -}): HeaderStyle | null { - if (input.family !== "gemini") { - return null; - } - if (!input.alternateStyle || input.alternateStyle === input.headerStyle) { - return null; - } - return input.alternateStyle; -} - -type HeaderRoutingDecision = { - cliFirst: boolean; - preferredHeaderStyle: HeaderStyle; - explicitQuota: boolean; - allowQuotaFallback: boolean; -}; - -function resolveHeaderRoutingDecision( - urlString: string, - family: ModelFamily, - config: AntigravityConfig, -): HeaderRoutingDecision { - const cliFirst = getCliFirst(config); - const preferredHeaderStyle = getHeaderStyleFromUrl(urlString, family, cliFirst); - const explicitQuota = isExplicitQuotaFromUrl(urlString); - return { - cliFirst, - preferredHeaderStyle, - explicitQuota, - allowQuotaFallback: family === "gemini" && !!(config.quota_style_fallback ?? false), - }; -} - -type OpencodeMutableConfig = Record & { - provider?: Record & { - models?: Record; - whitelist?: string[]; - }>; -}; - -function applyAntigravityProviderCatalog(config: Record, providerId: string): void { - const mutableConfig = config as OpencodeMutableConfig; - mutableConfig.provider ??= {}; - - const providerConfig = mutableConfig.provider[providerId] ?? {}; - providerConfig.models = { - ...(providerConfig.models ?? {}), - ...OPENCODE_MODEL_DEFINITIONS, - }; - providerConfig.whitelist = getAntigravityOpencodeModelIds(); - mutableConfig.provider[providerId] = providerConfig; -} - -function getCliFirst(config: AntigravityConfig): boolean { - return (config as AntigravityConfig & { cli_first?: boolean }).cli_first ?? false; -} - -function getHeaderStyleFromUrl( - urlString: string, - family: ModelFamily, - cliFirst: boolean = false, -): HeaderStyle { - if (family === "claude") { - return "antigravity"; - } - const modelWithSuffix = extractModelFromUrlWithSuffix(urlString); - if (!modelWithSuffix) { - return cliFirst ? "gemini-cli" : "antigravity"; - } - const { quotaPreference } = resolveModelWithTier(modelWithSuffix, { cli_first: cliFirst }); - return quotaPreference ?? "antigravity"; -} - -function isExplicitQuotaFromUrl(urlString: string): boolean { - const modelWithSuffix = extractModelFromUrlWithSuffix(urlString); - if (!modelWithSuffix) { - return false; - } - const { explicitQuota } = resolveModelWithTier(modelWithSuffix); - return explicitQuota ?? false; -} - -export const __testExports = { - getHeaderStyleFromUrl, - isCapacityRetryBudgetExhausted, - resolveHeaderRoutingDecision, - resolveQuotaFallbackHeaderStyle, -}; +CONTENT_PLACEHOLDER \ No newline at end of file