Files
opencode-antigravity-auth/src/plugin.ts
Tinh To af888a74d0 Merge pull request #284 from Qwinty/feat/quota-checker-v2
feat: add quota check and account management to auth login
2026-01-25 21:52:32 +07:00

2436 lines
96 KiB
TypeScript

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<string>();
const warmupSucceededSessionIds = new Set<string>();
const log = createLogger("plugin");
// Module-level toast debounce to persist across requests (fixes toast spam)
const rateLimitToastCooldowns = new Map<string, number>();
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<boolean> {
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<string> {
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<AntigravityTokenExchangeResult> {
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<Extract<AntigravityTokenExchangeResult, { type: "success" }>>,
replaceAll: boolean = false,
): Promise<void> {
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<string, number>();
const indexByEmail = new Map<string, number>();
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<string, string> }).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<RateLimitBodyInfo> {
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<string, RateLimitState>();
// Track empty response retry attempts (ported from LLM-API-Key-Proxy)
const emptyResponseAttempts = new Map<string, number>();
/**
* 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<number, { consecutiveFailures: number; lastFailureAt: number }>();
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<void> {
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<PluginResult> => {
// 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<string, unknown> | 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<LoaderResult | Record<string, unknown>> => {
// 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<typeof startAntigravityDebugRequest>;
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<typeof prepareAntigravityRequest>,
projectId: string,
): Promise<void> => {
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<string, string>) => {
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<Extract<AntigravityTokenExchangeResult, { type: "success" }>> = [];
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<AntigravityTokenExchangeResult> => {
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<never>((_, 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<AntigravityTokenExchangeResult> => 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<AntigravityTokenExchangeResult> => {
const CALLBACK_TIMEOUT_MS = 30000;
try {
const callbackPromise = listener.waitForCallback();
const timeoutPromise = new Promise<never>((_, 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<AntigravityTokenExchangeResult> => {
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;
}