diff --git a/assets/antigravity.schema.json b/assets/antigravity.schema.json index 1a48106..ea0dcc7 100644 --- a/assets/antigravity.schema.json +++ b/assets/antigravity.schema.json @@ -10,6 +10,14 @@ "type": "boolean", "description": "Suppress most toast notifications (rate limit, account switching). Recovery toasts always shown. Env: OPENCODE_ANTIGRAVITY_QUIET=1" }, + "toast_scope": { + "default": "root_only", + "type": "string", + "enum": [ + "root_only", + "all" + ] + }, "debug": { "default": false, "type": "boolean", @@ -133,8 +141,7 @@ }, "cli_first": { "default": false, - "type": "boolean", - "description": "Prefer gemini-cli routing before Antigravity for Gemini models. When false (default), Antigravity is tried first and gemini-cli is fallback." + "type": "boolean" }, "account_selection_strategy": { "default": "hybrid", @@ -153,6 +160,27 @@ "default": true, "type": "boolean" }, + "scheduling_mode": { + "default": "cache_first", + "type": "string", + "enum": [ + "cache_first", + "balance", + "performance_first" + ] + }, + "max_cache_first_wait_seconds": { + "default": 60, + "type": "number", + "minimum": 5, + "maximum": 300 + }, + "failure_ttl_seconds": { + "default": 3600, + "type": "number", + "minimum": 60, + "maximum": 7200 + }, "default_retry_after_seconds": { "default": 60, "type": "number", @@ -165,6 +193,38 @@ "minimum": 5, "maximum": 300 }, + "request_jitter_max_ms": { + "default": 0, + "type": "number", + "minimum": 0, + "maximum": 5000 + }, + "soft_quota_threshold_percent": { + "default": 90, + "type": "number", + "minimum": 1, + "maximum": 100 + }, + "quota_refresh_interval_minutes": { + "default": 15, + "type": "number", + "minimum": 0, + "maximum": 60 + }, + "soft_quota_cache_ttl_minutes": { + "default": "auto", + "anyOf": [ + { + "type": "string", + "const": "auto" + }, + { + "type": "number", + "minimum": 1, + "maximum": 120 + } + ] + }, "health_score": { "type": "object", "properties": { @@ -255,6 +315,22 @@ "default": true, "type": "boolean", "description": "Enable automatic plugin updates. Env: OPENCODE_ANTIGRAVITY_AUTO_UPDATE=1" + }, + "notify_on_account_error": { + "default": true, + "type": "boolean" + }, + "telegram_bot_token": { + "type": "string" + }, + "telegram_chat_id": { + "type": "string" + }, + "notification_cooldown_seconds": { + "default": 60, + "type": "number", + "minimum": 0, + "maximum": 300 } }, "additionalProperties": false diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 08a043a..bc399fa 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -185,10 +185,86 @@ OPENCODE_ANTIGRAVITY_LOG_DIR=/path # log_dir OPENCODE_ANTIGRAVITY_KEEP_THINKING=1 # keep_thinking OPENCODE_ANTIGRAVITY_ACCOUNT_SELECTION_STRATEGY=round-robin OPENCODE_ANTIGRAVITY_PID_OFFSET_ENABLED=1 +OPENCODE_ANTIGRAVITY_TELEGRAM_BOT_TOKEN="your-bot-token" # Telegram notifications +OPENCODE_ANTIGRAVITY_TELEGRAM_CHAT_ID="your-chat-id" # Telegram chat ID ``` --- +## Error Notifications + +Get alerts when accounts encounter errors. Session continues with next account instead of stopping. + +| Option | Default | Description | +|--------|---------|-------------| +| `notify_on_account_error` | `true` | Enable notifications when accounts fail | +| `telegram_bot_token` | - | Telegram bot token for remote notifications | +| `telegram_chat_id` | - | Your Telegram chat ID | +| `notification_cooldown_seconds` | `60` | Cooldown between notifications (prevents spam) | + +### Telegram Setup (Optional) + +Receive account error notifications directly to Telegram - useful for monitoring long-running agents. + +**Step 1: Create a Telegram Bot** + +1. Open Telegram and search for [@BotFather](https://t.me/BotFather) +2. Send `/newbot` and follow the prompts +3. BotFather will give you a token like `123456789:ABCdefGHIjklMNOpqrsTUVwxyz` + +**Step 2: Get Your Chat ID** + +1. Search for [@userinfobot](https://t.me/userinfobot) on Telegram +2. Send any message to it +3. It will reply with your chat ID (e.g., `123456789`) + +**Step 3: Configure the Plugin** + +Add to your `antigravity.json`: + +```json +{ + "$schema": "https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/assets/antigravity.schema.json", + "telegram_bot_token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", + "telegram_chat_id": "123456789" +} +``` + +Or via environment variables: + +```bash +export OPENCODE_ANTIGRAVITY_TELEGRAM_BOT_TOKEN="123456789:ABCdefGHIjklMNOpqrsTUVwxyz" +export OPENCODE_ANTIGRAVITY_TELEGRAM_CHAT_ID="123456789" +``` + +**Step 4: Start Your Bot** + +> ⚠️ You must send at least one message to your bot before it can message you. + +1. Open your new bot in Telegram (use the link BotFather gave you) +2. Send any message (e.g., `/start`) +3. Now the plugin can send notifications to you! + +### What You'll Receive + +When an account encounters an error, you'll get a Telegram message like: + +``` +⚠️ Account Error +━━━━━━━━━━━━━━━━━━━━━━━━ +📧 Account: user@gmail.com +❌ Error: invalid_grant +💬 Message: Token revoked - run `opencode auth login` +📊 Status: 401 +🤖 Model: claude-sonnet-4-20250514 +📋 Remaining: 2 account(s) +🕐 Time: 2025-02-08T10:30:00.000Z +``` + +The session will automatically continue with the next available account. + +--- + ## Advanced Settings > These settings are for edge cases. Most users don't need to change them. diff --git a/package.json b/package.json index f4b5108..8625a8e 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "test:coverage": "vitest run --coverage", "prepublishOnly": "npm run build", "test:e2e:models": "npx tsx script/test-models.ts", - "test:e2e:regression": "npx tsx script/test-regression.ts" + "test:e2e:regression": "npx tsx script/test-regression.ts", + "prepare": "npm run build" }, "peerDependencies": { "typescript": "^5" diff --git a/src/plugin.ts b/src/plugin.ts index 04ca3c0..111f03e 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -7,7 +7,7 @@ import { accessTokenExpired, isOAuthAuth, parseRefreshParts } from "./plugin/aut import { promptAddAnotherAccount, promptLoginMode, promptProjectId } from "./plugin/cli"; import { ensureProjectContext } from "./plugin/project"; import { - startAntigravityDebugRequest, + startAntigravityDebugRequest, logAntigravityDebugResponse, logAccountContext, logRateLimitEvent, @@ -43,6 +43,7 @@ import { createProactiveRefreshQueue, type ProactiveRefreshQueue } from "./plugi import { initLogger, createLogger } from "./plugin/logger"; import { initHealthTracker, getHealthTracker, initTokenTracker, getTokenTracker } from "./plugin/rotation"; import { executeSearch } from "./plugin/search"; +// Notification import removed (inlined) import type { GetAuth, LoaderResult, @@ -119,23 +120,23 @@ async function triggerAsyncQuotaRefreshForAccount( 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 + 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]; @@ -143,9 +144,9 @@ async function triggerAsyncQuotaRefreshForAccount( 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(); @@ -245,7 +246,7 @@ async function openBrowser(url: string): Promise { try { exec(`wslview "${url}"`); return true; - } catch {} + } catch { } } if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { return false; @@ -352,7 +353,7 @@ async function persistAccountPool( } 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(); @@ -380,10 +381,10 @@ async function persistAccountPool( // 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; @@ -419,7 +420,7 @@ async function persistAccountPool( managedProjectId: parts.managedProjectId ?? existing.managedProjectId, lastUsed: now, }; - + // Update the token index if the token changed if (oldToken !== parts.refreshToken) { indexByRefreshToken.delete(oldToken); @@ -432,8 +433,8 @@ async function persistAccountPool( } // For fresh logins, always start at index 0 - const activeIndex = replaceAll - ? 0 + const activeIndex = replaceAll + ? 0 : (typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex) ? stored.activeIndex : 0); await saveAccounts({ @@ -488,13 +489,13 @@ function parseDurationToMs(duration: string): number | null { 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]!); @@ -506,7 +507,7 @@ function parseDurationToMs(duration: string): number | null { case "ms": totalMs += value; break; } } - + return matchFound ? totalMs : null; } @@ -523,12 +524,12 @@ function extractRateLimitBodyInfo(body: unknown): RateLimitBodyInfo { } const error = (body as { error?: unknown }).error; - const message = error && typeof error === "object" - ? (error as { message?: string }).message + const message = error && typeof error === "object" + ? (error as { message?: string }).message : undefined; - const details = error && typeof error === "object" - ? (error as { details?: unknown[] }).details + const details = error && typeof error === "object" + ? (error as { details?: unknown[] }).details : undefined; let reason: string | undefined; @@ -656,7 +657,7 @@ const emptyResponseAttempts = new Map(); * @returns { attempt, delayMs, isDuplicate } - isDuplicate=true if within dedup window */ function getRateLimitBackoff( - accountIndex: number, + accountIndex: number, quotaKey: string, serverRetryAfterMs: number | null, maxBackoffMs: number = 60_000 @@ -664,30 +665,30 @@ function getRateLimitBackoff( 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, + return { + attempt: previous.consecutive429, delayMs: Math.max(baseDelay, backoffDelay), - isDuplicate: true + 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 + const attempt = previous && (now - previous.lastAt < RATE_LIMIT_STATE_RESET_MS) + ? previous.consecutive429 + 1 : 1; - - rateLimitStateByAccountQuota.set(stateKey, { - consecutive429: attempt, + + rateLimitStateByAccountQuota.set(stateKey, { + consecutive429: attempt, lastAt: now, - quotaKey + 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 }; @@ -728,17 +729,17 @@ const FAILURE_STATE_RESET_MS = 120_000; // Reset failure count after 2 minutes o 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 + 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 }; } @@ -787,13 +788,13 @@ export const createAntigravityPlugin = (providerId: string) => async ( // 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); - + // Initialize health tracker for hybrid strategy if (config.health_score) { initHealthTracker({ @@ -815,16 +816,16 @@ export const createAntigravityPlugin = (providerId: string) => async ( 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, @@ -834,7 +835,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( 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") { @@ -850,14 +851,14 @@ export const createAntigravityPlugin = (providerId: string) => async ( 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, @@ -865,7 +866,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( sessionID, error, }; - + // handleSessionRecovery now does the actual fix (injects tool_result, etc.) const recovered = await sessionRecovery.handleSessionRecovery(messageInfo); @@ -877,8 +878,8 @@ export const createAntigravityPlugin = (providerId: string) => async ( path: { id: sessionID }, body: { parts: [{ type: "text", text: config.resume_text }] }, query: { directory }, - }).catch(() => {}); - + }).catch(() => { }); + // Show success toast (respects toast_scope for child sessions) const successToast = getRecoverySuccessToast(); log.debug("recovery-toast", { ...successToast, isChildSession, toastScope: config.toast_scope }); @@ -889,7 +890,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( message: successToast.message, variant: "success", }, - }).catch(() => {}); + }).catch(() => { }); } } } @@ -951,487 +952,592 @@ export const createAntigravityPlugin = (providerId: string) => async ( google_search: googleSearchTool, }, auth: { - provider: providerId, - loader: async (getAuth: GetAuth, provider: Provider): Promise> => { - // Cache getAuth for tool access - cachedGetAuth = getAuth; - - const auth = await getAuth(); - - // If OpenCode has no valid OAuth auth, clear any stale account storage - 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); - if (accountManager.getAccountCount() > 0) { - accountManager.requestSaveToDisk(); - } + provider: providerId, + loader: async (getAuth: GetAuth, provider: Provider): Promise> => { + // Cache getAuth for tool access + cachedGetAuth = getAuth; - // 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(); - } + const auth = await getAuth(); - if (isDebugEnabled()) { - const logPath = getLogFilePath(); - if (logPath) { + // If OpenCode has no valid OAuth auth, clear any stale account storage + if (!isOAuthAuth(auth)) { try { - await client.tui.showToast({ - body: { message: `Debug log: ${logPath}`, variant: "info" }, - }); + await clearAccounts(); } catch { - // TUI may not be available + // ignore } + return {}; } - } - if (provider.models) { - for (const model of Object.values(provider.models)) { - if (model) { - model.cost = { input: 0, output: 0 }; - } - } - } + // 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(); - return { - apiKey: "", - async fetch(input, init) { - if (!isGenerativeLanguageRequest(input)) { - return fetch(input, init); - } + // Note: AccountManager now ensures the current auth is always included in accounts - const latestAuth = await getAuth(); - if (!isOAuthAuth(latestAuth)) { - return fetch(input, init); - } + const accountManager = await AccountManager.loadFromDisk(auth); + if (accountManager.getAccountCount() > 0) { + accountManager.requestSaveToDisk(); + } - if (accountManager.getAccountCount() === 0) { - throw new Error("No Antigravity accounts configured. Run `opencode auth login`."); - } + // 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(); + } - 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}`); - - type FailureContext = { - response: Response; - streaming: boolean; - debugContext: ReturnType; - requestedModel?: string; - projectId?: string; - endpoint?: string; - effectiveModel?: string; - sessionId?: string; - toolDebugMissing?: number; - toolDebugSummary?: string; - toolDebugPayload?: string; - }; - - 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; - } - } - + if (isDebugEnabled()) { + const logPath = getLogFilePath(); + if (logPath) { try { await client.tui.showToast({ - body: { message, variant }, + body: { message: `Debug log: ${logPath}`, variant: "info" }, }); } 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); - }; - - while (true) { - // Check for abort at the start of each iteration - checkAborted(); - - const accountCount = accountManager.getAccountCount(); - - if (accountCount === 0) { - throw new Error("No Antigravity accounts available. Run `opencode auth login`."); + } + } + + if (provider.models) { + for (const model of Object.values(provider.models)) { + if (model) { + model.cost = { input: 0, output: 0 }; } + } + } - const softQuotaCacheTtlMs = computeSoftQuotaCacheTtlMs( - config.soft_quota_cache_ttl_minutes, - config.quota_refresh_interval_minutes, - ); + return { + apiKey: "", + async fetch(input, init) { + if (!isGenerativeLanguageRequest(input)) { + return fetch(input, init); + } - const account = accountManager.getCurrentOrNextForFamily( - family, - model, - config.account_selection_strategy, - 'antigravity', - config.pid_offset_enabled, - config.soft_quota_threshold_percent, - softQuotaCacheTtlMs, - ); - - 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 latestAuth = await getAuth(); + if (!isOAuthAuth(latestAuth)) { + return fetch(input, init); + } + + if (accountManager.getAccountCount() === 0) { + throw new Error("No Antigravity accounts configured. Run `opencode auth login`."); + } + + 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}`); + + type FailureContext = { + response: Response; + streaming: boolean; + debugContext: ReturnType; + requestedModel?: string; + projectId?: string; + endpoint?: string; + effectiveModel?: string; + sessionId?: string; + toolDebugMissing?: number; + toolDebugSummary?: string; + toolDebugPayload?: string; + accountEmail?: string; + }; + + 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); + }; + + while (true) { + // Check for abort at the start of each iteration + checkAborted(); + + const accountCount = accountManager.getAccountCount(); + + if (accountCount === 0) { + throw new Error("No Antigravity accounts available. Run `opencode auth login`."); + } + + const softQuotaCacheTtlMs = computeSoftQuotaCacheTtlMs( + config.soft_quota_cache_ttl_minutes, + config.quota_refresh_interval_minutes, + ); + + const account = accountManager.getCurrentOrNextForFamily( + family, + model, + config.account_selection_strategy, + 'antigravity', + config.pid_offset_enabled, + config.soft_quota_threshold_percent, + softQuotaCacheTtlMs, + ); + + 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" + ); + throw new Error( + `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.` + ); + } + + 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 headerStyle = getHeaderStyleFromUrl(urlString, family); + const explicitQuota = isExplicitQuotaFromUrl(urlString); + // All accounts are rate-limited - wait and retry + const waitMs = accountManager.getMinWaitTimeForFamily( + family, + model, + headerStyle, + explicitQuota, + ) || 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 (softQuotaWaitMs === null || (maxWaitMs > 0 && softQuotaWaitMs > maxWaitMs)) { - const waitTimeFormatted = softQuotaWaitMs ? formatWaitTime(softQuotaWaitMs) : "unknown"; + if (maxWaitMs > 0 && waitMs > maxWaitMs) { + const waitTimeFormatted = formatWaitTime(waitMs); await showToast( - `All accounts over ${threshold}% quota threshold. Resets in ${waitTimeFormatted}.`, + `Rate limited for ${waitTimeFormatted}. Try again later or add another account.`, "error" ); + + // Return a proper rate limit error response throw new Error( - `Quota protection: All ${accountCount} account(s) are over ${threshold}% usage for ${family}. ` + + `All ${accountCount} account(s) rate-limited for ${family}. ` + `Quota resets in ${waitTimeFormatted}. ` + - `Add more accounts, wait for quota reset, or set soft_quota_threshold_percent: 100 to disable.` + `Add more accounts with \`opencode auth login\` or wait and retry.` ); } - - 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; + + if (!rateLimitToastShown) { + await showToast(`All ${accountCount} account(s) rate-limited for ${family}. Waiting ${waitSecValue}s...`, "warning"); + rateLimitToastShown = true; } - - await sleep(softQuotaWaitMs, abortSignal); + + // Wait for the rate-limit cooldown to expire, then retry + await sleep(waitMs, abortSignal); continue; } - const headerStyle = getHeaderStyleFromUrl(urlString, family); - const explicitQuota = isExplicitQuotaFromUrl(urlString); - // All accounts are rate-limited - wait and retry - const waitMs = accountManager.getMinWaitTimeForFamily( - family, - model, - headerStyle, - explicitQuota, - ) || 60_000; - const waitSecValue = Math.max(1, Math.ceil(waitMs / 1000)); + // Account is available - reset the toast flag + resetAllAccountsBlockedToasts(); - pushDebug(`all-rate-limited family=${family} accounts=${accountCount} waitMs=${waitMs}`); + pushDebug( + `selected idx=${account.index} email=${account.email ?? ""} family=${family} accounts=${accountCount} strategy=${config.account_selection_strategy}`, + ); if (isDebugEnabled()) { - logAccountContext("All accounts rate-limited", { - index: -1, + logAccountContext("Selected", { + index: account.index, + email: account.email, family, totalAccounts: accountCount, + rateLimitState: account.rateLimitResetTimes, }); - 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); + // 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( - `Rate limited for ${waitTimeFormatted}. Try again later or add another account.`, - "error" + `Using ${accountLabel} (${enabledPosition}/${accountCount})`, + "info" ); - - // Return a proper rate limit error response - throw new Error( - `All ${accountCount} account(s) rate-limited for ${family}. ` + - `Quota resets in ${waitTimeFormatted}. ` + - `Add more accounts with \`opencode auth login\` or wait and retry.` - ); - } - - if (!rateLimitToastShown) { - await showToast(`All ${accountCount} account(s) rate-limited for ${family}. Waiting ${waitSecValue}s...`, "warning"); - rateLimitToastShown = true; + accountManager.markToastShown(account.index); } - // Wait for the rate-limit cooldown to expire, then retry - await sleep(waitMs, abortSignal); - continue; - } + accountManager.requestSaveToDisk(); - // Account is available - reset the toast flag - resetAllAccountsBlockedToasts(); + let authRecord = accountManager.toAuthDetails(account); - pushDebug( - `selected idx=${account.index} email=${account.email ?? ""} family=${family} accounts=${accountCount} strategy=${config.account_selection_strategy}`, - ); - if (isDebugEnabled()) { - logAccountContext("Selected", { - index: account.index, - email: account.email, - family, - totalAccounts: accountCount, - rateLimitState: account.rateLimitResetTimes, - }); - } + 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`); + } + // Notify about the error + const notificationConfig: NotificationConfig = { + enabled: config.notify_on_account_error, + cooldownMs: (config.notification_cooldown_seconds ?? 60) * 1000, + quietMode: config.quiet_mode, + telegram: config.telegram_bot_token && config.telegram_chat_id ? { + botToken: config.telegram_bot_token, + chatId: config.telegram_chat_id, + } : undefined, + }; + await notifyAccountError(client, notificationConfig, { + accountEmail: account.email, + accountIndex: account.index, + errorType: "auth-failure", + errorMessage: "Token refresh failed", + remainingAccounts: accountManager.getAccountCount() - 1, + timestamp: new Date(), + model: model ?? undefined, + }); + 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 { + await accountManager.saveToDisk(); + } catch (persistError) { + log.error("Failed to persist revoked account removal", { error: String(persistError) }); + } + } - // 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); - } + // Notify about the revoked account + const notificationConfig: NotificationConfig = { + enabled: config.notify_on_account_error, + cooldownMs: (config.notification_cooldown_seconds ?? 60) * 1000, + quietMode: config.quiet_mode, + telegram: config.telegram_bot_token && config.telegram_chat_id ? { + botToken: config.telegram_bot_token, + chatId: config.telegram_chat_id, + } : undefined, + }; + await notifyAccountError(client, notificationConfig, { + accountEmail: account.email, + accountIndex: account.index, + errorType: "invalid_grant", + errorMessage: "Token revoked - account removed from pool. Run `opencode auth login` to re-add.", + remainingAccounts: accountManager.getAccountCount(), + timestamp: new Date(), + model: model ?? undefined, + }); + + 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) }); + } - accountManager.requestSaveToDisk(); + throw new Error( + "All Antigravity accounts have invalid refresh tokens. Run `opencode auth login` and reauthenticate.", + ); + } - let authRecord = accountManager.toAuthDetails(account); + // Continue to next account instead of throwing + continue; + } - 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"); + 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-failed: cooldown ${cooldownMs}ms after ${failures} failures`); + pushDebug(`token-refresh-error: cooldown ${cooldownMs}ms after ${failures} failures`); } + // Notify about the error + const notificationConfig: NotificationConfig = { + enabled: config.notify_on_account_error, + cooldownMs: (config.notification_cooldown_seconds ?? 60) * 1000, + quietMode: config.quiet_mode, + telegram: config.telegram_bot_token && config.telegram_chat_id ? { + botToken: config.telegram_bot_token, + chatId: config.telegram_chat_id, + } : undefined, + }; + await notifyAccountError(client, notificationConfig, { + accountEmail: account.email, + accountIndex: account.index, + errorType: "auth-failure", + errorMessage: lastError.message, + remainingAccounts: accountManager.getAccountCount() - 1, + timestamp: new Date(), + model: model ?? undefined, + }); 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 { - await accountManager.saveToDisk(); - } 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) }); - } - - throw new Error( - "All Antigravity accounts have invalid refresh tokens. Run `opencode auth login` and reauthenticate.", - ); - } + } - lastError = error; - continue; + const accessToken = authRecord.access; + if (!accessToken) { + lastError = new Error("Missing access token"); + if (accountCount <= 1) { + throw lastError; } + 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, "auth-failure"); + accountManager.markAccountCoolingDown(account, cooldownMs, "project-error"); accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model); - pushDebug(`token-refresh-error: cooldown ${cooldownMs}ms after ${failures} failures`); + pushDebug(`project-context-error: cooldown ${cooldownMs}ms after ${failures} failures`); } + // Notify about the error + const notificationConfig: NotificationConfig = { + enabled: config.notify_on_account_error, + cooldownMs: (config.notification_cooldown_seconds ?? 60) * 1000, + quietMode: config.quiet_mode, + telegram: config.telegram_bot_token && config.telegram_chat_id ? { + botToken: config.telegram_bot_token, + chatId: config.telegram_chat_id, + } : undefined, + }; + await notifyAccountError(client, notificationConfig, { + accountEmail: account.email, + accountIndex: account.index, + errorType: "project-error", + errorMessage: lastError.message, + remainingAccounts: accountManager.getAccountCount() - 1, + timestamp: new Date(), + model: model ?? undefined, + }); continue; } - } - const accessToken = authRecord.access; - if (!accessToken) { - lastError = new Error("Missing access token"); - if (accountCount <= 1) { - throw lastError; + if (projectContext.auth !== authRecord) { + accountManager.updateFromAuth(account, projectContext.auth); + authRecord = projectContext.auth; + try { + await accountManager.saveToDisk(); + } catch (error) { + log.error("Failed to persist project context", { error: String(error) }); + } } - 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; - } + const runThinkingWarmup = async ( + prepared: ReturnType, + projectId: string, + ): Promise => { + if (!prepared.needsSignedThinkingWarmup || !prepared.sessionId) { + return; + } - if (projectContext.auth !== authRecord) { - accountManager.updateFromAuth(account, projectContext.auth); - authRecord = projectContext.auth; - try { - await accountManager.saveToDisk(); - } catch (error) { - log.error("Failed to persist project context", { error: String(error) }); - } - } + if (!trackWarmupAttempt(prepared.sessionId)) { + return; + } - const runThinkingWarmup = async ( - prepared: ReturnType, - projectId: string, - ): Promise => { - if (!prepared.needsSignedThinkingWarmup || !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; + } - if (!trackWarmupAttempt(prepared.sessionId)) { - return; - } + const warmupUrl = toWarmupStreamUrl(prepared.request); + const warmupHeaders = new Headers(prepared.init.headers ?? {}); + warmupHeaders.set("accept", "text/event-stream"); - 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 warmupInit: RequestInit = { + ...prepared.init, + method: prepared.init.method ?? "POST", + headers: warmupHeaders, + body: warmupBody, + }; - const warmupUrl = toWarmupStreamUrl(prepared.request); - const warmupHeaders = new Headers(prepared.init.headers ?? {}); - warmupHeaders.set("accept", "text/event-stream"); + const warmupDebugContext = startAntigravityDebugRequest({ + originalUrl: warmupUrl, + resolvedUrl: warmupUrl, + method: warmupInit.method, + headers: warmupHeaders, + body: warmupBody, + streaming: true, + projectId, + }); - const warmupInit: RequestInit = { - ...prepared.init, - method: prepared.init.method ?? "POST", - headers: warmupHeaders, - body: warmupBody, + try { + pushDebug("thinking-warmup: start"); + const warmupResponse = await fetch(warmupUrl, warmupInit); + const transformed = await transformAntigravityResponse( + warmupResponse, + true, + warmupDebugContext, + prepared.requestedModel, + projectId, + warmupUrl, + prepared.effectiveModel, + prepared.sessionId, + undefined, + undefined, + undefined, + undefined, + account.email, + ); + 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 warmupDebugContext = startAntigravityDebugRequest({ - originalUrl: warmupUrl, - resolvedUrl: warmupUrl, - method: warmupInit.method, - headers: warmupHeaders, - body: warmupBody, - streaming: true, - projectId, - }); + // Try endpoint fallbacks with single header style based on model suffix + let shouldSwitchAccount = false; - try { - pushDebug("thinking-warmup: start"); - const warmupResponse = 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)}`, - ); + // Determine header style from model suffix: + // - Gemini models default to Antigravity + // - Claude models always use Antigravity + let headerStyle = getHeaderStyleFromUrl(urlString, family); + const explicitQuota = isExplicitQuotaFromUrl(urlString); + const cliFirst = getCliFirst(config); + pushDebug(`headerStyle=${headerStyle} explicit=${explicitQuota}`); + if (account.fingerprint) { + pushDebug(`fingerprint: quotaUser=${account.fingerprint.quotaUser} deviceId=${account.fingerprint.deviceId.slice(0, 8)}...`); } - }; - // Try endpoint fallbacks with single header style based on model suffix - let shouldSwitchAccount = false; - - // Determine header style from model suffix: - // - Gemini models default to Antigravity - // - Claude models always use Antigravity - let headerStyle = getHeaderStyleFromUrl(urlString, family); - const explicitQuota = isExplicitQuotaFromUrl(urlString); - const cliFirst = getCliFirst(config); - pushDebug(`headerStyle=${headerStyle} explicit=${explicitQuota}`); - if (account.fingerprint) { - pushDebug(`fingerprint: quotaUser=${account.fingerprint.quotaUser} 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 (config.quota_fallback && !explicitQuota && family === "gemini" && headerStyle === "antigravity" && !cliFirst) { - // 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 + // 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 (config.quota_fallback && !explicitQuota && family === "gemini" && headerStyle === "antigravity" && !cliFirst) { + // 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({ + quotaFallback: config.quota_fallback, + cliFirst, + explicitQuota, + 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 (config.quota_fallback && !explicitQuota && family === "gemini") { + // gemini-cli rate-limited - try alternate style (antigravity) on same account const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); const fallbackStyle = resolveQuotaFallbackHeaderStyle({ quotaFallback: config.quota_fallback, @@ -1442,1222 +1548,1206 @@ export const createAntigravityPlugin = (providerId: string) => async ( alternateStyle, }); if (fallbackStyle) { + const quotaName = headerStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity"; + const altQuotaName = fallbackStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity"; await showToast( - `Antigravity quota exhausted on all accounts. Using Gemini CLI quota.`, + `${quotaName} quota exhausted, using ${altQuotaName} quota`, "warning" ); headerStyle = fallbackStyle; - pushDebug(`all-accounts antigravity exhausted, quota fallback: ${headerStyle}`); + pushDebug(`quota fallback: ${headerStyle}`); } else { shouldSwitchAccount = true; } - } - } else if (config.quota_fallback && !explicitQuota && family === "gemini") { - // gemini-cli rate-limited - try alternate style (antigravity) on same account - const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); - const fallbackStyle = resolveQuotaFallbackHeaderStyle({ - quotaFallback: config.quota_fallback, - cliFirst, - explicitQuota, - 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; - } - } - - 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]; + while (!shouldSwitchAccount) { - // 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; - } + // Flag to force thinking recovery on retry after API error + let forceThinkingRecovery = false; - try { - const prepared = prepareAntigravityRequest( - input, - init, - accessToken, - projectContext.effectiveProjectId, - currentEndpoint, - headerStyle, - forceThinkingRecovery, - { - claudeToolHardening: config.claude_tool_hardening, - fingerprint: account.fingerprint, - }, - ); + // Track if token was consumed (for hybrid strategy refund on error) + let tokenConsumed = false; - 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, - }); + // 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; + } - await runThinkingWarmup(prepared, projectContext.effectiveProjectId); + const currentEndpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i]; - 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); + // 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; } - } - // 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); - } + try { + const prepared = prepareAntigravityRequest( + input, + init, + accessToken, + projectContext.effectiveProjectId, + currentEndpoint, + headerStyle, + forceThinkingRecovery, + { + claudeToolHardening: config.claude_tool_hardening, + fingerprint: account.fingerprint, + }, + ); - const response = await fetch(prepared.request, prepared.init); - pushDebug(`status=${response.status} ${response.statusText}`); + 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, + }); + await runThinkingWarmup(prepared, projectContext.effectiveProjectId); + 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); + } - // 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 response = await fetch(prepared.request, prepared.init); + pushDebug(`status=${response.status} ${response.statusText}`); - 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") { - // 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})`); - - 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 < 3) { - capacityRetryCount++; - i -= 1; - continue; - } else { - pushDebug(`Max capacity retries (3) 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}`); + + + + // 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") { + // 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})`); + + 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 < 3) { + capacityRetryCount++; + i -= 1; + continue; + } else { + pushDebug(`Max capacity retries (3) 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; } - 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); + // STRATEGY 2: RATE LIMIT EXCEEDED (RPM) / QUOTA EXHAUSTED / UNKNOWN + // Goal: Lock and Rotate (Standard Logic) - 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}`); - } + // 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); - logRateLimitEvent( - account.index, - account.email, - family, - response.status, - effectiveDelayMs, - bodyInfo, - ); + // 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}`); + } - 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 + 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; } - // 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(); + + // For Gemini, preserve preferred quota across accounts before fallback + if (family === "gemini") { + if (headerStyle === "antigravity" && !cliFirst) { + // 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 5s...`, "warning"); + await sleep(SWITCH_ACCOUNT_DELAY_MS, 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 (config.quota_fallback && !explicitQuota) { + const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); + const fallbackStyle = resolveQuotaFallbackHeaderStyle({ + quotaFallback: config.quota_fallback, + cliFirst, + explicitQuota, + 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" && cliFirst) { + if (config.quota_fallback && !explicitQuota) { + const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); + const fallbackStyle = resolveQuotaFallbackHeaderStyle({ + quotaFallback: config.quota_fallback, + cliFirst, + explicitQuota, + 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; + } + } + } + } - accountManager.requestSaveToDisk(); + const quotaName = headerStyle === "antigravity" ? "Antigravity" : "Gemini CLI"; - // For Gemini, preserve preferred quota across accounts before fallback - if (family === "gemini") { - if (headerStyle === "antigravity" && !cliFirst) { - // 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 5s...`, "warning"); + if (accountCount > 1) { + const quotaMsg = bodyInfo.quotaResetTime + ? ` (quota resets ${bodyInfo.quotaResetTime})` + : ``; + await showToast(`Rate limited again. Switching account in 5s...${quotaMsg}`, "warning"); await sleep(SWITCH_ACCOUNT_DELAY_MS, abortSignal); + + lastFailure = { + response, + 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, + accountEmail: account.email, + }; + shouldSwitchAccount = true; + break; + } 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"); + + lastFailure = { + response, + 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, + accountEmail: account.email, + }; + + await sleep(expBackoffMs, 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 (config.quota_fallback && !explicitQuota) { - const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); - const fallbackStyle = resolveQuotaFallbackHeaderStyle({ - quotaFallback: config.quota_fallback, - cliFirst, - explicitQuota, - family, - headerStyle, - alternateStyle, - }); - if (fallbackStyle) { - const safeModelName = model || "this model"; + // Success - reset rate limit backoff state for this quota + const quotaKey = headerStyleToQuotaKey(headerStyle, family); + resetRateLimitState(account.index, quotaKey); + resetAccountFailureState(account.index); + + const shouldRetryEndpoint = ( + response.status === 403 || + response.status === 404 || + response.status >= 500 + ); + + if (shouldRetryEndpoint) { + await logResponseBody(debugContext, response, response.status); + } + + if (shouldRetryEndpoint && i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) { + lastFailure = { + response, + 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, + accountEmail: account.email, + }; + 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, + ); + } + logAntigravityDebugResponse(debugContext, response, { + note: response.ok ? "Success" : `Error ${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( - `Antigravity quota exhausted for ${safeModelName}. Switching to Gemini CLI quota...`, + "Context too long - use /compact to reduce size", "warning" ); - headerStyle = fallbackStyle; - pushDebug(`quota fallback: ${headerStyle}`); - continue; + 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); } } - } else if (headerStyle === "gemini-cli" && cliFirst) { - if (config.quota_fallback && !explicitQuota) { - const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); - const fallbackStyle = resolveQuotaFallbackHeaderStyle({ - quotaFallback: config.quota_fallback, - cliFirst, - explicitQuota, - family, - headerStyle, - alternateStyle, - }); - if (fallbackStyle) { - const safeModelName = model || "this model"; + } + + // 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( - `Gemini CLI quota exhausted for ${safeModelName}. Switching to Antigravity quota...`, + `Empty response received. Retrying (${currentAttempts}/${maxAttempts})...`, "warning" ); - headerStyle = fallbackStyle; - pushDebug(`quota fallback: ${headerStyle}`); - continue; + await sleep(retryDelayMs, abortSignal); + continue; // Retry the endpoint loop } + + // Clean up and throw after max attempts + emptyResponseAttempts.delete(emptyAttemptKey); + throw new EmptyResponseError( + "antigravity", + prepared.effectiveModel ?? "unknown", + currentAttempts, + ); } - } - } - const quotaName = headerStyle === "antigravity" ? "Antigravity" : "Gemini CLI"; + // Clean up successful attempt tracking + const emptyAttemptKeyClean = `${prepared.sessionId ?? "none"}:${prepared.effectiveModel ?? "unknown"}`; + emptyResponseAttempts.delete(emptyAttemptKeyClean); + } - if (accountCount > 1) { - const quotaMsg = bodyInfo.quotaResetTime - ? ` (quota resets ${bodyInfo.quotaResetTime})` - : ``; - await showToast(`Rate limited again. Switching account in 5s...${quotaMsg}`, "warning"); - await sleep(SWITCH_ACCOUNT_DELAY_MS, abortSignal); - - lastFailure = { + const transformedResponse = await transformAntigravityResponse( response, - 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, - }; - shouldSwitchAccount = true; - break; - } 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"); - - lastFailure = { - response, - streaming: prepared.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, - }; - - await sleep(expBackoffMs, abortSignal); - shouldSwitchAccount = true; - break; - } - } + prepared.requestedModel, + prepared.projectId, + prepared.endpoint, + prepared.effectiveModel, + prepared.sessionId, + prepared.toolDebugMissing, + prepared.toolDebugSummary, + prepared.toolDebugPayload, + debugLines, + account.email, + ); - // Success - reset rate limit backoff state for this quota - const quotaKey = headerStyleToQuotaKey(headerStyle, family); - resetRateLimitState(account.index, quotaKey); - resetAccountFailureState(account.index); + // 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" + ); + } + } - const shouldRetryEndpoint = ( - response.status === 403 || - response.status === 404 || - response.status >= 500 - ); + return transformedResponse; + } catch (error) { + // Refund token on network/API error (only if consumed) + if (tokenConsumed) { + getTokenTracker().refund(account.index); + tokenConsumed = false; + } - if (shouldRetryEndpoint) { - await logResponseBody(debugContext, response, response.status); - } + // 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; + } - if (shouldRetryEndpoint && i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) { - lastFailure = { - response, - 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, - }; - 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" } }; - // 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, - ); - } - logAntigravityDebugResponse(debugContext, response, { - note: response.ok ? "Success" : `Error ${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); + 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" } + }); } - } - } - - // 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 + + if (i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) { + lastError = error instanceof Error ? error : new Error(String(error)); + continue; } - - // Clean up and throw after max attempts - emptyResponseAttempts.delete(emptyAttemptKey); - throw new EmptyResponseError( - "antigravity", - prepared.effectiveModel ?? "unknown", - currentAttempts, - ); - } - - // 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, - ); - // 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" - ); + // 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; } } - - 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; + } // end headerStyleLoop + + if (shouldSwitchAccount) { + // 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.accountEmail, + ); } - - // 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; + throw lastError || new Error("All Antigravity endpoints failed"); } - // 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; + continue; } - } - } // end headerStyleLoop - - if (shouldSwitchAccount) { - // 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, - ); - } - throw lastError || new Error("All Antigravity endpoints failed"); + // 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.accountEmail, + ); } - 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, - ); + throw lastError || new Error("All Antigravity accounts failed"); } + }, + }; + }, + 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 + ); - throw lastError || new Error("All Antigravity accounts failed"); - } - }, - }; - }, - 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' | 'unknown' = 'unknown'; - - const rateLimits = acc.rateLimitResetTimes; - if (rateLimits) { - const isRateLimited = Object.values(rateLimits).some( - (resetTime) => typeof resetTime === 'number' && resetTime > now - ); - if (isRateLimited) { - status = 'rate-limited'; + // 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' | 'unknown' = 'unknown'; + + 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'; } - } else { - status = 'active'; - } - - if (acc.coolingDownUntil && acc.coolingDownUntil > now) { - 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, - }; - }); - - menuResult = await promptLoginMode(existingAccounts); - - if (menuResult.mode === "check") { - console.log("\n📊 Checking quotas for all accounts...\n"); - 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; + if (acc.coolingDownUntil && acc.coolingDownUntil > now) { + status = 'rate-limited'; } - // ANSI color codes - const colors = { - red: '\x1b[31m', - orange: '\x1b[33m', // Yellow/orange - green: '\x1b[32m', - reset: '\x1b[0m', + return { + email: acc.email, + index: idx, + addedAt: acc.addedAt, + lastUsed: acc.lastUsed, + status, + isCurrentAccount: idx === (existingStorage.activeIndex ?? 0), + enabled: acc.enabled !== false, }; + }); - // 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; - }; + menuResult = await promptLoginMode(existingAccounts); - // 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}`; - }; + if (menuResult.mode === "check") { + console.log("\n📊 Checking quotas for all accounts...\n"); + const results = await checkAccountsQuota(existingStorage.accounts, client, providerId); + let storageUpdated = false; - // Helper to format reset time with days support - const formatReset = (resetTime?: string): string => { - if (!resetTime) return ''; - const ms = Date.parse(resetTime) - Date.now(); - if (ms <= 0) return ' (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)`; + 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; } - 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); - const modelName = model.modelId.padEnd(29); - console.log(` │ ${connector} ${modelName} ${bar}${reset}`); - }); - } + // ANSI color codes + const colors = { + red: '\x1b[31m', + orange: '\x1b[33m', // Yellow/orange + green: '\x1b[32m', + reset: '\x1b[0m', + }; - // 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); - const modelName = g.name.padEnd(29); - console.log(` ${connector} ${modelName} ${bar}${reset}`); - }); - } - console.log(""); + // 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; + }; - // 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.cachedQuotaUpdatedAt = Date.now(); + // 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): string => { + if (!resetTime) return ''; + const ms = Date.parse(resetTime) - Date.now(); + if (ms <= 0) return ' (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); + const modelName = model.modelId.padEnd(29); + console.log(` │ ${connector} ${modelName} ${bar}${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); + const modelName = g.name.padEnd(29); + console.log(` ${connector} ${modelName} ${bar}${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.cachedQuotaUpdatedAt = Date.now(); + storageUpdated = true; + } + } + + if (res.updatedAccount) { + existingStorage.accounts[res.index] = { + ...res.updatedAccount, + cachedQuota: res.quota?.groups, + cachedQuotaUpdatedAt: Date.now(), + }; storageUpdated = true; } } - - if (res.updatedAccount) { - existingStorage.accounts[res.index] = { - ...res.updatedAccount, - cachedQuota: res.quota?.groups, - cachedQuotaUpdatedAt: Date.now(), - }; - storageUpdated = true; + if (storageUpdated) { + await saveAccounts(existingStorage); } + console.log(""); + continue; } - if (storageUpdated) { - await saveAccounts(existingStorage); - } - console.log(""); - 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); - console.log(`\nAccount ${acc.email || menuResult.toggleAccountIndex + 1} ${acc.enabled ? 'enabled' : 'disabled'}.\n`); + if (menuResult.mode === "manage") { + if (menuResult.toggleAccountIndex !== undefined) { + const acc = existingStorage.accounts[menuResult.toggleAccountIndex]; + if (acc) { + acc.enabled = acc.enabled === false; + await saveAccounts(existingStorage); + console.log(`\nAccount ${acc.email || menuResult.toggleAccountIndex + 1} ${acc.enabled ? 'enabled' : 'disabled'}.\n`); + } } + continue; } - continue; + + break; } - 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 - ); - await saveAccounts({ - version: 3, - accounts: updatedAccounts, - activeIndex: 0, - activeIndexByFamily: { claude: 0, gemini: 0 }, - }); - console.log("\nAccount deleted.\n"); - - if (updatedAccounts.length > 0) { + if (menuResult.mode === "cancel") { return { url: "", - instructions: "Account deleted. Please run `opencode auth login` again to continue.", + instructions: "Authentication cancelled", method: "auto", - callback: async () => ({ type: "failed", error: "Account deleted - please re-run auth" }), + callback: async () => ({ type: "failed", error: "Authentication cancelled" }), }; } - } - 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; - } 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"); + if (menuResult.deleteAccountIndex !== undefined) { + const updatedAccounts = existingStorage.accounts.filter( + (_, idx) => idx !== menuResult.deleteAccountIndex + ); + await saveAccounts({ + version: 3, + accounts: updatedAccounts, + activeIndex: 0, + activeIndexByFamily: { claude: 0, gemini: 0 }, + }); + console.log("\nAccount deleted.\n"); + + if (updatedAccounts.length > 0) { + return { + url: "", + instructions: "Account deleted. Please run `opencode auth login` again to continue.", + method: "auto", + callback: async () => ({ type: "failed", error: "Account deleted - please re-run auth" }), + }; + } + } + + 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; + } 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}) ===`); + while (accounts.length < MAX_OAUTH_ACCOUNTS) { + console.log(`\n=== Antigravity OAuth (Account ${accounts.length + 1}) ===`); - const projectId = await promptProjectId(); + const projectId = await promptProjectId(); - const result = await (async (): Promise => { - const authorization = await authorizeAntigravity(projectId); - const fallbackState = getStateFromAuthorizationUrl(authorization.url); + const result = await (async (): Promise => { + const authorization = await authorizeAntigravity(projectId); + const fallbackState = getStateFromAuthorizationUrl(authorization.url); - console.log("\nOAuth URL:\n" + authorization.url + "\n"); + 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"); + 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); } - return promptManualOAuthInput(fallbackState); - } - let listener: OAuthListener | null = null; - if (!isHeadless) { - try { - listener = await startOAuthListener(); - } catch { - listener = null; + 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) - ); + if (!isHeadless) { + await openBrowser(authorization.url); + } - let callbackUrl: URL; + if (listener) { 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 SOFT_TIMEOUT_MS = 30000; + const callbackPromise = listener.waitForCallback(); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error("SOFT_TIMEOUT")), SOFT_TIMEOUT_MS) + ); - const params = extractOAuthCallbackParams(callbackUrl); - if (!params) { - return { type: "failed", error: "Missing code or state in callback URL" }; - } + 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; + } - return exchangeAntigravity(params.code, params.state); - } catch (error) { - if (error instanceof Error && error.message !== "SOFT_TIMEOUT") { + 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.message, + 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 { - type: "failed", - error: error instanceof Error ? error.message : "Unknown error", + url: "", + instructions: `Authentication failed: ${result.error}`, + method: "auto", + callback: async () => result, }; - } finally { - try { - await listener.close(); - } catch {} } + + console.warn( + `[opencode-antigravity-auth] Skipping failed account ${accounts.length + 1}: ${result.error}`, + ); + break; } - return promptManualOAuthInput(fallbackState); - })(); + accounts.push(result); - if (result.type === "failed") { - if (accounts.length === 0) { - return { - url: "", - instructions: `Authentication failed: ${result.error}`, - method: "auto", - callback: async () => result, - }; + try { + await client.tui.showToast({ + body: { + message: `Account ${accounts.length} authenticated${result.email ? ` (${result.email})` : ""}`, + variant: "success", + }, + }); + } catch { } - console.warn( - `[opencode-antigravity-auth] Skipping failed account ${accounts.length + 1}: ${result.error}`, - ); - break; - } + 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: 3, + accounts: updatedAccounts, + activeIndex: currentStorage.activeIndex, + activeIndexByFamily: currentStorage.activeIndexByFamily, + }); + } + } + } else { + const isFirstAccount = accounts.length === 1; + await persistAccountPool([result], isFirstAccount && startFresh); + } + } catch { + } - accounts.push(result); + if (refreshAccountIndex !== undefined) { + break; + } - try { - await client.tui.showToast({ - body: { - message: `Account ${accounts.length} authenticated${result.email ? ` (${result.email})` : ""}`, - variant: "success", - }, - }); - } catch { - } + if (accounts.length >= MAX_OAUTH_ACCOUNTS) { + break; + } - try { - if (refreshAccountIndex !== undefined) { + // Get the actual deduplicated account count from storage for the prompt + let currentAccountCount = accounts.length; + try { 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: 3, - accounts: updatedAccounts, - activeIndex: currentStorage.activeIndex, - activeIndexByFamily: currentStorage.activeIndexByFamily, - }); - } + currentAccountCount = currentStorage.accounts.length; } - } else { - const isFirstAccount = accounts.length === 1; - await persistAccountPool([result], isFirstAccount && startFresh); + } catch { + // Fall back to accounts.length if we can't read storage } - } catch { - } - if (refreshAccountIndex !== undefined) { - break; + const addAnother = await promptAddAnotherAccount(currentAccountCount); + if (!addAnother) { + break; + } } - if (accounts.length >= MAX_OAUTH_ACCOUNTS) { - break; + const primary = accounts[0]; + if (!primary) { + return { + url: "", + instructions: "Authentication cancelled", + method: "auto", + callback: async () => ({ type: "failed", error: "Authentication cancelled" }), + }; } - // Get the actual deduplicated account count from storage for the prompt - let currentAccountCount = accounts.length; + let actualAccountCount = accounts.length; try { - const currentStorage = await loadAccounts(); - if (currentStorage) { - currentAccountCount = currentStorage.accounts.length; + const finalStorage = await loadAccounts(); + if (finalStorage) { + actualAccountCount = finalStorage.accounts.length; } } catch { - // Fall back to accounts.length if we can't read storage } - const addAnother = await promptAddAnotherAccount(currentAccountCount); - if (!addAnother) { - break; - } - } + const successMessage = refreshAccountIndex !== undefined + ? `Token refreshed successfully.` + : `Multi-account setup complete (${actualAccountCount} account(s)).`; - const primary = accounts[0]; - if (!primary) { return { url: "", - instructions: "Authentication cancelled", + instructions: successMessage, method: "auto", - callback: async () => ({ type: "failed", error: "Authentication cancelled" }), + callback: async (): Promise => primary, }; } - 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 = ""; + // 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; + // Check existing accounts count for toast message + const existingStorage = await loadAccounts(); + const existingCount = existingStorage?.accounts.length ?? 0; - const useManualFlow = isHeadless || shouldSkipLocalServer(); + const useManualFlow = isHeadless || shouldSkipLocalServer(); - let listener: OAuthListener | null = null; - if (!useManualFlow) { - try { - listener = await startOAuthListener(); - } catch { - listener = null; + let listener: OAuthListener | null = null; + if (!useManualFlow) { + try { + listener = await startOAuthListener(); + } catch { + listener = null; + } } - } - const authorization = await authorizeAntigravity(projectId); - const fallbackState = getStateFromAuthorizationUrl(authorization.url); + 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 (!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; + 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 { - 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 callbackPromise = listener.waitForCallback(); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error("CALLBACK_TIMEOUT")), CALLBACK_TIMEOUT_MS), + ); - const result = await exchangeAntigravity(params.code, params.state); - if (result.type === "success") { + let callbackUrl: URL; try { - await persistAccountPool([result], false); - } catch { + 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 newTotal = existingCount + 1; - const toastMessage = existingCount > 0 - ? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total` - : `Authenticated${result.email ? ` (${result.email})` : ""}`; + 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 client.tui.showToast({ - body: { - message: toastMessage, - variant: "success", - }, - }); + await listener.close(); } catch { } } + }, + }; + } - return result; - } catch (error) { - return { - type: "failed", - error: error instanceof Error ? error.message : "Unknown error", - }; - } finally { + 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 { - await listener.close(); + // TUI flow adds to existing accounts (non-destructive) + await persistAccountPool([result], false); } catch { + // ignore } - } - }, - }; - } - - 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})` : ""}`; + // 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 + try { + await client.tui.showToast({ + body: { + message: toastMessage, + variant: "success", + }, + }); + } catch { + // TUI may not be available + } } - } - return result; - }, - }; + return result; + }, + }; + }, }, - }, - { - label: "Manually enter API Key", - type: "api", - }, - ], - }, + { + label: "Manually enter API Key", + type: "api", + }, + ], + }, }; }; @@ -2760,3 +2850,281 @@ export const __testExports = { getHeaderStyleFromUrl, resolveQuotaFallbackHeaderStyle, }; + +/** + * Account error notification service. + * + * Sends notifications (Toast + Telegram) when accounts encounter errors. + * Implements cooldown to prevent notification spam. + */ + +const notificationLog = createLogger("notification"); + +export interface AccountErrorNotification { + /** + * Index of the account in the pool + */ + accountIndex: number; + + /** + * Account email (if available) + */ + accountEmail?: string; + + /** + * The error type/code (e.g., "invalid_grant", "auth-failure") + */ + errorType: string; + + /** + * Detailed error message + */ + errorMessage: string; + + /** + * The HTTP status code (if applicable) + */ + statusCode?: number; + + /** + * The model that was being used (if applicable) + */ + model?: string; + + /** + * Number of remaining valid accounts in the pool + */ + remainingAccounts: number; + + /** + * Timestamp of the error + */ + timestamp: Date; + + /** + * The request/response payload for debugging context + */ + payload?: string; +} + +export interface NotificationConfig { + /** + * Whether notifications are enabled + */ + enabled: boolean; + + /** + * Whether to suppress toast notifications (CLI/quiet mode) + */ + quietMode: boolean; + + /** + * Cooldown period in milliseconds between notifications for the same error type + */ + cooldownMs: number; + + /** + * Telegram configuration (optional) + */ + telegram?: { + botToken?: string; + chatId?: string; + }; +} + +// Cooldown tracking +const notificationCooldowns = new Map(); +const MAX_NOTIFICATION_COOLDOWN_ENTRIES = 100; + +/** + * Clean up old cooldown entries to prevent memory leaks. + */ +function cleanupNotificationCooldowns(cooldownMs: number) { + if (notificationCooldowns.size > MAX_NOTIFICATION_COOLDOWN_ENTRIES) { + const now = Date.now(); + for (const [key, time] of notificationCooldowns) { + if (now - time > cooldownMs * 2) { + notificationCooldowns.delete(key); + } + } + } +} + +/** + * Check if a notification should be sent based on cooldown. + */ +function shouldNotify(notification: AccountErrorNotification, cooldownMs: number): boolean { + if (cooldownMs <= 0) return true; + + cleanupNotificationCooldowns(cooldownMs); + + // Create unique key: error type + account index + const key = `${notification.errorType}:${notification.accountIndex}`; + const lastNotified = notificationCooldowns.get(key) ?? 0; + const now = Date.now(); + + if (now - lastNotified < cooldownMs) { + notificationLog.debug("notification-cooldown", { key, remainingMs: cooldownMs - (now - lastNotified) }); + return false; + } + + notificationCooldowns.set(key, now); + return true; +} + +/** + * Format notification for display. + */ +function formatNotificationMessage(notification: AccountErrorNotification): string { + const accountLabel = notification.accountEmail || `Account #${notification.accountIndex + 1}`; + const timestamp = notification.timestamp.toISOString(); + + let message = `⚠️ Account Error\n`; + message += `━━━━━━━━━━━━━━━━━━━━━━━━\n`; + message += `📧 Account: ${accountLabel}\n`; + message += `❌ Error: ${notification.errorType}\n`; + message += `💬 Message: ${notification.errorMessage.slice(0, 200)}${notification.errorMessage.length > 200 ? "..." : ""}\n`; + + if (notification.statusCode) { + message += `📊 Status: ${notification.statusCode}\n`; + } + + if (notification.model) { + message += `🤖 Model: ${notification.model}\n`; + } + + message += `📋 Remaining: ${notification.remainingAccounts} account(s)\n`; + message += `🕐 Time: ${timestamp}\n`; + + if (notification.payload) { + const truncatedPayload = notification.payload.slice(0, 500); + message += `\n📦 Payload:\n\`\`\`\n${truncatedPayload}${notification.payload.length > 500 ? "\n..." : ""}\n\`\`\``; + } + + return message; +} + +/** + * Format notification for toast (shorter, single line). + */ +function formatToastMessage(notification: AccountErrorNotification): string { + const accountLabel = notification.accountEmail + ? notification.accountEmail.split("@")[0] + : `#${notification.accountIndex + 1}`; + + return `${accountLabel}: ${notification.errorType} - ${notification.remainingAccounts} accounts left`; +} + +/** + * Send notification via Telegram. + */ +export async function sendTelegramMessage( + config: NonNullable, + notification: AccountErrorNotification +): Promise { + const url = `https://api.telegram.org/bot${config.botToken}/sendMessage`; + const message = formatNotificationMessage(notification); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + chat_id: config.chatId, + text: message, + parse_mode: "Markdown", + disable_notification: false, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + notificationLog.error("telegram-send-failed", { + status: response.status, + error: errorText, + }); + return false; + } + + notificationLog.debug("telegram-sent", { + accountIndex: notification.accountIndex, + errorType: notification.errorType, + }); + return true; + } catch (error) { + notificationLog.error("telegram-error", { + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + +/** + * Main notification function. Sends both toast and Telegram if configured. + */ +export async function notifyAccountError( + client: PluginClient | undefined, + config: NotificationConfig, + notification: AccountErrorNotification +): Promise { + if (!config.enabled) { + notificationLog.debug("notifications-disabled"); + return; + } + + // Check cooldown + if (!shouldNotify(notification, config.cooldownMs)) { + return; + } + + notificationLog.info("account-error-notification", { + accountIndex: notification.accountIndex, + accountEmail: notification.accountEmail, + errorType: notification.errorType, + remainingAccounts: notification.remainingAccounts, + }); + + // Send toast notification (unless quiet mode) + if (client && !config.quietMode) { + try { + const toastMessage = formatToastMessage(notification); + await client.tui.showToast({ + body: { + title: "Account Error", + message: toastMessage, + variant: notification.remainingAccounts > 0 ? "warning" : "error", + }, + }); + } catch (error) { + notificationLog.error("toast-error", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + // Send Telegram notification + if (config.telegram?.botToken && config.telegram?.chatId) { + // Fire and forget - don't block on Telegram + sendTelegramMessage(config.telegram, notification).catch((error) => { + notificationLog.error("telegram-async-error", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } +} + +/** + * Reset all notification cooldowns (for testing). + */ +export function resetNotificationCooldowns() { + notificationCooldowns.clear(); +} + +/** + * Get current cooldown state (for testing/debugging). + */ +export function getNotificationCooldownState() { + return new Map(notificationCooldowns); +} diff --git a/src/plugin/accounts.ts b/src/plugin/accounts.ts index bcdd07f..811c7bc 100644 --- a/src/plugin/accounts.ts +++ b/src/plugin/accounts.ts @@ -28,9 +28,9 @@ function updateFingerprintVersion(fingerprint: Fingerprint): Fingerprint { return fingerprint; } -export type RateLimitReason = +export type RateLimitReason = | "QUOTA_EXHAUSTED" - | "RATE_LIMIT_EXCEEDED" + | "RATE_LIMIT_EXCEEDED" | "MODEL_CAPACITY_EXHAUSTED" | "SERVER_ERROR" | "UNKNOWN"; @@ -58,8 +58,8 @@ function generateJitter(maxJitterMs: number): number { } export function parseRateLimitReason( - reason: string | undefined, - message: string | undefined, + reason: string | undefined, + message: string | undefined, status?: number ): RateLimitReason { // 1. Status Code Checks (Rust parity) @@ -76,11 +76,11 @@ export function parseRateLimitReason( case "MODEL_CAPACITY_EXHAUSTED": return "MODEL_CAPACITY_EXHAUSTED"; } } - + // 3. Message Text Scanning (Rust Regex parity) if (message) { const lower = message.toLowerCase(); - + // Capacity / Overloaded (Transient) - Check FIRST before "exhausted" if (lower.includes("capacity") || lower.includes("overloaded") || lower.includes("resource exhausted")) { return "MODEL_CAPACITY_EXHAUSTED"; @@ -98,12 +98,12 @@ export function parseRateLimitReason( return "QUOTA_EXHAUSTED"; } } - + // Default fallback for 429 without clearer info if (status === 429) { - return "UNKNOWN"; + return "UNKNOWN"; } - + return "UNKNOWN"; } @@ -117,7 +117,7 @@ export function calculateBackoffMs( // Rust uses 2s min buffer, we keep 2s return Math.max(retryAfterMs, MIN_BACKOFF_MS); } - + switch (reason) { case "QUOTA_EXHAUSTED": { const index = Math.min(consecutiveFailures, QUOTA_EXHAUSTED_BACKOFFS.length - 1); @@ -196,16 +196,16 @@ function isRateLimitedForFamily(account: ManagedAccount, family: ModelFamily, mo if (family === "claude") { return isRateLimitedForQuotaKey(account, "claude"); } - + const antigravityIsLimited = isRateLimitedForHeaderStyle(account, family, "antigravity", model); const cliIsLimited = isRateLimitedForHeaderStyle(account, family, "gemini-cli", model); - + return antigravityIsLimited && cliIsLimited; } function isRateLimitedForHeaderStyle(account: ManagedAccount, family: ModelFamily, headerStyle: HeaderStyle, model?: string | null): boolean { clearExpiredRateLimits(account); - + if (family === "claude") { return isRateLimitedForQuotaKey(account, "claude"); } @@ -494,7 +494,7 @@ export class AccountManager { } getCurrentOrNextForFamily( - family: ModelFamily, + family: ModelFamily, model?: string | null, strategy: AccountSelectionStrategy = 'sticky', headerStyle: HeaderStyle = 'antigravity', @@ -516,7 +516,7 @@ export class AccountManager { if (strategy === 'hybrid') { const healthTracker = getHealthTracker(); const tokenTracker = getTokenTracker(); - + const accountsWithMetrics: AccountWithMetrics[] = this.accounts .filter(acc => acc.enabled !== false) .map(acc => { @@ -533,7 +533,7 @@ export class AccountManager { // Get current account index for stickiness const currentIndex = this.currentAccountIndexByFamily[family] ?? null; - + const selectedIndex = selectHybridAccount(accountsWithMetrics, tokenTracker, currentIndex); if (selectedIndex !== null) { const selected = this.accounts[selectedIndex]; @@ -635,20 +635,20 @@ export class AccountManager { failureTtlMs: number = 3600_000, // Default 1 hour TTL ): number { const now = nowMs(); - + // TTL-based reset: if last failure was more than failureTtlMs ago, reset count if (account.lastFailureTime !== undefined && (now - account.lastFailureTime) > failureTtlMs) { account.consecutiveFailures = 0; } - + const failures = (account.consecutiveFailures ?? 0) + 1; account.consecutiveFailures = failures; account.lastFailureTime = now; - + const backoffMs = calculateBackoffMs(reason, failures - 1, retryAfterMs); const key = getQuotaKey(family, headerStyle, model); account.rateLimitResetTimes[key] = now + backoffMs; - + return backoffMs; } @@ -709,10 +709,10 @@ export class AccountManager { isFreshForQuota(account: ManagedAccount, quotaKey: string): boolean { const touchedAt = account.touchedForQuota[quotaKey]; if (!touchedAt) return true; - + const resetTime = account.rateLimitResetTimes[quotaKey as QuotaKey]; if (resetTime && touchedAt < resetTime) return true; - + return false; } @@ -720,9 +720,9 @@ export class AccountManager { return this.accounts.filter(acc => { clearExpiredRateLimits(acc); return acc.enabled !== false && - this.isFreshForQuota(acc, quotaKey) && - !isRateLimitedForFamily(acc, family, model) && - !this.isAccountCoolingDown(acc); + this.isFreshForQuota(acc, quotaKey) && + !isRateLimitedForFamily(acc, family, model) && + !this.isAccountCoolingDown(acc); }); } @@ -880,7 +880,7 @@ export class AccountManager { const t1 = a.rateLimitResetTimes[antigravityKey]; const t2 = a.rateLimitResetTimes[cliKey]; - + const accountWait = Math.min( t1 !== undefined ? Math.max(0, t1 - nowMs()) : Infinity, t2 !== undefined ? Math.max(0, t2 - nowMs()) : Infinity @@ -896,10 +896,29 @@ export class AccountManager { return [...this.accounts]; } + async syncEnabledStatusFromDisk(): Promise { + try { + const stored = await loadAccounts(); + if (!stored) return; + + const enabledRefreshTokens = new Set( + stored.accounts + .filter((a) => a.enabled !== false) + .map((a) => a.refreshToken) + ); + + for (const account of this.accounts) { + account.enabled = enabledRefreshTokens.has(account.parts.refreshToken); + } + } catch (error) { + // Ignore errors reading from disk, fallback to in-memory state + } + } + async saveToDisk(): Promise { const claudeIndex = Math.max(0, this.currentAccountIndexByFamily.claude); const geminiIndex = Math.max(0, this.currentAccountIndexByFamily.gemini); - + const storage: AccountStorageV3 = { version: 3, accounts: this.accounts.map((a) => ({ @@ -950,7 +969,7 @@ export class AccountManager { private async executeSave(): Promise { this.savePending = false; this.saveTimeout = null; - + try { await this.saveToDisk(); } catch { @@ -974,7 +993,7 @@ export class AccountManager { regenerateAccountFingerprint(accountIndex: number): Fingerprint | null { const account = this.accounts[accountIndex]; if (!account) return null; - + // Save current fingerprint to history if it exists if (account.fingerprint) { const historyEntry: FingerprintVersion = { @@ -982,14 +1001,14 @@ export class AccountManager { timestamp: nowMs(), reason: 'regenerated', }; - + if (!account.fingerprintHistory) { account.fingerprintHistory = []; } - + // Add to beginning of history (most recent first) account.fingerprintHistory.unshift(historyEntry); - + // Trim to max history size if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) { account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY); @@ -999,7 +1018,7 @@ export class AccountManager { // Generate and assign new fingerprint account.fingerprint = generateFingerprint(); this.requestSaveToDisk(); - + return account.fingerprint; } @@ -1017,10 +1036,10 @@ export class AccountManager { if (!history || historyIndex < 0 || historyIndex >= history.length) { return null; } - + // Capture the fingerprint to restore BEFORE modifying history const fingerprintToRestore = history[historyIndex]!.fingerprint; - + // Save current fingerprint to history before restoring (if it exists) if (account.fingerprint) { const historyEntry: FingerprintVersion = { @@ -1028,9 +1047,9 @@ export class AccountManager { timestamp: nowMs(), reason: 'restored', }; - + account.fingerprintHistory!.unshift(historyEntry); - + // Trim to max history size if (account.fingerprintHistory!.length > MAX_FINGERPRINT_HISTORY) { account.fingerprintHistory = account.fingerprintHistory!.slice(0, MAX_FINGERPRINT_HISTORY); @@ -1039,9 +1058,9 @@ export class AccountManager { // Restore the fingerprint account.fingerprint = { ...fingerprintToRestore, createdAt: nowMs() }; - + this.requestSaveToDisk(); - + return account.fingerprint; } diff --git a/src/plugin/config/schema.ts b/src/plugin/config/schema.ts index e1a3b5a..8e5bd63 100644 --- a/src/plugin/config/schema.ts +++ b/src/plugin/config/schema.ts @@ -47,13 +47,13 @@ export type SchedulingMode = z.infer; export const SignatureCacheConfigSchema = z.object({ /** Enable disk caching of signatures (default: true) */ enabled: z.boolean().default(true), - + /** In-memory TTL in seconds (default: 3600 = 1 hour) */ memory_ttl_seconds: z.number().min(60).max(86400).default(3600), - + /** Disk TTL in seconds (default: 172800 = 48 hours) */ disk_ttl_seconds: z.number().min(3600).max(604800).default(172800), - + /** Background write interval in seconds (default: 60) */ write_interval_seconds: z.number().min(10).max(600).default(60), }); @@ -64,11 +64,11 @@ export const SignatureCacheConfigSchema = z.object({ export const AntigravityConfigSchema = z.object({ /** JSON Schema reference for IDE support */ $schema: z.string().optional(), - + // ========================================================================= // General Settings // ========================================================================= - + /** * Suppress most toast notifications (rate limit, account switching, etc.) * Recovery toasts are always shown regardless of this setting. @@ -76,7 +76,7 @@ export const AntigravityConfigSchema = z.object({ * @default false */ quiet_mode: z.boolean().default(false), - + /** * Control which sessions show toast notifications. * @@ -89,25 +89,25 @@ export const AntigravityConfigSchema = z.object({ * @default "root_only" */ toast_scope: ToastScopeSchema.default('root_only'), - + /** * Enable debug logging to file. * Env override: OPENCODE_ANTIGRAVITY_DEBUG=1 * @default false */ debug: z.boolean().default(false), - + /** * Custom directory for debug logs. * Env override: OPENCODE_ANTIGRAVITY_LOG_DIR=/path/to/logs * @default OS-specific config dir + "/antigravity-logs" */ log_dir: z.string().optional(), - + // ========================================================================= // Thinking Blocks // ========================================================================= - + /** * Preserve thinking blocks for Claude models using signature caching. * @@ -118,11 +118,11 @@ export const AntigravityConfigSchema = z.object({ * @default false */ keep_thinking: z.boolean().default(false), - + // ========================================================================= // Session Recovery // ========================================================================= - + /** * Enable automatic session recovery from tool_result_missing errors. * When enabled, shows a toast notification when recoverable errors occur. @@ -130,7 +130,7 @@ export const AntigravityConfigSchema = z.object({ * @default true */ session_recovery: z.boolean().default(true), - + /** * Automatically send a "continue" prompt after successful recovery. * Only applies when session_recovery is enabled. @@ -141,7 +141,7 @@ export const AntigravityConfigSchema = z.object({ * @default false */ auto_resume: z.boolean().default(false), - + /** * Custom text to send when auto-resuming after recovery. * Only used when auto_resume is enabled. @@ -149,21 +149,21 @@ export const AntigravityConfigSchema = z.object({ * @default "continue" */ resume_text: z.string().default("continue"), - + // ========================================================================= // Signature Caching // ========================================================================= - + /** * Signature cache configuration for persisting thinking block signatures. * Only used when keep_thinking is enabled. */ signature_cache: SignatureCacheConfigSchema.optional(), - + // ========================================================================= // Empty Response Retry (ported from LLM-API-Key-Proxy) // ========================================================================= - + /** * Maximum retry attempts when Antigravity returns an empty response. * Empty responses occur when no candidates/choices are returned. @@ -171,18 +171,18 @@ export const AntigravityConfigSchema = z.object({ * @default 4 */ empty_response_max_attempts: z.number().min(1).max(10).default(4), - + /** * Delay in milliseconds between empty response retries. * * @default 2000 */ empty_response_retry_delay_ms: z.number().min(500).max(10000).default(2000), - + // ========================================================================= // Tool ID Recovery (ported from LLM-API-Key-Proxy) // ========================================================================= - + /** * Enable tool ID orphan recovery. * When tool responses have mismatched IDs (due to context compaction), @@ -191,11 +191,11 @@ export const AntigravityConfigSchema = z.object({ * @default true */ tool_id_recovery: z.boolean().default(true), - + // ========================================================================= // Tool Hallucination Prevention (ported from LLM-API-Key-Proxy) // ========================================================================= - + /** * Enable tool hallucination prevention for Claude models. * When enabled, injects: @@ -208,11 +208,11 @@ export const AntigravityConfigSchema = z.object({ * @default true */ claude_tool_hardening: z.boolean().default(true), - + // ========================================================================= // Proactive Token Refresh (ported from LLM-API-Key-Proxy) // ========================================================================= - + /** * Enable proactive background token refresh. * When enabled, tokens are refreshed in the background before they expire, @@ -221,7 +221,7 @@ export const AntigravityConfigSchema = z.object({ * @default true */ proactive_token_refresh: z.boolean().default(true), - + /** * Seconds before token expiry to trigger proactive refresh. * Default is 30 minutes (1800 seconds). @@ -229,7 +229,7 @@ export const AntigravityConfigSchema = z.object({ * @default 1800 */ proactive_refresh_buffer_seconds: z.number().min(60).max(7200).default(1800), - + /** * Interval between proactive refresh checks in seconds. * Default is 5 minutes (300 seconds). @@ -237,11 +237,11 @@ export const AntigravityConfigSchema = z.object({ * @default 300 */ proactive_refresh_check_interval_seconds: z.number().min(30).max(1800).default(300), - + // ========================================================================= // Rate Limiting // ========================================================================= - + /** * Maximum time in seconds to wait when all accounts are rate-limited. * If the minimum wait time across all accounts exceeds this threshold, @@ -252,7 +252,7 @@ export const AntigravityConfigSchema = z.object({ * @default 300 (5 minutes) */ max_rate_limit_wait_seconds: z.number().min(0).max(3600).default(300), - + /** * Enable quota fallback for Gemini models. * When the preferred quota (gemini-cli or antigravity) is exhausted, @@ -275,14 +275,14 @@ export const AntigravityConfigSchema = z.object({ * @default false */ cli_first: z.boolean().default(false), - + /** * Strategy for selecting accounts when making requests. * Env override: OPENCODE_ANTIGRAVITY_ACCOUNT_SELECTION_STRATEGY * @default "hybrid" */ account_selection_strategy: AccountSelectionStrategySchema.default('hybrid'), - + /** * Enable PID-based account offset for multi-session distribution. * @@ -296,137 +296,174 @@ export const AntigravityConfigSchema = z.object({ * @default false */ pid_offset_enabled: z.boolean().default(false), - - /** - * Switch to another account immediately on first rate limit (after 1s delay). - * When disabled, retries same account first, then switches on second rate limit. - * - * @default true - */ - switch_on_first_rate_limit: z.boolean().default(true), - - /** - * Scheduling mode for rate limit behavior. - * - * - `cache_first`: Wait for same account to recover (preserves prompt cache). Default. - * - `balance`: Switch account immediately on rate limit. Maximum availability. - * - `performance_first`: Round-robin distribution for maximum throughput. - * - * Env override: OPENCODE_ANTIGRAVITY_SCHEDULING_MODE - * @default "cache_first" - */ - scheduling_mode: SchedulingModeSchema.default('cache_first'), - - /** - * Maximum seconds to wait for same account in cache_first mode. - * If the account's rate limit reset time exceeds this, switch accounts. - * - * @default 60 - */ - max_cache_first_wait_seconds: z.number().min(5).max(300).default(60), - - /** - * TTL in seconds for failure count expiration. - * After this period of no failures, consecutiveFailures resets to 0. - * This prevents old failures from permanently penalizing an account. + + /** + * Switch to another account immediately on first rate limit (after 1s delay). + * When disabled, retries same account first, then switches on second rate limit. * - * @default 3600 (1 hour) + * @default true */ - failure_ttl_seconds: z.number().min(60).max(7200).default(3600), - - /** - * Default retry delay in seconds when API doesn't return a retry-after header. - * Lower values allow faster retries but may trigger more 429 errors. - * - * @default 60 - */ - default_retry_after_seconds: z.number().min(1).max(300).default(60), - - /** - * Maximum backoff delay in seconds for exponential retry. - * This caps how long the exponential backoff can grow. - * - * @default 60 - */ - max_backoff_seconds: z.number().min(5).max(300).default(60), - - /** - * Maximum random delay in milliseconds before each API request. - * Adds timing jitter to break predictable request cadence patterns. - * Set to 0 to disable request jitter. - * - * @default 0 - */ - request_jitter_max_ms: z.number().min(0).max(5000).default(0), - - /** - * Soft quota threshold percentage (1-100). - * When an account's quota usage reaches this percentage, skip it during - * account selection (same as if it were rate-limited). - * - * Example: 90 means skip account when 90% of quota is used (10% remaining). - * Set to 100 to disable soft quota protection. - * - * @default 90 - */ - soft_quota_threshold_percent: z.number().min(1).max(100).default(90), - - /** - * How often to refresh quota data in the background (in minutes). - * Quota is refreshed opportunistically after successful API requests. - * Set to 0 to disable automatic refresh (manual only via Check quotas). - * - * @default 15 - */ - quota_refresh_interval_minutes: z.number().min(0).max(60).default(15), - - /** - * How long quota cache is considered fresh for threshold checks (in minutes). - * After this time, cache is stale and account is allowed (fail-open). - * - * "auto" = derive from refresh interval: max(2 * refresh_interval, 10) - * - * @default "auto" - */ - soft_quota_cache_ttl_minutes: z.union([ - z.literal("auto"), - z.number().min(1).max(120) - ]).default("auto"), - - // ========================================================================= - // Health Score (used by hybrid strategy) - // ========================================================================= - - health_score: z.object({ - initial: z.number().min(0).max(100).default(70), - success_reward: z.number().min(0).max(10).default(1), - rate_limit_penalty: z.number().min(-50).max(0).default(-10), - failure_penalty: z.number().min(-100).max(0).default(-20), - recovery_rate_per_hour: z.number().min(0).max(20).default(2), - min_usable: z.number().min(0).max(100).default(50), - max_score: z.number().min(50).max(100).default(100), - }).optional(), - - // ========================================================================= - // Token Bucket (for hybrid strategy) - // ========================================================================= - - token_bucket: z.object({ - max_tokens: z.number().min(1).max(1000).default(50), - regeneration_rate_per_minute: z.number().min(0.1).max(60).default(6), - initial_tokens: z.number().min(1).max(1000).default(50), - }).optional(), - - // ========================================================================= - // Auto-Update + switch_on_first_rate_limit: z.boolean().default(true), + + /** + * Scheduling mode for rate limit behavior. + * + * - `cache_first`: Wait for same account to recover (preserves prompt cache). Default. + * - `balance`: Switch account immediately on rate limit. Maximum availability. + * - `performance_first`: Round-robin distribution for maximum throughput. + * + * Env override: OPENCODE_ANTIGRAVITY_SCHEDULING_MODE + * @default "cache_first" + */ + scheduling_mode: SchedulingModeSchema.default('cache_first'), + + /** + * Maximum seconds to wait for same account in cache_first mode. + * If the account's rate limit reset time exceeds this, switch accounts. + * + * @default 60 + */ + max_cache_first_wait_seconds: z.number().min(5).max(300).default(60), + + /** + * TTL in seconds for failure count expiration. + * After this period of no failures, consecutiveFailures resets to 0. + * This prevents old failures from permanently penalizing an account. + * + * @default 3600 (1 hour) + */ + failure_ttl_seconds: z.number().min(60).max(7200).default(3600), + + /** + * Default retry delay in seconds when API doesn't return a retry-after header. + * Lower values allow faster retries but may trigger more 429 errors. + * + * @default 60 + */ + default_retry_after_seconds: z.number().min(1).max(300).default(60), + + /** + * Maximum backoff delay in seconds for exponential retry. + * This caps how long the exponential backoff can grow. + * + * @default 60 + */ + max_backoff_seconds: z.number().min(5).max(300).default(60), + + /** + * Maximum random delay in milliseconds before each API request. + * Adds timing jitter to break predictable request cadence patterns. + * Set to 0 to disable request jitter. + * + * @default 0 + */ + request_jitter_max_ms: z.number().min(0).max(5000).default(0), + + /** + * Soft quota threshold percentage (1-100). + * When an account's quota usage reaches this percentage, skip it during + * account selection (same as if it were rate-limited). + * + * Example: 90 means skip account when 90% of quota is used (10% remaining). + * Set to 100 to disable soft quota protection. + * + * @default 90 + */ + soft_quota_threshold_percent: z.number().min(1).max(100).default(90), + + /** + * How often to refresh quota data in the background (in minutes). + * Quota is refreshed opportunistically after successful API requests. + * Set to 0 to disable automatic refresh (manual only via Check quotas). + * + * @default 15 + */ + quota_refresh_interval_minutes: z.number().min(0).max(60).default(15), + + /** + * How long quota cache is considered fresh for threshold checks (in minutes). + * After this time, cache is stale and account is allowed (fail-open). + * + * "auto" = derive from refresh interval: max(2 * refresh_interval, 10) + * + * @default "auto" + */ + soft_quota_cache_ttl_minutes: z.union([ + z.literal("auto"), + z.number().min(1).max(120) + ]).default("auto"), + + // ========================================================================= + // Health Score (used by hybrid strategy) + // ========================================================================= + + health_score: z.object({ + initial: z.number().min(0).max(100).default(70), + success_reward: z.number().min(0).max(10).default(1), + rate_limit_penalty: z.number().min(-50).max(0).default(-10), + failure_penalty: z.number().min(-100).max(0).default(-20), + recovery_rate_per_hour: z.number().min(0).max(20).default(2), + min_usable: z.number().min(0).max(100).default(50), + max_score: z.number().min(50).max(100).default(100), + }).optional(), + + // ========================================================================= + // Token Bucket (for hybrid strategy) // ========================================================================= - + + token_bucket: z.object({ + max_tokens: z.number().min(1).max(1000).default(50), + regeneration_rate_per_minute: z.number().min(0.1).max(60).default(6), + initial_tokens: z.number().min(1).max(1000).default(50), + }).optional(), + + // ========================================================================= + // Auto-Update + // ========================================================================= + /** * Enable automatic plugin updates. * @default true */ auto_update: z.boolean().default(true), + // ========================================================================= + // Error Notifications + // ========================================================================= + + /** + * Enable notifications when an account encounters an error. + * Shows toast notification and optionally sends Telegram message. + * + * @default true + */ + notify_on_account_error: z.boolean().default(true), + + /** + * Telegram bot token for remote error notifications. + * Create a bot via @BotFather on Telegram to get this. + * + * Env override: OPENCODE_ANTIGRAVITY_TELEGRAM_BOT_TOKEN + */ + telegram_bot_token: z.string().optional(), + + /** + * Your Telegram chat ID for receiving notifications. + * Use @userinfobot on Telegram to find your chat ID. + * + * Env override: OPENCODE_ANTIGRAVITY_TELEGRAM_CHAT_ID + */ + telegram_chat_id: z.string().optional(), + + /** + * Cooldown in seconds between notifications of the same error type. + * Prevents notification spam when multiple errors occur quickly. + * Set to 0 to disable cooldown (receive all notifications). + * + * @default 60 (1 minute) + */ + notification_cooldown_seconds: z.number().min(0).max(300).default(60), + }); export type AntigravityConfig = z.infer; @@ -466,6 +503,8 @@ export const DEFAULT_CONFIG: AntigravityConfig = { quota_refresh_interval_minutes: 15, soft_quota_cache_ttl_minutes: "auto", auto_update: true, + notify_on_account_error: true, + notification_cooldown_seconds: 60, signature_cache: { enabled: true, memory_ttl_seconds: 3600, diff --git a/src/plugin/request.ts b/src/plugin/request.ts index d5a8c6f..bd3404e 100644 --- a/src/plugin/request.ts +++ b/src/plugin/request.ts @@ -1506,6 +1506,7 @@ export async function transformAntigravityResponse( toolDebugSummary?: string, toolDebugPayload?: string, debugLines?: string[], + account?: string, ): Promise { const contentType = response.headers.get("content-type") ?? ""; const isJsonResponse = contentType.includes("application/json"); @@ -1577,9 +1578,9 @@ export async function transformAntigravityResponse( // Inject Debug Info if (errorBody?.error) { - const debugInfo = `\n\n[Debug Info]\nRequested Model: ${requestedModel || "Unknown"}\nEffective Model: ${effectiveModel || "Unknown"}\nProject: ${projectId || "Unknown"}\nEndpoint: ${endpoint || "Unknown"}\nStatus: ${response.status}\nRequest ID: ${headers.get("x-request-id") || "N/A"}${toolDebugMissing !== undefined ? `\nTool Debug Missing: ${toolDebugMissing}` : ""}${toolDebugSummary ? `\nTool Debug Summary: ${toolDebugSummary}` : ""}${toolDebugPayload ? `\nTool Debug Payload: ${toolDebugPayload}` : ""}`; + const debugInfo = `\n\n[Debug Info]\nAccount: ${account || "Unknown"}\nRequested Model: ${requestedModel || "Unknown"}\nEffective Model: ${effectiveModel || "Unknown"}\nProject: ${projectId || "Unknown"}\nEndpoint: ${endpoint || "Unknown"}\nStatus: ${response.status}\nRequest ID: ${headers.get("x-request-id") || "N/A"}${toolDebugMissing !== undefined ? `\nTool Debug Missing: ${toolDebugMissing}` : ""}${toolDebugSummary ? `\nTool Debug Summary: ${toolDebugSummary}` : ""}${toolDebugPayload ? `\nTool Debug Payload: ${toolDebugPayload}\nAccount: ${account || "Unknown"}` : ""}`; const injectedDebug = debugText ? `\n\n${debugText}` : ""; - errorBody.error.message = (errorBody.error.message || "Unknown error") + debugInfo + injectedDebug; + errorBody.error.message = (errorBody.error.message || "Unknown error") + injectedDebug + debugInfo; // Check if this is a recoverable thinking error - throw to trigger retry const errorType = detectErrorType(errorBody.error.message || ""); @@ -1650,7 +1651,7 @@ export async function transformAntigravityResponse( const effectiveBody = patched ?? parsed ?? undefined; const usage = usageFromSse ?? (effectiveBody ? extractUsageMetadata(effectiveBody) : null); - + // Log cache stats when available if (usage && effectiveModel) { logCacheStats( @@ -1660,7 +1661,7 @@ export async function transformAntigravityResponse( usage.promptTokenCount ?? usage.totalTokenCount ?? 0, ); } - + if (usage?.cachedContentTokenCount !== undefined) { headers.set("x-antigravity-cached-content-token-count", String(usage.cachedContentTokenCount)); if (usage.totalTokenCount !== undefined) {