import { exec } from "node:child_process"; import { tool } from "@opencode-ai/plugin"; import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_PROVIDER_ID, type HeaderStyle } from "./constants"; import { authorizeAntigravity, exchangeAntigravity } from "./antigravity/oauth"; import type { AntigravityTokenExchangeResult } from "./antigravity/oauth"; import { accessTokenExpired, isOAuthAuth, parseRefreshParts } from "./plugin/auth"; import { promptAddAnotherAccount, promptLoginMode, promptProjectId } from "./plugin/cli"; import { ensureProjectContext } from "./plugin/project"; import { startAntigravityDebugRequest, logAntigravityDebugResponse, logAccountContext, logRateLimitEvent, logRateLimitSnapshot, logResponseBody, logModelFamily, isDebugEnabled, getLogFilePath, initializeDebug, } from "./plugin/debug"; import { buildThinkingWarmupBody, isGenerativeLanguageRequest, prepareAntigravityRequest, transformAntigravityResponse, } from "./plugin/request"; import { resolveModelWithTier } from "./plugin/transform/model-resolver"; import { isEmptyResponseBody, createSyntheticErrorResponse, } from "./plugin/request-helpers"; import { EmptyResponseError } from "./plugin/errors"; import { AntigravityTokenRefreshError, refreshAccessToken } from "./plugin/token"; import { startOAuthListener, type OAuthListener } from "./plugin/server"; import { clearAccounts, loadAccounts, saveAccounts } from "./plugin/storage"; import { AccountManager, type ModelFamily, parseRateLimitReason, calculateBackoffMs } from "./plugin/accounts"; import { createAutoUpdateCheckerHook } from "./hooks/auto-update-checker"; import { loadConfig, initRuntimeConfig, type AntigravityConfig } from "./plugin/config"; import { createSessionRecoveryHook, getRecoverySuccessToast } from "./plugin/recovery"; import { checkAccountsQuota } from "./plugin/quota"; import { initDiskSignatureCache } from "./plugin/cache"; import { createProactiveRefreshQueue, type ProactiveRefreshQueue } from "./plugin/refresh-queue"; import { initLogger, createLogger } from "./plugin/logger"; import { initHealthTracker, getHealthTracker, initTokenTracker, getTokenTracker } from "./plugin/rotation"; import { executeSearch } from "./plugin/search"; import type { GetAuth, LoaderResult, PluginContext, PluginResult, ProjectContextResult, Provider, } from "./plugin/types"; const MAX_OAUTH_ACCOUNTS = 10; const MAX_WARMUP_SESSIONS = 1000; const MAX_WARMUP_RETRIES = 2; const CAPACITY_BACKOFF_TIERS_MS = [5000, 10000, 20000, 30000, 60000]; function getCapacityBackoffDelay(consecutiveFailures: number): number { const index = Math.min(consecutiveFailures, CAPACITY_BACKOFF_TIERS_MS.length - 1); return CAPACITY_BACKOFF_TIERS_MS[Math.max(0, index)] ?? 5000; } const warmupAttemptedSessionIds = new Set(); const warmupSucceededSessionIds = new Set(); const log = createLogger("plugin"); // Module-level toast debounce to persist across requests (fixes toast spam) const rateLimitToastCooldowns = new Map(); const RATE_LIMIT_TOAST_COOLDOWN_MS = 5000; const MAX_TOAST_COOLDOWN_ENTRIES = 100; // Track if "all accounts rate-limited" toast was shown to prevent spam in while loop let allAccountsRateLimitedToastShown = false; function cleanupToastCooldowns(): void { if (rateLimitToastCooldowns.size > MAX_TOAST_COOLDOWN_ENTRIES) { const now = Date.now(); for (const [key, time] of rateLimitToastCooldowns) { if (now - time > RATE_LIMIT_TOAST_COOLDOWN_MS * 2) { rateLimitToastCooldowns.delete(key); } } } } function shouldShowRateLimitToast(message: string): boolean { cleanupToastCooldowns(); const toastKey = message.replace(/\d+/g, "X"); const lastShown = rateLimitToastCooldowns.get(toastKey) ?? 0; const now = Date.now(); if (now - lastShown < RATE_LIMIT_TOAST_COOLDOWN_MS) { return false; } rateLimitToastCooldowns.set(toastKey, now); return true; } function resetAllAccountsRateLimitedToast(): void { allAccountsRateLimitedToastShown = false; } function trackWarmupAttempt(sessionId: string): boolean { if (warmupSucceededSessionIds.has(sessionId)) { return false; } if (warmupAttemptedSessionIds.size >= MAX_WARMUP_SESSIONS) { const first = warmupAttemptedSessionIds.values().next().value; if (first) { warmupAttemptedSessionIds.delete(first); warmupSucceededSessionIds.delete(first); } } const attempts = getWarmupAttemptCount(sessionId); if (attempts >= MAX_WARMUP_RETRIES) { return false; } warmupAttemptedSessionIds.add(sessionId); return true; } function getWarmupAttemptCount(sessionId: string): number { return warmupAttemptedSessionIds.has(sessionId) ? 1 : 0; } function markWarmupSuccess(sessionId: string): void { warmupSucceededSessionIds.add(sessionId); if (warmupSucceededSessionIds.size >= MAX_WARMUP_SESSIONS) { const first = warmupSucceededSessionIds.values().next().value; if (first) warmupSucceededSessionIds.delete(first); } } function clearWarmupAttempt(sessionId: string): void { warmupAttemptedSessionIds.delete(sessionId); } function isWSL(): boolean { if (process.platform !== "linux") return false; try { const { readFileSync } = require("node:fs"); const release = readFileSync("/proc/version", "utf8").toLowerCase(); return release.includes("microsoft") || release.includes("wsl"); } catch { return false; } } function isWSL2(): boolean { if (!isWSL()) return false; try { const { readFileSync } = require("node:fs"); const version = readFileSync("/proc/version", "utf8").toLowerCase(); return version.includes("wsl2") || version.includes("microsoft-standard"); } catch { return false; } } function isRemoteEnvironment(): boolean { if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) { return true; } if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) { return true; } if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && !isWSL()) { return true; } return false; } function shouldSkipLocalServer(): boolean { return isWSL2() || isRemoteEnvironment(); } async function openBrowser(url: string): Promise { try { if (process.platform === "darwin") { exec(`open "${url}"`); return true; } if (process.platform === "win32") { exec(`start "" "${url}"`); return true; } if (isWSL()) { try { exec(`wslview "${url}"`); return true; } catch {} } if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { return false; } exec(`xdg-open "${url}"`); return true; } catch { return false; } } async function promptOAuthCallbackValue(message: string): Promise { const { createInterface } = await import("node:readline/promises"); const { stdin, stdout } = await import("node:process"); const rl = createInterface({ input: stdin, output: stdout }); try { return (await rl.question(message)).trim(); } finally { rl.close(); } } type OAuthCallbackParams = { code: string; state: string }; function getStateFromAuthorizationUrl(authorizationUrl: string): string { try { return new URL(authorizationUrl).searchParams.get("state") ?? ""; } catch { return ""; } } function extractOAuthCallbackParams(url: URL): OAuthCallbackParams | null { const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); if (!code || !state) { return null; } return { code, state }; } function parseOAuthCallbackInput( value: string, fallbackState: string, ): OAuthCallbackParams | { error: string } { const trimmed = value.trim(); if (!trimmed) { return { error: "Missing authorization code" }; } try { const url = new URL(trimmed); const code = url.searchParams.get("code"); const state = url.searchParams.get("state") ?? fallbackState; if (!code) { return { error: "Missing code in callback URL" }; } if (!state) { return { error: "Missing state in callback URL" }; } return { code, state }; } catch { if (!fallbackState) { return { error: "Missing state. Paste the full redirect URL instead of only the code." }; } return { code: trimmed, state: fallbackState }; } } async function promptManualOAuthInput( fallbackState: string, ): Promise { console.log("1. Open the URL above in your browser and complete Google sign-in."); console.log("2. After approving, copy the full redirected localhost URL from the address bar."); console.log("3. Paste it back here.\n"); const callbackInput = await promptOAuthCallbackValue( "Paste the redirect URL (or just the code) here: ", ); const params = parseOAuthCallbackInput(callbackInput, fallbackState); if ("error" in params) { return { type: "failed", error: params.error }; } return exchangeAntigravity(params.code, params.state); } function clampInt(value: number, min: number, max: number): number { if (!Number.isFinite(value)) { return min; } return Math.min(max, Math.max(min, Math.floor(value))); } async function persistAccountPool( results: Array>, replaceAll: boolean = false, ): Promise { if (results.length === 0) { return; } const now = Date.now(); // If replaceAll is true (fresh login), start with empty accounts // Otherwise, load existing accounts and merge const stored = replaceAll ? null : await loadAccounts(); const accounts = stored?.accounts ? [...stored.accounts] : []; const indexByRefreshToken = new Map(); const indexByEmail = new Map(); for (let i = 0; i < accounts.length; i++) { const acc = accounts[i]; if (acc?.refreshToken) { indexByRefreshToken.set(acc.refreshToken, i); } if (acc?.email) { indexByEmail.set(acc.email, i); } } for (const result of results) { const parts = parseRefreshParts(result.refresh); if (!parts.refreshToken) { continue; } // First, check for existing account by email (prevents duplicates when refresh token changes) // Only use email-based deduplication if the new account has an email const existingByEmail = result.email ? indexByEmail.get(result.email) : undefined; const existingByToken = indexByRefreshToken.get(parts.refreshToken); // Prefer email-based match to handle refresh token rotation const existingIndex = existingByEmail ?? existingByToken; if (existingIndex === undefined) { // New account - add it const newIndex = accounts.length; indexByRefreshToken.set(parts.refreshToken, newIndex); if (result.email) { indexByEmail.set(result.email, newIndex); } accounts.push({ email: result.email, refreshToken: parts.refreshToken, projectId: parts.projectId, managedProjectId: parts.managedProjectId, addedAt: now, lastUsed: now, enabled: true, }); continue; } const existing = accounts[existingIndex]; if (!existing) { continue; } // Update existing account (this handles both email match and token match cases) // When email matches but token differs, this effectively replaces the old token const oldToken = existing.refreshToken; accounts[existingIndex] = { ...existing, email: result.email ?? existing.email, refreshToken: parts.refreshToken, projectId: parts.projectId ?? existing.projectId, managedProjectId: parts.managedProjectId ?? existing.managedProjectId, lastUsed: now, }; // Update the token index if the token changed if (oldToken !== parts.refreshToken) { indexByRefreshToken.delete(oldToken); indexByRefreshToken.set(parts.refreshToken, existingIndex); } } if (accounts.length === 0) { return; } // For fresh logins, always start at index 0 const activeIndex = replaceAll ? 0 : (typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex) ? stored.activeIndex : 0); await saveAccounts({ version: 3, accounts, activeIndex: clampInt(activeIndex, 0, accounts.length - 1), activeIndexByFamily: { claude: clampInt(activeIndex, 0, accounts.length - 1), gemini: clampInt(activeIndex, 0, accounts.length - 1), }, }); } function retryAfterMsFromResponse(response: Response, defaultRetryMs: number = 60_000): number { const retryAfterMsHeader = response.headers.get("retry-after-ms"); if (retryAfterMsHeader) { const parsed = Number.parseInt(retryAfterMsHeader, 10); if (!Number.isNaN(parsed) && parsed > 0) { return parsed; } } const retryAfterHeader = response.headers.get("retry-after"); if (retryAfterHeader) { const parsed = Number.parseInt(retryAfterHeader, 10); if (!Number.isNaN(parsed) && parsed > 0) { return parsed * 1000; } } return defaultRetryMs; } /** * Parse Go-style duration strings to milliseconds. * Supports compound durations: "1h16m0.667s", "1.5s", "200ms", "5m30s" * * @param duration - Duration string in Go format * @returns Duration in milliseconds, or null if parsing fails */ function parseDurationToMs(duration: string): number | null { // Handle simple formats first for backwards compatibility const simpleMatch = duration.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i); if (simpleMatch) { const value = parseFloat(simpleMatch[1]!); const unit = (simpleMatch[2] || "s").toLowerCase(); switch (unit) { case "h": return value * 3600 * 1000; case "m": return value * 60 * 1000; case "s": return value * 1000; case "ms": return value; default: return value * 1000; } } // Parse compound Go-style durations: "1h16m0.667s", "5m30s", etc. const compoundRegex = /(\d+(?:\.\d+)?)(h|m(?!s)|s|ms)/gi; let totalMs = 0; let matchFound = false; let match; while ((match = compoundRegex.exec(duration)) !== null) { matchFound = true; const value = parseFloat(match[1]!); const unit = match[2]!.toLowerCase(); switch (unit) { case "h": totalMs += value * 3600 * 1000; break; case "m": totalMs += value * 60 * 1000; break; case "s": totalMs += value * 1000; break; case "ms": totalMs += value; break; } } return matchFound ? totalMs : null; } interface RateLimitBodyInfo { retryDelayMs: number | null; message?: string; quotaResetTime?: string; reason?: string; } function extractRateLimitBodyInfo(body: unknown): RateLimitBodyInfo { if (!body || typeof body !== "object") { return { retryDelayMs: null }; } const error = (body as { error?: unknown }).error; const message = error && typeof error === "object" ? (error as { message?: string }).message : undefined; const details = error && typeof error === "object" ? (error as { details?: unknown[] }).details : undefined; let reason: string | undefined; if (Array.isArray(details)) { for (const detail of details) { if (!detail || typeof detail !== "object") continue; const type = (detail as { "@type"?: string })["@type"]; if (typeof type === "string" && type.includes("google.rpc.ErrorInfo")) { const detailReason = (detail as { reason?: string }).reason; if (typeof detailReason === "string") { reason = detailReason; break; } } } for (const detail of details) { if (!detail || typeof detail !== "object") continue; const type = (detail as { "@type"?: string })["@type"]; if (typeof type === "string" && type.includes("google.rpc.RetryInfo")) { const retryDelay = (detail as { retryDelay?: string }).retryDelay; if (typeof retryDelay === "string") { const retryDelayMs = parseDurationToMs(retryDelay); if (retryDelayMs !== null) { return { retryDelayMs, message, reason }; } } } } for (const detail of details) { if (!detail || typeof detail !== "object") continue; const metadata = (detail as { metadata?: Record }).metadata; if (metadata && typeof metadata === "object") { const quotaResetDelay = metadata.quotaResetDelay; const quotaResetTime = metadata.quotaResetTimeStamp; if (typeof quotaResetDelay === "string") { const quotaResetDelayMs = parseDurationToMs(quotaResetDelay); if (quotaResetDelayMs !== null) { return { retryDelayMs: quotaResetDelayMs, message, quotaResetTime, reason }; } } } } } if (message) { const afterMatch = message.match(/reset after\s+([0-9hms.]+)/i); const rawDuration = afterMatch?.[1]; if (rawDuration) { const parsed = parseDurationToMs(rawDuration); if (parsed !== null) { return { retryDelayMs: parsed, message, reason }; } } } return { retryDelayMs: null, message, reason }; } async function extractRetryInfoFromBody(response: Response): Promise { try { const text = await response.clone().text(); try { const parsed = JSON.parse(text) as unknown; return extractRateLimitBodyInfo(parsed); } catch { return { retryDelayMs: null }; } } catch { return { retryDelayMs: null }; } } function formatWaitTime(ms: number): string { if (ms < 1000) return `${ms}ms`; const seconds = Math.ceil(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; if (minutes < 60) { return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; } const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; } // Progressive rate limit retry delays const FIRST_RETRY_DELAY_MS = 1000; // 1s - first 429 quick retry on same account const SWITCH_ACCOUNT_DELAY_MS = 5000; // 5s - delay before switching to another account /** * Rate limit state tracking with time-window deduplication. * * Problem: When multiple subagents hit 429 simultaneously, each would increment * the consecutive counter, causing incorrect exponential backoff (5 concurrent * 429s = 2^5 backoff instead of 2^1). * * Solution: Track per account+quota with deduplication window. Multiple 429s * within RATE_LIMIT_DEDUP_WINDOW_MS are treated as a single event. */ const RATE_LIMIT_DEDUP_WINDOW_MS = 2000; // 2 seconds - concurrent requests within this window are deduplicated const RATE_LIMIT_STATE_RESET_MS = 120_000; // Reset consecutive counter after 2 minutes of no 429s interface RateLimitState { consecutive429: number; lastAt: number; quotaKey: string; // Track which quota this state is for } // Key format: `${accountIndex}:${quotaKey}` for per-account-per-quota tracking const rateLimitStateByAccountQuota = new Map(); // Track empty response retry attempts (ported from LLM-API-Key-Proxy) const emptyResponseAttempts = new Map(); /** * Get rate limit backoff with time-window deduplication. * * @param accountIndex - The account index * @param quotaKey - The quota key (e.g., "gemini-cli", "gemini-antigravity", "claude") * @param serverRetryAfterMs - Server-provided retry delay (if any) * @param maxBackoffMs - Maximum backoff delay in milliseconds (default 60000) * @returns { attempt, delayMs, isDuplicate } - isDuplicate=true if within dedup window */ function getRateLimitBackoff( accountIndex: number, quotaKey: string, serverRetryAfterMs: number | null, maxBackoffMs: number = 60_000 ): { attempt: number; delayMs: number; isDuplicate: boolean } { const now = Date.now(); const stateKey = `${accountIndex}:${quotaKey}`; const previous = rateLimitStateByAccountQuota.get(stateKey); // Check if this is a duplicate 429 within the dedup window if (previous && (now - previous.lastAt < RATE_LIMIT_DEDUP_WINDOW_MS)) { // Same rate limit event from concurrent request - don't increment const baseDelay = serverRetryAfterMs ?? 1000; const backoffDelay = Math.min(baseDelay * Math.pow(2, previous.consecutive429 - 1), maxBackoffMs); return { attempt: previous.consecutive429, delayMs: Math.max(baseDelay, backoffDelay), isDuplicate: true }; } // Check if we should reset (no 429 for 2 minutes) or increment const attempt = previous && (now - previous.lastAt < RATE_LIMIT_STATE_RESET_MS) ? previous.consecutive429 + 1 : 1; rateLimitStateByAccountQuota.set(stateKey, { consecutive429: attempt, lastAt: now, quotaKey }); const baseDelay = serverRetryAfterMs ?? 1000; const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxBackoffMs); return { attempt, delayMs: Math.max(baseDelay, backoffDelay), isDuplicate: false }; } /** * Reset rate limit state for an account+quota combination. * Only resets the specific quota, not all quotas for the account. */ function resetRateLimitState(accountIndex: number, quotaKey: string): void { const stateKey = `${accountIndex}:${quotaKey}`; rateLimitStateByAccountQuota.delete(stateKey); } /** * Reset all rate limit state for an account (all quotas). * Used when account is completely healthy. */ function resetAllRateLimitStateForAccount(accountIndex: number): void { for (const key of rateLimitStateByAccountQuota.keys()) { if (key.startsWith(`${accountIndex}:`)) { rateLimitStateByAccountQuota.delete(key); } } } function headerStyleToQuotaKey(headerStyle: HeaderStyle, family: ModelFamily): string { if (family === "claude") return "claude"; return headerStyle === "antigravity" ? "gemini-antigravity" : "gemini-cli"; } // Track consecutive non-429 failures per account to prevent infinite loops const accountFailureState = new Map(); const MAX_CONSECUTIVE_FAILURES = 5; const FAILURE_COOLDOWN_MS = 30_000; // 30 seconds cooldown after max failures const FAILURE_STATE_RESET_MS = 120_000; // Reset failure count after 2 minutes of no failures function trackAccountFailure(accountIndex: number): { failures: number; shouldCooldown: boolean; cooldownMs: number } { const now = Date.now(); const previous = accountFailureState.get(accountIndex); // Reset if last failure was more than 2 minutes ago const failures = previous && (now - previous.lastFailureAt < FAILURE_STATE_RESET_MS) ? previous.consecutiveFailures + 1 : 1; accountFailureState.set(accountIndex, { consecutiveFailures: failures, lastFailureAt: now }); const shouldCooldown = failures >= MAX_CONSECUTIVE_FAILURES; const cooldownMs = shouldCooldown ? FAILURE_COOLDOWN_MS : 0; return { failures, shouldCooldown, cooldownMs }; } function resetAccountFailureState(accountIndex: number): void { accountFailureState.delete(accountIndex); } /** * Sleep for a given number of milliseconds, respecting an abort signal. */ function sleep(ms: number, signal?: AbortSignal | null): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(signal.reason instanceof Error ? signal.reason : new Error("Aborted")); return; } const timeout = setTimeout(() => { cleanup(); resolve(); }, ms); const onAbort = () => { cleanup(); reject(signal?.reason instanceof Error ? signal.reason : new Error("Aborted")); }; const cleanup = () => { clearTimeout(timeout); signal?.removeEventListener("abort", onAbort); }; signal?.addEventListener("abort", onAbort, { once: true }); }); } /** * Creates an Antigravity OAuth plugin for a specific provider ID. */ export const createAntigravityPlugin = (providerId: string) => async ( { client, directory }: PluginContext, ): Promise => { // Load configuration from files and environment variables const config = loadConfig(directory); initRuntimeConfig(config); // Cached getAuth function for tool access let cachedGetAuth: GetAuth | null = null; // Initialize debug with config initializeDebug(config); // Initialize structured logger for TUI integration initLogger(client); // Initialize health tracker for hybrid strategy if (config.health_score) { initHealthTracker({ initial: config.health_score.initial, successReward: config.health_score.success_reward, rateLimitPenalty: config.health_score.rate_limit_penalty, failurePenalty: config.health_score.failure_penalty, recoveryRatePerHour: config.health_score.recovery_rate_per_hour, minUsable: config.health_score.min_usable, maxScore: config.health_score.max_score, }); } // Initialize token tracker for hybrid strategy if (config.token_bucket) { initTokenTracker({ maxTokens: config.token_bucket.max_tokens, regenerationRatePerMinute: config.token_bucket.regeneration_rate_per_minute, initialTokens: config.token_bucket.initial_tokens, }); } // Initialize disk signature cache if keep_thinking is enabled // This integrates with the in-memory cacheSignature/getCachedSignature functions if (config.keep_thinking) { initDiskSignatureCache(config.signature_cache); } // Initialize session recovery hook with full context const sessionRecovery = createSessionRecoveryHook({ client, directory }, config); const updateChecker = createAutoUpdateCheckerHook(client, directory, { showStartupToast: true, autoUpdate: config.auto_update, }); // Event handler for session recovery and updates const eventHandler = async (input: { event: { type: string; properties?: unknown } }) => { // Forward to update checker await updateChecker.event(input); // Handle session recovery if (sessionRecovery && input.event.type === "session.error") { const props = input.event.properties as Record | undefined; const sessionID = props?.sessionID as string | undefined; const messageID = props?.messageID as string | undefined; const error = props?.error; if (sessionRecovery.isRecoverableError(error)) { const messageInfo = { id: messageID, role: "assistant" as const, sessionID, error, }; // handleSessionRecovery now does the actual fix (injects tool_result, etc.) const recovered = await sessionRecovery.handleSessionRecovery(messageInfo); // Only send "continue" AFTER successful tool_result_missing recovery // (thinking recoveries already resume inside handleSessionRecovery) if (recovered && sessionID && config.auto_resume) { // For tool_result_missing, we need to send continue after injecting tool_results await client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text: config.resume_text }] }, query: { directory }, }).catch(() => {}); // Show success toast const successToast = getRecoverySuccessToast(); await client.tui.showToast({ body: { title: successToast.title, message: successToast.message, variant: "success", }, }).catch(() => {}); } } } }; // Create google_search tool with access to auth context const googleSearchTool = tool({ description: "Search the web using Google Search and analyze URLs. Returns real-time information from the internet with source citations. Use this when you need up-to-date information about current events, recent developments, or any topic that may have changed. You can also provide specific URLs to analyze. IMPORTANT: If the user mentions or provides any URLs in their query, you MUST extract those URLs and pass them in the 'urls' parameter for direct analysis.", args: { query: tool.schema.string().describe("The search query or question to answer using web search"), urls: tool.schema.array(tool.schema.string()).optional().describe("List of specific URLs to fetch and analyze. IMPORTANT: Always extract and include any URLs mentioned by the user in their query here."), thinking: tool.schema.boolean().optional().default(true).describe("Enable deep thinking for more thorough analysis (default: true)"), }, async execute(args, ctx) { log.debug("Google Search tool called", { query: args.query, urlCount: args.urls?.length ?? 0 }); // Get current auth context const auth = cachedGetAuth ? await cachedGetAuth() : null; if (!auth || !isOAuthAuth(auth)) { return "Error: Not authenticated with Antigravity. Please run `opencode auth login` to authenticate."; } // Get access token and project ID const parts = parseRefreshParts(auth.refresh); const projectId = parts.managedProjectId || parts.projectId || "unknown"; // Ensure we have a valid access token let accessToken = auth.access; if (!accessToken || accessTokenExpired(auth)) { try { const refreshed = await refreshAccessToken(auth, client, providerId); accessToken = refreshed?.access; } catch (error) { return `Error: Failed to refresh access token: ${error instanceof Error ? error.message : String(error)}`; } } if (!accessToken) { return "Error: No valid access token available. Please run `opencode auth login` to re-authenticate."; } return executeSearch( { query: args.query, urls: args.urls, thinking: args.thinking, }, accessToken, projectId, ctx.abort, ); }, }); return { event: eventHandler, tool: { 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(); } // Initialize proactive token refresh queue (ported from LLM-API-Key-Proxy) let refreshQueue: ProactiveRefreshQueue | null = null; if (config.proactive_token_refresh && accountManager.getAccountCount() > 0) { refreshQueue = createProactiveRefreshQueue(client, providerId, { enabled: config.proactive_token_refresh, bufferSeconds: config.proactive_refresh_buffer_seconds, checkIntervalSeconds: config.proactive_refresh_check_interval_seconds, }); refreshQueue.setAccountManager(accountManager); refreshQueue.start(); } if (isDebugEnabled()) { const logPath = getLogFilePath(); if (logPath) { try { await client.tui.showToast({ body: { message: `Debug log: ${logPath}`, variant: "info" }, }); } catch { // TUI may not be available } } } if (provider.models) { for (const model of Object.values(provider.models)) { if (model) { model.cost = { input: 0, output: 0 }; } } } return { apiKey: "", async fetch(input, init) { if (!isGenerativeLanguageRequest(input)) { return fetch(input, init); } const latestAuth = await getAuth(); if (!isOAuthAuth(latestAuth)) { return fetch(input, init); } 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; }; 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; // Helper to show toast without blocking on abort (respects quiet_mode) const showToast = async (message: string, variant: "info" | "warning" | "success" | "error") => { if (quietMode) return; if (abortSignal?.aborted) 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; const otherAccounts = accountManager.getAccounts().filter(acc => acc.index !== currentAccount.index); return otherAccounts.some(acc => !accountManager.isRateLimitedForHeaderStyle(acc, family, "antigravity", 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 account = accountManager.getCurrentOrNextForFamily( family, model, config.account_selection_strategy, 'antigravity', config.pid_offset_enabled, ); if (!account) { 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 (maxWaitMs > 0 && waitMs > maxWaitMs) { const waitTimeFormatted = formatWaitTime(waitMs); await showToast( `Rate limited for ${waitTimeFormatted}. Try again later or add another account.`, "error" ); // Return a proper rate limit error response 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 (!allAccountsRateLimitedToastShown) { await showToast(`All ${accountCount} account(s) rate-limited for ${family}. Waiting ${waitSecValue}s...`, "warning"); allAccountsRateLimitedToastShown = true; } // Wait for the rate-limit cooldown to expire, then retry await sleep(waitMs, abortSignal); continue; } // Account is available - reset the toast flag resetAllAccountsRateLimitedToast(); 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, }); } // Show toast when switching to a different account (debounced, quiet_mode handled by showToast) if (accountCount > 1 && accountManager.shouldShowAccountToast(account.index)) { const accountLabel = account.email || `Account ${account.index + 1}`; // Calculate position among enabled accounts (not absolute index) const enabledAccounts = accountManager.getEnabledAccounts(); const enabledPosition = enabledAccounts.findIndex(a => a.index === account.index) + 1; await showToast( `Using ${accountLabel} (${enabledPosition}/${accountCount})`, "info" ); accountManager.markToastShown(account.index); } accountManager.requestSaveToDisk(); let authRecord = accountManager.toAuthDetails(account); if (accessTokenExpired(authRecord)) { try { const refreshed = await refreshAccessToken(authRecord, client, providerId); if (!refreshed) { const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index); getHealthTracker().recordFailure(account.index); lastError = new Error("Antigravity token refresh failed"); if (shouldCooldown) { accountManager.markAccountCoolingDown(account, cooldownMs, "auth-failure"); accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model); pushDebug(`token-refresh-failed: cooldown ${cooldownMs}ms after ${failures} failures`); } continue; } resetAccountFailureState(account.index); accountManager.updateFromAuth(account, refreshed); authRecord = refreshed; try { await accountManager.saveToDisk(); } catch (error) { log.error("Failed to persist refreshed auth", { error: String(error) }); } } catch (error) { if (error instanceof AntigravityTokenRefreshError && error.code === "invalid_grant") { const removed = accountManager.removeAccount(account); if (removed) { log.warn("Removed revoked account from pool - reauthenticate via `opencode auth login`"); try { 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 { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index); getHealthTracker().recordFailure(account.index); lastError = error instanceof Error ? error : new Error(String(error)); if (shouldCooldown) { accountManager.markAccountCoolingDown(account, cooldownMs, "auth-failure"); accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model); pushDebug(`token-refresh-error: cooldown ${cooldownMs}ms after ${failures} failures`); } continue; } } const accessToken = authRecord.access; if (!accessToken) { lastError = new Error("Missing access token"); if (accountCount <= 1) { 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, "project-error"); accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model); pushDebug(`project-context-error: cooldown ${cooldownMs}ms after ${failures} failures`); } continue; } 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) }); } } const runThinkingWarmup = async ( prepared: ReturnType, projectId: string, ): Promise => { if (!prepared.needsSignedThinkingWarmup || !prepared.sessionId) { return; } if (!trackWarmupAttempt(prepared.sessionId)) { return; } const warmupBody = buildThinkingWarmupBody( typeof prepared.init.body === "string" ? prepared.init.body : undefined, Boolean(prepared.effectiveModel?.toLowerCase().includes("claude") && prepared.effectiveModel?.toLowerCase().includes("thinking")), ); if (!warmupBody) { return; } const warmupUrl = toWarmupStreamUrl(prepared.request); const warmupHeaders = new Headers(prepared.init.headers ?? {}); warmupHeaders.set("accept", "text/event-stream"); const warmupInit: RequestInit = { ...prepared.init, method: prepared.init.method ?? "POST", headers: warmupHeaders, body: warmupBody, }; const warmupDebugContext = startAntigravityDebugRequest({ originalUrl: warmupUrl, resolvedUrl: warmupUrl, method: warmupInit.method, headers: warmupHeaders, body: warmupBody, streaming: true, projectId, }); try { pushDebug("thinking-warmup: start"); const warmupResponse = 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)}`, ); } }; // Try endpoint fallbacks with single header style based on model suffix let shouldSwitchAccount = false; // Determine header style from model suffix: // - Models with :antigravity suffix -> use Antigravity quota // - Models without suffix (default) -> use Gemini CLI quota // - Claude models -> always use Antigravity let headerStyle = getHeaderStyleFromUrl(urlString, family); const explicitQuota = isExplicitQuotaFromUrl(urlString); 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)) { // Quota fallback: try alternate quota on same account (if enabled and not explicit) if (config.quota_fallback && !explicitQuota && family === "gemini") { const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); if (alternateStyle && alternateStyle !== headerStyle) { const quotaName = headerStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity"; const altQuotaName = alternateStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity"; await showToast( `${quotaName} quota exhausted, using ${altQuotaName} quota`, "warning" ); headerStyle = alternateStyle; 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]; try { const prepared = prepareAntigravityRequest( input, init, accessToken, projectContext.effectiveProjectId, currentEndpoint, headerStyle, forceThinkingRecovery, { claudeToolHardening: config.claude_tool_hardening, fingerprint: account.fingerprint, }, ); const originalUrl = toUrlString(input); const resolvedUrl = toUrlString(prepared.request); pushDebug(`endpoint=${currentEndpoint}`); pushDebug(`resolved=${resolvedUrl}`); const debugContext = startAntigravityDebugRequest({ originalUrl, resolvedUrl, method: prepared.init.method, headers: prepared.init.headers, body: prepared.init.body, streaming: prepared.streaming, projectId: projectContext.effectiveProjectId, }); 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); } const response = await fetch(prepared.request, prepared.init); pushDebug(`status=${response.status} ${response.statusText}`); // 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; } } // STRATEGY 2: RATE LIMIT EXCEEDED (RPM) / QUOTA EXHAUSTED / UNKNOWN // Goal: Lock and Rotate (Standard Logic) // Only now do we call getRateLimitBackoff, which increments the global failure tracker const quotaKey = headerStyleToQuotaKey(headerStyle, family); const { attempt, delayMs, isDuplicate } = getRateLimitBackoff(account.index, quotaKey, serverRetryMs); // Calculate potential backoffs const smartBackoffMs = calculateBackoffMs(rateLimitReason, account.consecutiveFailures ?? 0, serverRetryMs); const effectiveDelayMs = Math.max(delayMs, smartBackoffMs); pushDebug( `429 idx=${account.index} email=${account.email ?? ""} family=${family} delayMs=${effectiveDelayMs} attempt=${attempt} reason=${rateLimitReason}`, ); if (bodyInfo.message) { pushDebug(`429 message=${bodyInfo.message}`); } if (bodyInfo.quotaResetTime) { pushDebug(`429 quotaResetTime=${bodyInfo.quotaResetTime}`); } if (bodyInfo.reason) { pushDebug(`429 reason=${bodyInfo.reason}`); } logRateLimitEvent( account.index, account.email, family, response.status, effectiveDelayMs, bodyInfo, ); await logResponseBody(debugContext, response, 429); getHealthTracker().recordRateLimit(account.index); const accountLabel = account.email || `Account ${account.index + 1}`; // Progressive retry for standard 429s: 1st 429 → 1s then switch (if enabled) or retry same if (attempt === 1 && rateLimitReason !== "QUOTA_EXHAUSTED") { await showToast(`Rate limited. Quick retry in 1s...`, "warning"); await sleep(FIRST_RETRY_DELAY_MS, abortSignal); // CacheFirst mode: wait for same account if within threshold (preserves prompt cache) if (config.scheduling_mode === 'cache_first') { const maxCacheFirstWaitMs = config.max_cache_first_wait_seconds * 1000; // effectiveDelayMs is the backoff calculated for this account if (effectiveDelayMs <= maxCacheFirstWaitMs) { pushDebug(`cache_first: waiting ${effectiveDelayMs}ms for same account to recover`); await showToast(`⏳ Waiting ${Math.ceil(effectiveDelayMs / 1000)}s for same account (prompt cache preserved)...`, "info"); accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs); await sleep(effectiveDelayMs, abortSignal); // Retry same endpoint after wait i -= 1; continue; } // Wait time exceeds threshold, fall through to switch pushDebug(`cache_first: wait ${effectiveDelayMs}ms exceeds max ${maxCacheFirstWaitMs}ms, switching account`); } if (config.switch_on_first_rate_limit && accountCount > 1) { accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs, config.failure_ttl_seconds * 1000); shouldSwitchAccount = true; break; } // Same endpoint retry for first RPM hit i -= 1; continue; } accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs, config.failure_ttl_seconds * 1000); accountManager.requestSaveToDisk(); // For Gemini, try prioritized Antigravity across ALL accounts first if (family === "gemini") { if (headerStyle === "antigravity") { // Check if any other account has Antigravity quota for this model if (hasOtherAccountWithAntigravity(account)) { pushDebug(`antigravity exhausted on account ${account.index}, but available on others. Switching account.`); await showToast(`Rate limited again. Switching account in 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); if (alternateStyle && alternateStyle !== headerStyle) { const safeModelName = model || "this model"; await showToast( `Antigravity quota exhausted for ${safeModelName}. Switching to Gemini CLI quota...`, "warning" ); headerStyle = alternateStyle; pushDebug(`quota fallback: ${headerStyle}`); continue; } } } } const quotaName = headerStyle === "antigravity" ? "Antigravity" : "Gemini CLI"; if (accountCount > 1) { const quotaMsg = bodyInfo.quotaResetTime ? ` (quota resets ${bodyInfo.quotaResetTime})` : ``; await showToast(`Rate limited again. Switching account in 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, }; 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, }; await sleep(expBackoffMs, abortSignal); shouldSwitchAccount = true; break; } } // 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, }; continue; } // Success or non-retryable error - return the response if (response.ok) { account.consecutiveFailures = 0; getHealthTracker().recordSuccess(account.index); accountManager.markAccountUsed(account.index); } 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); } } } // Empty response retry logic (ported from LLM-API-Key-Proxy) // For non-streaming responses, check if the response body is empty // and retry if so (up to config.empty_response_max_attempts times) if (response.ok && !prepared.streaming) { const maxAttempts = config.empty_response_max_attempts ?? 4; const retryDelayMs = config.empty_response_retry_delay_ms ?? 2000; // Clone to check body without consuming original const clonedForCheck = response.clone(); const bodyText = await clonedForCheck.text(); if (isEmptyResponseBody(bodyText)) { // Track empty response attempts per request const emptyAttemptKey = `${prepared.sessionId ?? "none"}:${prepared.effectiveModel ?? "unknown"}`; const currentAttempts = (emptyResponseAttempts.get(emptyAttemptKey) ?? 0) + 1; emptyResponseAttempts.set(emptyAttemptKey, currentAttempts); pushDebug(`empty-response: attempt ${currentAttempts}/${maxAttempts}`); if (currentAttempts < maxAttempts) { await showToast( `Empty response received. Retrying (${currentAttempts}/${maxAttempts})...`, "warning" ); await sleep(retryDelayMs, abortSignal); continue; // Retry the endpoint loop } // Clean up and 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" ); } } return transformedResponse; } catch (error) { // Refund token on network/API error (only if consumed) if (tokenConsumed) { getTokenTracker().refund(account.index); tokenConsumed = false; } // Handle recoverable thinking errors - retry with forced recovery if (error instanceof Error && error.message === "THINKING_RECOVERY_NEEDED") { // Only retry once with forced recovery to avoid infinite loops if (!forceThinkingRecovery) { pushDebug("thinking-recovery: API error detected, retrying with forced recovery"); forceThinkingRecovery = true; i = -1; // Will become 0 after loop increment, restart endpoint loop continue; } // Already tried with forced recovery, give up and return error const recoveryError = error as any; const originalError = recoveryError.originalError || { error: { message: "Thinking recovery triggered" } }; const recoveryMessage = `${originalError.error?.message || "Session recovery failed"}\n\n[RECOVERY] Thinking block corruption could not be resolved. Try starting a new session.`; return new Response(JSON.stringify({ type: "error", error: { type: "unrecoverable_error", message: recoveryMessage } }), { status: 400, headers: { "Content-Type": "application/json" } }); } if (i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) { lastError = error instanceof Error ? error : new Error(String(error)); continue; } // All endpoints failed for this account - track failure and try next account const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index); lastError = error instanceof Error ? error : new Error(String(error)); if (shouldCooldown) { accountManager.markAccountCoolingDown(account, cooldownMs, "network-error"); accountManager.markRateLimited(account, cooldownMs, family, headerStyle, model); pushDebug(`endpoint-error: cooldown ${cooldownMs}ms after ${failures} failures`); } shouldSwitchAccount = true; break; } } } // end headerStyleLoop if (shouldSwitchAccount) { // 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"); } 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 ); // 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'; } 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("\nChecking quotas for all accounts..."); const results = await checkAccountsQuota(existingStorage.accounts, client, providerId); for (const res of results) { const label = res.email || `Account ${res.index + 1}`; const disabledStr = res.disabled ? " (disabled)" : ""; console.log(`\n${res.index + 1}. ${label}${disabledStr}`); if (res.status === "error") { console.log(` Error: ${res.error}`); continue; } if (!res.quota || Object.keys(res.quota.groups).length === 0) { console.log(" No quota information available."); if (res.quota?.error) console.log(` Error: ${res.quota.error}`); continue; } const printGrp = (name: string, group: any) => { if (!group) return; const remaining = typeof group.remainingFraction === 'number' ? `${Math.round(group.remainingFraction * 100)}%` : 'UNKNOWN'; const resetStr = group.resetTime ? `, resets in ${formatWaitTime(Date.parse(group.resetTime) - Date.now())}` : ''; console.log(` ${name}: ${remaining}${resetStr}`); }; printGrp("Claude", res.quota.groups.claude); printGrp("Gemini 3 Pro", res.quota.groups["gemini-pro"]); printGrp("Gemini 3 Flash", res.quota.groups["gemini-flash"]); if (res.updatedAccount) { existingStorage.accounts[res.index] = res.updatedAccount; 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`); } } continue; } break; } if (menuResult.mode === "cancel") { return { url: "", instructions: "Authentication cancelled", method: "auto", callback: async () => ({ type: "failed", error: "Authentication cancelled" }), }; } if (menuResult.deleteAccountIndex !== undefined) { const updatedAccounts = existingStorage.accounts.filter( (_, idx) => idx !== menuResult.deleteAccountIndex ); 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}) ===`); const projectId = await promptProjectId(); const result = await (async (): Promise => { const authorization = await authorizeAntigravity(projectId); const fallbackState = getStateFromAuthorizationUrl(authorization.url); console.log("\nOAuth URL:\n" + authorization.url + "\n"); if (useManualMode) { const browserOpened = await openBrowser(authorization.url); if (!browserOpened) { console.log("Could not open browser automatically."); console.log("Please open the URL above manually in your local browser.\n"); } return promptManualOAuthInput(fallbackState); } let listener: OAuthListener | null = null; if (!isHeadless) { try { listener = await startOAuthListener(); } catch { listener = null; } } if (!isHeadless) { await openBrowser(authorization.url); } if (listener) { try { const SOFT_TIMEOUT_MS = 30000; const callbackPromise = listener.waitForCallback(); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("SOFT_TIMEOUT")), SOFT_TIMEOUT_MS) ); let callbackUrl: URL; try { callbackUrl = await Promise.race([callbackPromise, timeoutPromise]); } catch (err) { if (err instanceof Error && err.message === "SOFT_TIMEOUT") { console.log("\n⏳ Automatic callback not received after 30 seconds."); console.log("You can paste the redirect URL manually.\n"); console.log("OAuth URL (in case you need it again):"); console.log(authorization.url + "\n"); try { await listener.close(); } catch {} return promptManualOAuthInput(fallbackState); } throw err; } const params = extractOAuthCallbackParams(callbackUrl); if (!params) { return { type: "failed", error: "Missing code or state in callback URL" }; } return exchangeAntigravity(params.code, params.state); } catch (error) { if (error instanceof Error && error.message !== "SOFT_TIMEOUT") { return { type: "failed", error: error.message, }; } return { type: "failed", error: error instanceof Error ? error.message : "Unknown error", }; } finally { try { await listener.close(); } catch {} } } return promptManualOAuthInput(fallbackState); })(); if (result.type === "failed") { if (accounts.length === 0) { return { url: "", instructions: `Authentication failed: ${result.error}`, method: "auto", callback: async () => result, }; } console.warn( `[opencode-antigravity-auth] Skipping failed account ${accounts.length + 1}: ${result.error}`, ); break; } accounts.push(result); try { await client.tui.showToast({ body: { message: `Account ${accounts.length} authenticated${result.email ? ` (${result.email})` : ""}`, variant: "success", }, }); } catch { } try { if (refreshAccountIndex !== undefined) { const currentStorage = await loadAccounts(); if (currentStorage) { const updatedAccounts = [...currentStorage.accounts]; const parts = parseRefreshParts(result.refresh); if (parts.refreshToken) { updatedAccounts[refreshAccountIndex] = { email: result.email ?? updatedAccounts[refreshAccountIndex]?.email, refreshToken: parts.refreshToken, projectId: parts.projectId ?? updatedAccounts[refreshAccountIndex]?.projectId, managedProjectId: parts.managedProjectId ?? updatedAccounts[refreshAccountIndex]?.managedProjectId, addedAt: updatedAccounts[refreshAccountIndex]?.addedAt ?? Date.now(), lastUsed: Date.now(), }; await saveAccounts({ version: 3, accounts: updatedAccounts, activeIndex: currentStorage.activeIndex, activeIndexByFamily: currentStorage.activeIndexByFamily, }); } } } else { const isFirstAccount = accounts.length === 1; await persistAccountPool([result], isFirstAccount && startFresh); } } catch { } if (refreshAccountIndex !== undefined) { break; } if (accounts.length >= MAX_OAUTH_ACCOUNTS) { break; } // Get the actual deduplicated account count from storage for the prompt let currentAccountCount = accounts.length; try { const currentStorage = await loadAccounts(); if (currentStorage) { currentAccountCount = currentStorage.accounts.length; } } catch { // Fall back to accounts.length if we can't read storage } const addAnother = await promptAddAnotherAccount(currentAccountCount); if (!addAnother) { break; } } const primary = accounts[0]; if (!primary) { return { url: "", instructions: "Authentication cancelled", method: "auto", callback: async () => ({ type: "failed", error: "Authentication cancelled" }), }; } let actualAccountCount = accounts.length; try { const finalStorage = await loadAccounts(); if (finalStorage) { actualAccountCount = finalStorage.accounts.length; } } catch { } const successMessage = refreshAccountIndex !== undefined ? `Token refreshed successfully.` : `Multi-account setup complete (${actualAccountCount} account(s)).`; return { url: "", instructions: successMessage, method: "auto", callback: async (): Promise => primary, }; } // TUI flow (`/connect`) does not support per-account prompts. // Default to adding new accounts (non-destructive). // Users can run `opencode auth logout` first if they want a fresh start. const projectId = ""; // Check existing accounts count for toast message const existingStorage = await loadAccounts(); const existingCount = existingStorage?.accounts.length ?? 0; const useManualFlow = isHeadless || shouldSkipLocalServer(); let listener: OAuthListener | null = null; if (!useManualFlow) { try { listener = await startOAuthListener(); } catch { listener = null; } } const authorization = await authorizeAntigravity(projectId); const fallbackState = getStateFromAuthorizationUrl(authorization.url); if (!useManualFlow) { const browserOpened = await openBrowser(authorization.url); if (!browserOpened) { listener?.close().catch(() => {}); listener = null; } } if (listener) { return { url: authorization.url, instructions: "Complete sign-in in your browser. We'll automatically detect the redirect back to localhost.", method: "auto", callback: async (): Promise => { const CALLBACK_TIMEOUT_MS = 30000; try { const callbackPromise = listener.waitForCallback(); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("CALLBACK_TIMEOUT")), CALLBACK_TIMEOUT_MS), ); let callbackUrl: URL; try { callbackUrl = await Promise.race([callbackPromise, timeoutPromise]); } catch (err) { if (err instanceof Error && err.message === "CALLBACK_TIMEOUT") { return { type: "failed", error: "Callback timeout - please use CLI with --no-browser flag for manual input", }; } throw err; } const params = extractOAuthCallbackParams(callbackUrl); if (!params) { return { type: "failed", error: "Missing code or state in callback URL" }; } const result = await exchangeAntigravity(params.code, params.state); if (result.type === "success") { try { await persistAccountPool([result], false); } catch { } const newTotal = existingCount + 1; const toastMessage = existingCount > 0 ? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total` : `Authenticated${result.email ? ` (${result.email})` : ""}`; try { await client.tui.showToast({ body: { message: toastMessage, variant: "success", }, }); } catch { } } return result; } catch (error) { return { type: "failed", error: error instanceof Error ? error.message : "Unknown error", }; } finally { try { await listener.close(); } catch { } } }, }; } return { url: authorization.url, instructions: "Visit the URL above, complete OAuth, then paste either the full redirect URL or the authorization code.", method: "code", callback: async (codeInput: string): Promise => { const params = parseOAuthCallbackInput(codeInput, fallbackState); if ("error" in params) { return { type: "failed", error: params.error }; } const result = await exchangeAntigravity(params.code, params.state); if (result.type === "success") { try { // TUI flow adds to existing accounts (non-destructive) await persistAccountPool([result], false); } catch { // ignore } // Show appropriate toast message const newTotal = existingCount + 1; const toastMessage = existingCount > 0 ? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total` : `Authenticated${result.email ? ` (${result.email})` : ""}`; try { await client.tui.showToast({ body: { message: toastMessage, variant: "success", }, }); } catch { // TUI may not be available } } return result; }, }; }, }, { label: "Manually enter API Key", type: "api", }, ], }, }; }; export const AntigravityCLIOAuthPlugin = createAntigravityPlugin(ANTIGRAVITY_PROVIDER_ID); export const GoogleOAuthPlugin = AntigravityCLIOAuthPlugin; function toUrlString(value: RequestInfo): string { if (typeof value === "string") { return value; } const candidate = (value as Request).url; if (candidate) { return candidate; } return value.toString(); } function toWarmupStreamUrl(value: RequestInfo): string { const urlString = toUrlString(value); try { const url = new URL(urlString); if (!url.pathname.includes(":streamGenerateContent")) { url.pathname = url.pathname.replace(":generateContent", ":streamGenerateContent"); } url.searchParams.set("alt", "sse"); return url.toString(); } catch { return urlString; } } function extractModelFromUrl(urlString: string): string | null { const match = urlString.match(/\/models\/([^:\/?]+)(?::\w+)?/); return match?.[1] ?? null; } function extractModelFromUrlWithSuffix(urlString: string): string | null { const match = urlString.match(/\/models\/([^:\/\?]+)/); return match?.[1] ?? null; } function getModelFamilyFromUrl(urlString: string): ModelFamily { const model = extractModelFromUrl(urlString); let family: ModelFamily = "gemini"; if (model && model.includes("claude")) { family = "claude"; } if (isDebugEnabled()) { logModelFamily(urlString, model, family); } return family; } function getHeaderStyleFromUrl(urlString: string, family: ModelFamily): HeaderStyle { if (family === "claude") { return "antigravity"; } const modelWithSuffix = extractModelFromUrlWithSuffix(urlString); if (!modelWithSuffix) { return "gemini-cli"; } const { quotaPreference } = resolveModelWithTier(modelWithSuffix); return quotaPreference ?? "gemini-cli"; } function isExplicitQuotaFromUrl(urlString: string): boolean { const modelWithSuffix = extractModelFromUrlWithSuffix(urlString); if (!modelWithSuffix) { return false; } const { explicitQuota } = resolveModelWithTier(modelWithSuffix); return explicitQuota ?? false; }