diff --git a/README.md b/README.md index a380165f..7a4cd177 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,14 @@ ccs sync Re-creates symlinks for shared commands, skills, and settings. +### Antigravity Quota Management + +```bash +ccs cliproxy doctor # Check quota status for all agy accounts +``` + +**Auto-Failover**: When an Antigravity account runs out of quota, CCS automatically switches to another account with remaining capacity. Shared GCP project accounts are excluded (pooled quota). +
## Configuration diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 0de849d6..6a031402 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -58,6 +58,7 @@ import { import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector'; import { withStartupLock } from './startup-lock'; import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; +import { fetchAccountQuota, findAvailableAccount } from './quota-fetcher'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -463,6 +464,40 @@ export async function execClaudeWithCLIProxy( } } + // 3b. Preflight quota check - auto-switch to account with quota before launch + // Only for agy (Antigravity) which has quota tracking + if (provider === 'agy') { + const defaultAccount = getDefaultAccount(provider); + if (defaultAccount) { + log(`Checking quota for ${defaultAccount.email || defaultAccount.id}`); + const quota = await fetchAccountQuota(provider, defaultAccount.id); + + // Check if current account is exhausted (no model with >5% quota) + const hasQuota = quota.success && quota.models.some((m) => m.percentage > 5); + + if (!hasQuota && quota.success) { + // Current account exhausted, try to find alternative + log('Current account quota exhausted, searching for alternatives...'); + const alternative = await findAvailableAccount(provider, defaultAccount.id); + + if (alternative) { + // Auto-switch to account with remaining quota + setDefaultAccount(provider, alternative.account.id); + touchAccount(provider, alternative.account.id); + console.log( + info( + `Auto-switched to ${alternative.account.email || alternative.account.id} (current account quota exhausted)` + ) + ); + } else { + // No alternatives available - warn but continue + console.log(warn('All accounts appear quota-exhausted')); + console.log(` Run: ccs cliproxy doctor`); + } + } + } + } + // 4. First-run model configuration (interactive) // For supported providers, prompt user to select model on first run // Pass customSettingsPath for CLIProxy variants diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 749d9393..d2daa236 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -9,6 +9,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { getAuthDir } from './config-generator'; import { CLIProxyProvider } from './types'; +import { getProviderAccounts, type AccountInfo } from './account-manager'; /** Individual model quota info */ export interface ModelQuota { @@ -40,6 +41,10 @@ export interface QuotaResult { expiresAt?: string; /** True if account hasn't been activated in official Antigravity app */ isUnprovisioned?: boolean; + /** Account ID (email) this quota belongs to */ + accountId?: string; + /** GCP project ID for this account */ + projectId?: string; } /** Google Cloud Code API endpoints */ @@ -523,3 +528,129 @@ export async function fetchAccountQuota( return result; } + +/** + * Read project ID directly from auth file without making API call + * Used for quick project ID comparison in doctor command + */ +export function readProjectIdFromAuthFile( + provider: CLIProxyProvider, + accountId: string +): string | null { + const authData = readAuthData(provider, accountId); + return authData?.projectId || null; +} + +/** Result for all accounts of a provider */ +export interface AllAccountsQuotaResult { + /** Provider name */ + provider: CLIProxyProvider; + /** Results per account */ + accounts: Array<{ + account: AccountInfo; + quota: QuotaResult; + }>; + /** Accounts grouped by project ID (for detecting shared projects) */ + projectGroups: Record; + /** Timestamp of fetch */ + lastUpdated: number; +} + +/** + * Fetch quota for all accounts of a provider + * Also detects accounts sharing same GCP project (failover won't help) + * + * @param provider - Provider name (only 'agy' supported for quota) + * @returns Results for all accounts with project grouping + */ +export async function fetchAllProviderQuotas( + provider: CLIProxyProvider +): Promise { + const accounts = getProviderAccounts(provider); + const results: AllAccountsQuotaResult = { + provider, + accounts: [], + projectGroups: {}, + lastUpdated: Date.now(), + }; + + if (accounts.length === 0) { + return results; + } + + // Fetch quota for each account in parallel + const quotaPromises = accounts.map(async (account) => { + const quota = await fetchAccountQuota(provider, account.id); + + // Read project ID from auth file if not in quota result + let projectId = quota.projectId; + if (!projectId) { + projectId = readProjectIdFromAuthFile(provider, account.id) || undefined; + } + + return { + account, + quota: { ...quota, accountId: account.id, projectId }, + }; + }); + + const quotaResults = await Promise.all(quotaPromises); + + // Build project groups for detecting shared projects + for (const { account, quota } of quotaResults) { + results.accounts.push({ account, quota }); + + if (quota.projectId) { + if (!results.projectGroups[quota.projectId]) { + results.projectGroups[quota.projectId] = []; + } + results.projectGroups[quota.projectId].push(account.id); + } + } + + return results; +} + +/** + * Find available account with remaining quota + * Used by preflight check for auto-switching + * + * @param provider - Provider name + * @param excludeAccountId - Account to exclude (current exhausted account) + * @returns Account with available quota, or null if none available + */ +export async function findAvailableAccount( + provider: CLIProxyProvider, + excludeAccountId?: string +): Promise<{ account: AccountInfo; quota: QuotaResult } | null> { + const allQuotas = await fetchAllProviderQuotas(provider); + + // Get excluded account's project ID to avoid switching to same-project accounts + const excludedProjectId = allQuotas.accounts.find((a) => a.account.id === excludeAccountId)?.quota + .projectId; + + for (const { account, quota } of allQuotas.accounts) { + // Skip excluded account + if (excludeAccountId && account.id === excludeAccountId) { + continue; + } + + // Skip failed quota fetches + if (!quota.success) { + continue; + } + + // Skip accounts sharing same GCP project (quota is pooled) + if (excludedProjectId && quota.projectId === excludedProjectId) { + continue; + } + + // Check if any model has remaining quota (> 5% to avoid edge cases) + const hasQuota = quota.models.some((m) => m.percentage > 5); + if (hasQuota) { + return { account, quota }; + } + } + + return null; +} diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index bce8a3b9..080d271b 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -21,6 +21,7 @@ import * as path from 'path'; import { getAllAuthStatus, getOAuthConfig, triggerOAuth } from '../cliproxy/auth-handler'; import { getProviderAccounts } from '../cliproxy/account-manager'; +import { fetchAllProviderQuotas } from '../cliproxy/quota-fetcher'; import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector'; import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector'; import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../cliproxy/model-catalog'; @@ -548,6 +549,7 @@ async function showHelp(): Promise { [ ['status', 'Show running CLIProxy status'], ['stop', 'Stop running CLIProxy instance'], + ['doctor', 'Quota diagnostics and shared project detection'], ], ], [ @@ -579,6 +581,116 @@ async function showHelp(): Promise { console.log(''); } +// ============================================================================ +// DOCTOR COMMAND - Quota diagnostics and shared project detection +// ============================================================================ + +async function handleDoctor(): Promise { + await initUI(); + console.log(header('CLIProxy Quota Diagnostics')); + console.log(''); + + // Check each OAuth provider (agy is the only one with quota) + const provider: CLIProxyProvider = 'agy'; + const accounts = getProviderAccounts(provider); + + if (accounts.length === 0) { + console.log(info('No Antigravity accounts configured')); + console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`); + return; + } + + console.log(subheader(`Antigravity Accounts (${accounts.length})`)); + console.log(''); + + // Fetch quota for all accounts + console.log(dim('Fetching quotas...')); + const quotaResult = await fetchAllProviderQuotas(provider); + + // Display per-account quota status + for (const { account, quota } of quotaResult.accounts) { + const accountLabel = account.email || account.id || 'Unknown Account'; + const defaultBadge = account.isDefault ? color(' (default)', 'info') : ''; + + if (!quota.success) { + console.log(` ${fail(accountLabel)}${defaultBadge}`); + console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + if (quota.isUnprovisioned) { + console.log( + ` ${warn('Account not provisioned - open Gemini Code Assist in IDE first')}` + ); + } + console.log(''); + continue; + } + + // Calculate overall quota health (guard against empty models array) + const avgQuota = + quota.models.length > 0 + ? quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length + : 0; + const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); + + console.log(` ${statusIcon}${accountLabel}${defaultBadge}`); + if (quota.projectId) { + console.log(` Project: ${dim(quota.projectId)}`); + } + + // Show model quotas + for (const model of quota.models) { + const bar = formatQuotaBar(model.percentage); + console.log(` ${model.name.padEnd(20)} ${bar} ${model.percentage.toFixed(0)}%`); + } + console.log(''); + } + + // Check for shared GCP projects (critical warning) + const sharedProjects = Object.entries(quotaResult.projectGroups).filter( + ([, accountIds]) => accountIds.length > 1 + ); + + if (sharedProjects.length > 0) { + console.log(''); + console.log(subheader('Shared Project Warning')); + console.log(''); + for (const [projectId, accountIds] of sharedProjects) { + console.log( + fail(`Project ${projectId.substring(0, 20)}... shared by ${accountIds.length} accounts:`) + ); + for (const accountId of accountIds) { + console.log(` - ${accountId}`); + } + console.log(''); + console.log(warn('These accounts share the same quota pool!')); + console.log(warn('Failover between them will NOT help when quota is exhausted.')); + console.log(info('Solution: Use accounts from different GCP projects.')); + } + } + + // Summary + console.log(''); + console.log(subheader('Summary')); + const healthyAccounts = quotaResult.accounts.filter( + ({ quota }) => quota.success && quota.models.some((m) => m.percentage > 5) + ); + console.log(` Accounts with quota: ${healthyAccounts.length}/${accounts.length}`); + if (sharedProjects.length > 0) { + console.log(` ${fail(`Shared projects: ${sharedProjects.length} (failover limited)`)}`); + } else if (accounts.length > 1) { + console.log(` ${ok('No shared projects (failover fully operational)')}`); + } + console.log(''); +} + +function formatQuotaBar(percentage: number): string { + const width = 20; + const clampedPct = Math.max(0, Math.min(100, percentage)); + const filled = Math.round((clampedPct / 100) * width); + const empty = width - filled; + const filledChar = clampedPct > 50 ? '█' : clampedPct > 10 ? '▓' : '░'; + return `[${filledChar.repeat(filled)}${' '.repeat(empty)}]`; +} + // ============================================================================ // MAIN ROUTER // ============================================================================ @@ -617,6 +729,11 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } + if (command === 'doctor' || command === 'diag') { + await handleDoctor(); + return; + } + const installIdx = args.indexOf('--install'); if (installIdx !== -1) { let version = args[installIdx + 1]; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index ddfa96ef..840ad3d5 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -256,6 +256,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); printSubSection('CLI Proxy Plus Management', [ ['ccs cliproxy', 'Show CLIProxy Plus status and version'], ['ccs cliproxy --help', 'Full CLIProxy Plus management help'], + ['ccs cliproxy doctor', 'Quota diagnostics (Antigravity)'], ['ccs cliproxy --install ', 'Install specific version (e.g., 6.6.6)'], ['ccs cliproxy --latest', 'Update to latest version'], ]);