mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 12:19:35 +00:00
Merge pull request #275 from kaitranntt/kai/feat/antigravity-failover
feat(agy): add preflight quota check with auto-failover
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user