Merge pull request #275 from kaitranntt/kai/feat/antigravity-failover

feat(agy): add preflight quota check with auto-failover
This commit is contained in:
Kai (Tam Nhu) Tran
2026-01-05 11:30:21 -08:00
committed by GitHub
5 changed files with 292 additions and 0 deletions
+8
View File
@@ -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).
<br>
## Configuration
+35
View File
@@ -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
+131
View File
@@ -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<string, string[]>;
/** 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<AllAccountsQuotaResult> {
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;
}
+117
View File
@@ -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<void> {
[
['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<void> {
console.log('');
}
// ============================================================================
// DOCTOR COMMAND - Quota diagnostics and shared project detection
// ============================================================================
async function handleDoctor(): Promise<void> {
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<void> {
return;
}
if (command === 'doctor' || command === 'diag') {
await handleDoctor();
return;
}
const installIdx = args.indexOf('--install');
if (installIdx !== -1) {
let version = args[installIdx + 1];
+1
View File
@@ -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 <ver>', 'Install specific version (e.g., 6.6.6)'],
['ccs cliproxy --latest', 'Update to latest version'],
]);