Merge pull request #285 from kaitranntt/kai/feat/hybrid-quota-management

feat(cliproxy): hybrid quota management with auto-failover and manual controls
This commit is contained in:
Kai (Tam Nhu) Tran
2026-01-06 14:19:18 -08:00
committed by GitHub
19 changed files with 1260 additions and 63 deletions
+59 -3
View File
@@ -33,7 +33,8 @@ CLI wrapper for instant switching between multiple Claude accounts and alternati
| Using `chore:` for dev→main PR | No npm release triggered | Use `feat:` or `fix:` prefix |
| Committing directly to `main` or `dev` | Bypasses CI/review | Always use PRs |
| Manual version bump or git tag | Conflicts with semantic-release | Let CI handle versioning |
| Forgetting `--help` update | CLI docs out of sync | Update src/ccs.ts, lib/ccs, lib/ccs.ps1 |
| Forgetting `--help` update | CLI docs out of sync | Update `src/commands/help-command.ts` |
| Forgetting docs update | User docs out of sync | Update `docs/` and CCS docs submodule |
## Quality Gates (MANDATORY)
@@ -99,10 +100,61 @@ bun run validate # Step 3: Final check (must pass)
2. **TTY-aware colors** - Respect NO_COLOR env var
3. **Non-invasive** - NEVER modify `~/.claude/settings.json`
4. **Cross-platform parity** - bash/PowerShell/Node.js must behave identically
5. **CLI documentation** - ALL changes MUST update `--help` in src/ccs.ts, lib/ccs, lib/ccs.ps1
5. **CLI documentation** - ALL CLI changes MUST update respective `--help` handler (see table below)
6. **Idempotent** - All install operations safe to run multiple times
7. **Dashboard parity** - Configuration features MUST work in both CLI and Dashboard
### Help Location Reference
| Command | Help Handler Location |
|---------|----------------------|
| `ccs --help` | `src/commands/help-command.ts` |
| `ccs cliproxy --help` | `src/commands/cliproxy-command.ts``showHelp()` |
| `ccs auth --help` | `src/commands/auth-command.ts` |
| `ccs api --help` | `src/commands/api-command.ts` |
| `ccs copilot --help` | `src/commands/copilot-command.ts` |
**Note:** `lib/ccs` and `lib/ccs.ps1` are bootstrap wrappers only—they delegate to Node.js and contain no help text.
## Documentation Requirements (MANDATORY)
**Documentation is a first-class citizen. ALL user-facing changes require docs updates.**
### Local Documentation (`docs/`)
Update local `docs/` folder for:
- Architecture changes
- Internal API documentation
- Development guides
### CCS Docs Submodule (Owner Only)
**For @kaitranntt (repository owner):** When adding/changing CLI commands or config options, you MUST also update the CCS docs submodule at `~/CloudPersonal/ccs/docs/`:
| Change Type | Files to Update |
|-------------|-----------------|
| New CLI command/flag | `reference/cli-commands.mdx` |
| New config option | `reference/config-schema.mdx` |
| Provider feature | `providers/<provider>.mdx` |
| New feature | `features/<feature>.mdx` |
**Workflow for docs submodule:**
```bash
cd ~/CloudPersonal/ccs/docs/
git checkout main && git pull
# Make changes
git add -A && git commit -m "docs: <description>"
git push origin main
```
**For external contributors:** Document changes in PR description. Owner will sync to CCS docs.
### Pre-Commit Docs Checklist
- [ ] Respective `--help` updated (see Help Location Reference table)
- [ ] Local `docs/` updated if architecture changed
- [ ] CCS docs submodule updated (owner) or PR description includes docs (contributor)
## Feature Interface Requirements
| Feature Type | CLI | Dashboard | Example |
@@ -285,10 +337,14 @@ rm -rf ~/.ccs # Clean environment
**Code:**
- [ ] Conventional commit format (`feat:`, `fix:`, etc.)
- [ ] `--help` updated (src/ccs.ts, lib/ccs, lib/ccs.ps1) — if CLI changed
- [ ] Respective `--help` updated (see Help Location Reference) — if CLI changed
- [ ] Tests added/updated — if behavior changed
- [ ] README.md updated — if user-facing
**Documentation:**
- [ ] CCS docs updated (owner: `~/CloudPersonal/ccs/docs/`) — if CLI/config changed
- [ ] Local `docs/` updated — if architecture changed
**Standards:**
- [ ] ASCII only (NO emojis), NO_COLOR respected
- [ ] YAGNI/KISS/DRY alignment verified
+79
View File
@@ -14,6 +14,9 @@ import { CLIProxyProvider } from './types';
import { getCliproxyDir, getAuthDir } from './config-generator';
import { PROVIDER_TYPE_VALUES } from './auth/auth-types';
/** Account tier for quota management (free vs paid - no Pro/Ultra distinction needed) */
export type AccountTier = 'free' | 'paid' | 'unknown';
/**
* Providers that typically have empty email in OAuth token files.
* For these providers, nickname is used as accountId instead of email.
@@ -38,6 +41,12 @@ export interface AccountInfo {
createdAt: string;
/** Last usage time */
lastUsedAt?: string;
/** User-paused state (skip in quota rotation) */
paused?: boolean;
/** ISO timestamp when paused */
pausedAt?: string;
/** Account tier: free or paid (Pro/Ultra combined) */
tier?: AccountTier;
}
/** Provider accounts configuration */
@@ -384,6 +393,76 @@ export function setDefaultAccount(provider: CLIProxyProvider, accountId: string)
return true;
}
/**
* Pause an account (skip in quota rotation)
*/
export function pauseAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry();
const providerAccounts = registry.providers[provider];
if (!providerAccounts?.accounts[accountId]) {
return false;
}
providerAccounts.accounts[accountId].paused = true;
providerAccounts.accounts[accountId].pausedAt = new Date().toISOString();
saveAccountsRegistry(registry);
return true;
}
/**
* Resume a paused account
*/
export function resumeAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry();
const providerAccounts = registry.providers[provider];
if (!providerAccounts?.accounts[accountId]) {
return false;
}
providerAccounts.accounts[accountId].paused = false;
providerAccounts.accounts[accountId].pausedAt = undefined;
saveAccountsRegistry(registry);
return true;
}
/**
* Check if an account is paused
*/
export function isAccountPaused(provider: CLIProxyProvider, accountId: string): boolean {
const accounts = getProviderAccounts(provider);
const account = accounts.find((a) => a.id === accountId);
return account?.paused ?? false;
}
/**
* Update account tier
*/
export function setAccountTier(
provider: CLIProxyProvider,
accountId: string,
tier: AccountTier
): boolean {
const registry = loadAccountsRegistry();
const providerAccounts = registry.providers[provider];
if (!providerAccounts?.accounts[accountId]) {
return false;
}
providerAccounts.accounts[accountId].tier = tier;
saveAccountsRegistry(registry);
return true;
}
/**
* Get non-paused accounts for a provider
*/
export function getActiveAccounts(provider: CLIProxyProvider): AccountInfo[] {
return getProviderAccounts(provider).filter((a) => !a.paused);
}
/**
* Remove an account
*/
+12 -27
View File
@@ -58,7 +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';
import { preflightCheck } from './quota-manager';
/** Default executor configuration */
const DEFAULT_CONFIG: ExecutorConfig = {
@@ -472,35 +472,20 @@ export async function execClaudeWithCLIProxy(
}
// 3b. Preflight quota check - auto-switch to account with quota before launch
// Only for agy (Antigravity) which has quota tracking
// Uses quota-manager for caching, tier priority, and cooldown support
if (provider === 'agy') {
const defaultAccount = getDefaultAccount(provider);
if (defaultAccount) {
log(`Checking quota for ${defaultAccount.email || defaultAccount.id}`);
const quota = await fetchAccountQuota(provider, defaultAccount.id);
const preflight = await preflightCheck(provider);
// Check if current account is exhausted (no model with >5% quota)
const hasQuota = quota.success && quota.models.some((m) => m.percentage > 5);
if (!preflight.proceed) {
console.error(fail(`Cannot start session: ${preflight.reason}`));
process.exit(1);
}
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`);
}
if (preflight.switchedFrom) {
console.log(info(`Auto-switched to ${preflight.accountId}`));
console.log(` Reason: ${preflight.reason}`);
if (preflight.quotaPercent !== undefined) {
console.log(` New account quota: ${preflight.quotaPercent.toFixed(1)}%`);
}
}
}
+136 -29
View File
@@ -9,7 +9,12 @@ 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';
import {
getProviderAccounts,
setAccountTier,
type AccountInfo,
type AccountTier,
} from './account-manager';
/** Individual model quota info */
export interface ModelQuota {
@@ -45,6 +50,8 @@ export interface QuotaResult {
accountId?: string;
/** GCP project ID for this account */
projectId?: string;
/** Detected account tier based on model access */
tier?: AccountTier;
}
/** Google Cloud Code API endpoints */
@@ -104,9 +111,21 @@ interface TokenRefreshResponse {
error_description?: string;
}
/** Tier info from loadCodeAssist */
interface TierInfo {
id?: string;
isDefault?: boolean;
}
/** loadCodeAssist response */
interface LoadCodeAssistResponse {
cloudaicompanionProject?: string | { id?: string };
/** Current tier (may be trial/temporary) */
currentTier?: TierInfo;
/** Paid tier (reflects actual subscription - takes priority) */
paidTier?: TierInfo;
/** Array of allowed tiers - use isDefault=true to find active tier (CLIProxyAPIPlus approach) */
allowedTiers?: TierInfo[];
}
/** fetchAvailableModels response model */
@@ -268,11 +287,56 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData |
}
/**
* Get project ID via loadCodeAssist endpoint
* Map tier ID string to AccountTier type
* Simplified: anything with 'pro' or 'ultra' = paid, 'free'/'legacy' = free
*/
async function getProjectId(
accessToken: string
): Promise<{ projectId: string | null; error?: string; isUnprovisioned?: boolean }> {
function mapTierString(tierStr: string | undefined): AccountTier {
if (!tierStr) return 'unknown';
const normalized = tierStr.toLowerCase();
if (normalized.includes('ultra') || normalized.includes('pro')) return 'paid';
if (normalized.includes('free') || normalized.includes('legacy')) {
return 'free';
}
// "standard-tier" and other unknown values should NOT map to 'free'
// Let inferTierFromModels handle the detection
return 'unknown';
}
/**
* Infer tier from model access patterns.
* - Paid: Has access to Claude models OR premium Gemini models
* - Free: Only basic models
*
* Claude models are Ultra-exclusive, premium Gemini indicates Pro/Ultra.
* Both are "paid" tier for our purposes.
*/
function inferTierFromModels(models: ModelQuota[]): AccountTier {
if (models.length === 0) return 'unknown';
// Check for Claude models (Ultra-exclusive) or premium Gemini (Pro/Ultra)
const hasPaidAccess = models.some((m) => {
const name = m.name.toLowerCase();
return (
name.includes('claude') ||
name.includes('gemini-3-pro') ||
name.includes('gemini-2.5-pro') ||
name.includes('gemini-pro-high')
);
});
return hasPaidAccess ? 'paid' : 'unknown';
}
/**
* Get project ID and tier via loadCodeAssist endpoint
* Uses allowedTiers array with isDefault=true (CLIProxyAPIPlus approach)
*/
async function getProjectId(accessToken: string): Promise<{
projectId: string | null;
tier?: AccountTier;
error?: string;
isUnprovisioned?: boolean;
}> {
const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`;
const controller = new AbortController();
@@ -327,7 +391,20 @@ async function getProjectId(
};
}
return { projectId: projectId.trim() };
// Extract tier using CLIProxyAPIPlus approach:
// 1. Find tier with isDefault=true in allowedTiers array
// 2. Fallback to paidTier > currentTier
let tierStr: string | undefined;
if (data.allowedTiers && Array.isArray(data.allowedTiers)) {
const defaultTier = data.allowedTiers.find((t) => t.isDefault);
tierStr = defaultTier?.id;
}
if (!tierStr) {
tierStr = data.paidTier?.id || data.currentTier?.id;
}
const tier = mapTierString(tierStr);
return { projectId: projectId.trim(), tier };
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === 'AbortError') {
@@ -489,43 +566,73 @@ export async function fetchAccountQuota(
}
}
// Get project ID - prefer stored value, fallback to API call
// Get project ID and tier - prefer stored project ID, but always call API for tier
let projectId = authData.projectId;
if (!projectId) {
let lastProjectResult = await getProjectId(accessToken);
if (!lastProjectResult.projectId) {
// If project ID fetch fails, it might be token issue - try refresh if we haven't
if (authData.refreshToken && accessToken === authData.accessToken) {
const refreshResult = await refreshAccessToken(authData.refreshToken);
if (refreshResult.accessToken) {
accessToken = refreshResult.accessToken;
lastProjectResult = await getProjectId(accessToken);
}
}
if (!lastProjectResult.projectId) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: lastProjectResult.error || 'Failed to retrieve project ID',
isUnprovisioned: lastProjectResult.isUnprovisioned,
};
let apiTier: AccountTier = 'unknown';
// Always call loadCodeAssist to get accurate tier from API
let lastProjectResult = await getProjectId(accessToken);
if (!lastProjectResult.projectId && !projectId) {
// If project ID fetch fails, it might be token issue - try refresh if we haven't
if (authData.refreshToken && accessToken === authData.accessToken) {
const refreshResult = await refreshAccessToken(authData.refreshToken);
if (refreshResult.accessToken) {
accessToken = refreshResult.accessToken;
lastProjectResult = await getProjectId(accessToken);
}
}
projectId = lastProjectResult.projectId;
if (!lastProjectResult.projectId) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: lastProjectResult.error || 'Failed to retrieve project ID',
isUnprovisioned: lastProjectResult.isUnprovisioned,
};
}
}
// Use API project ID if available, else fallback to stored
projectId = lastProjectResult.projectId || projectId;
apiTier = lastProjectResult.tier || 'unknown';
// Fetch models with quota
const result = await fetchAvailableModels(accessToken, projectId);
const result = await fetchAvailableModels(accessToken, projectId as string);
// If quota fetch fails with auth error and we haven't refreshed yet, try refresh
if (!result.success && result.error?.includes('expired') && authData.refreshToken) {
const refreshResult = await refreshAccessToken(authData.refreshToken);
if (refreshResult.accessToken) {
return fetchAvailableModels(refreshResult.accessToken, projectId);
const retryResult = await fetchAvailableModels(
refreshResult.accessToken,
projectId as string
);
// Determine tier: model access (Claude = Ultra) > API tier > fallback
if (retryResult.success) {
let finalTier = inferTierFromModels(retryResult.models);
if (finalTier === 'unknown') {
finalTier = apiTier !== 'unknown' ? apiTier : 'paid';
}
retryResult.tier = finalTier;
retryResult.accountId = accountId;
setAccountTier(provider, accountId, finalTier);
}
return retryResult;
}
}
// Determine tier: model access > API tier > fallback to paid
if (result.success) {
let finalTier = inferTierFromModels(result.models);
if (finalTier === 'unknown') {
finalTier = apiTier !== 'unknown' ? apiTier : 'paid';
}
result.tier = finalTier;
result.accountId = accountId;
setAccountTier(provider, accountId, finalTier);
}
return result;
}
+395
View File
@@ -0,0 +1,395 @@
/**
* Quota Manager for Hybrid Auto+Manual Account Selection
*
* Provides pre-flight quota checking with caching, tier-based failover,
* and cooldown tracking for exhausted accounts.
*
* Key features:
* - 30-second in-memory cache for quota results
* - Tier-priority failover (ultra > pro by default)
* - Cooldown tracking for exhausted accounts
* - Respects paused accounts from manual config
* - Graceful degradation on API failures
*/
import { CLIProxyProvider } from './types';
import { QuotaResult, fetchAccountQuota } from './quota-fetcher';
import {
getDefaultAccount,
getProviderAccounts,
isAccountPaused,
setDefaultAccount,
touchAccount,
type AccountInfo,
} from './account-manager';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
// ============================================================================
// QUOTA CACHE (30-second TTL)
// ============================================================================
interface CacheEntry {
result: QuotaResult;
timestamp: number;
}
const CACHE_TTL_MS = 30_000; // 30 seconds
const quotaCache = new Map<string, CacheEntry>();
// Request deduplication: track in-flight fetch promises to avoid parallel duplicate requests
const pendingFetches = new Map<string, Promise<QuotaResult>>();
function getCacheKey(provider: CLIProxyProvider, accountId: string): string {
return `${provider}:${accountId}`;
}
/**
* Get cached quota result if still valid
*/
export function getCachedQuota(provider: CLIProxyProvider, accountId: string): QuotaResult | null {
const key = getCacheKey(provider, accountId);
const entry = quotaCache.get(key);
if (!entry) return null;
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
quotaCache.delete(key);
return null;
}
return entry.result;
}
/**
* Cache quota result
*/
export function setCachedQuota(
provider: CLIProxyProvider,
accountId: string,
result: QuotaResult
): void {
const key = getCacheKey(provider, accountId);
quotaCache.set(key, { result, timestamp: Date.now() });
}
/**
* Clear all cached quota results
*/
export function clearQuotaCache(): void {
quotaCache.clear();
}
/**
* Fetch quota with request deduplication
* If a fetch for this account is already in progress, return the existing promise
*/
async function fetchQuotaWithDedup(
provider: CLIProxyProvider,
accountId: string
): Promise<QuotaResult> {
const key = getCacheKey(provider, accountId);
// Check if fetch already in progress
const pending = pendingFetches.get(key);
if (pending) {
return pending;
}
// Start new fetch and track it
const fetchPromise = fetchAccountQuota(provider, accountId)
.then((result) => {
setCachedQuota(provider, accountId, result);
return result;
})
.catch((): QuotaResult => {
return { success: false, models: [], lastUpdated: Date.now() };
})
.finally(() => {
pendingFetches.delete(key);
});
pendingFetches.set(key, fetchPromise);
return fetchPromise;
}
// ============================================================================
// COOLDOWN TRACKING
// ============================================================================
interface CooldownEntry {
until: number; // timestamp when cooldown expires
}
const cooldownMap = new Map<string, CooldownEntry>();
/**
* Check if account is on cooldown
*/
export function isOnCooldown(provider: CLIProxyProvider, accountId: string): boolean {
const key = getCacheKey(provider, accountId);
const entry = cooldownMap.get(key);
if (!entry) return false;
if (Date.now() > entry.until) {
cooldownMap.delete(key);
return false;
}
return true;
}
/**
* Apply cooldown to an exhausted account
*/
export function applyCooldown(
provider: CLIProxyProvider,
accountId: string,
minutes: number
): void {
const key = getCacheKey(provider, accountId);
cooldownMap.set(key, { until: Date.now() + minutes * 60 * 1000 });
}
/**
* Clear cooldown for an account
*/
export function clearCooldown(provider: CLIProxyProvider, accountId: string): void {
const key = getCacheKey(provider, accountId);
cooldownMap.delete(key);
}
// ============================================================================
// PRE-FLIGHT CHECK
// ============================================================================
/**
* Result of pre-flight quota check
*/
export interface PreflightResult {
/** Whether to proceed with session */
proceed: boolean;
/** Account to use (may differ from original default) */
accountId: string;
/** If switched, the original account ID */
switchedFrom?: string;
/** Reason for switch or failure */
reason?: string;
/** Average quota percentage of selected account */
quotaPercent?: number;
}
/**
* Calculate average quota percentage from models
*/
function calculateAverageQuota(quota: QuotaResult): number {
if (!quota.success || quota.models.length === 0) {
return 100; // Assume OK if no data
}
const total = quota.models.reduce((sum, m) => sum + m.percentage, 0);
return total / quota.models.length;
}
/**
* Find healthy account with remaining quota
* Respects tier priority and skips paused/cooldown accounts
*/
export async function findHealthyAccount(
provider: CLIProxyProvider,
exclude: string[]
): Promise<{ id: string; tier: string; lastQuota: number } | null> {
const config = loadOrCreateUnifiedConfig();
const tierPriority = config.quota_management?.auto?.tier_priority ?? ['paid'];
const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5;
const accounts = getProviderAccounts(provider);
// Filter available accounts
const available = accounts.filter(
(a) =>
!exclude.includes(a.id) && !isAccountPaused(provider, a.id) && !isOnCooldown(provider, a.id)
);
if (available.length === 0) return null;
// Fetch quota for each available account (with caching and deduplication)
const withQuotas = await Promise.all(
available.map(async (account) => {
let quota = getCachedQuota(provider, account.id);
if (!quota) {
quota = await fetchQuotaWithDedup(provider, account.id);
}
const avgQuota = calculateAverageQuota(quota);
return {
id: account.id,
tier: account.tier || 'paid',
lastQuota: avgQuota,
};
})
);
// Filter by threshold
const healthy = withQuotas.filter((a) => a.lastQuota >= threshold);
if (healthy.length === 0) return null;
// Sort by tier priority then quota descending
healthy.sort((a, b) => {
const tierA = tierPriority.indexOf(a.tier);
const tierB = tierPriority.indexOf(b.tier);
const tierOrderA = tierA === -1 ? 999 : tierA;
const tierOrderB = tierB === -1 ? 999 : tierB;
if (tierOrderA !== tierOrderB) return tierOrderA - tierOrderB;
return b.lastQuota - a.lastQuota;
});
return healthy[0];
}
/**
* Find and switch to a healthy account
*/
async function findAndSwitch(
provider: CLIProxyProvider,
excludeAccountId: string,
reason: string
): Promise<PreflightResult> {
const alternative = await findHealthyAccount(provider, [excludeAccountId]);
if (!alternative) {
// No alternatives: use original anyway (graceful degradation)
return {
proceed: true,
accountId: excludeAccountId,
reason: `${reason}, no alternatives available`,
};
}
// Switch default
setDefaultAccount(provider, alternative.id);
touchAccount(provider, alternative.id);
return {
proceed: true,
accountId: alternative.id,
switchedFrom: excludeAccountId,
reason,
quotaPercent: alternative.lastQuota,
};
}
/**
* Perform pre-flight quota check before session start
*
* Checks if default account has sufficient quota, auto-switches if needed.
* Respects paused accounts, tier priority, and cooldown settings.
*
* @param provider - CLIProxy provider (only 'agy' supports quota)
* @returns PreflightResult with account to use and any switch info
*/
export async function preflightCheck(provider: CLIProxyProvider): Promise<PreflightResult> {
// Only Antigravity supports quota checking
if (provider !== 'agy') {
const defaultAccount = getDefaultAccount(provider);
return { proceed: true, accountId: defaultAccount?.id || '' };
}
const config = loadOrCreateUnifiedConfig();
const quotaConfig = config.quota_management;
// Skip if preflight disabled or mode is manual
if (!quotaConfig?.auto?.preflight_check || quotaConfig?.mode === 'manual') {
const defaultAccount = getDefaultAccount(provider);
return { proceed: true, accountId: defaultAccount?.id || '' };
}
const defaultAccount = getDefaultAccount(provider);
if (!defaultAccount) {
return { proceed: false, accountId: '', reason: 'No accounts configured' };
}
// Check forced_default override (manual mode)
const forcedDefault = quotaConfig.manual?.forced_default;
if (forcedDefault) {
const forcedAccount = getProviderAccounts(provider).find((a) => a.id === forcedDefault);
if (forcedAccount) {
return { proceed: true, accountId: forcedAccount.id, reason: 'Forced default override' };
}
}
// Check if default is paused
if (isAccountPaused(provider, defaultAccount.id)) {
return await findAndSwitch(provider, defaultAccount.id, 'Default account is paused');
}
// Check cooldown
if (isOnCooldown(provider, defaultAccount.id)) {
return await findAndSwitch(provider, defaultAccount.id, 'Default account on cooldown');
}
// Check quota (with cache and deduplication)
let quota = getCachedQuota(provider, defaultAccount.id);
if (!quota) {
quota = await fetchQuotaWithDedup(provider, defaultAccount.id);
}
// Calculate average quota
const avgQuota = calculateAverageQuota(quota);
const threshold = quotaConfig.auto?.exhaustion_threshold ?? 5;
if (avgQuota < threshold) {
// Apply cooldown to exhausted account
applyCooldown(provider, defaultAccount.id, quotaConfig.auto?.cooldown_minutes ?? 5);
return await findAndSwitch(
provider,
defaultAccount.id,
`Quota exhausted (${avgQuota.toFixed(1)}%)`
);
}
return {
proceed: true,
accountId: defaultAccount.id,
quotaPercent: avgQuota,
};
}
/**
* Get quota status for all accounts of a provider
* Used by CLI status command
*/
export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
accounts: Array<{
account: AccountInfo;
quota: number;
paused: boolean;
onCooldown: boolean;
isDefault: boolean;
}>;
}> {
const accounts = getProviderAccounts(provider);
const defaultAccount = getDefaultAccount(provider);
const results = await Promise.all(
accounts.map(async (account) => {
let quota = getCachedQuota(provider, account.id);
if (!quota && provider === 'agy') {
quota = await fetchQuotaWithDedup(provider, account.id);
}
const avgQuota = quota ? calculateAverageQuota(quota) : 100;
return {
account,
quota: avgQuota,
paused: isAccountPaused(provider, account.id),
onCooldown: isOnCooldown(provider, account.id),
isDefault: defaultAccount?.id === account.id,
};
})
);
return { accounts: results };
}
+234 -1
View File
@@ -20,8 +20,15 @@
import * as path from 'path';
import { getAllAuthStatus, getOAuthConfig, triggerOAuth } from '../cliproxy/auth-handler';
import { getProviderAccounts } from '../cliproxy/account-manager';
import {
getProviderAccounts,
setDefaultAccount,
pauseAccount,
resumeAccount,
findAccountByQuery,
} from '../cliproxy/account-manager';
import { fetchAllProviderQuotas } from '../cliproxy/quota-fetcher';
import { isOnCooldown } from '../cliproxy/quota-manager';
import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector';
import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector';
import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../cliproxy/model-catalog';
@@ -544,6 +551,15 @@ async function showHelp(): Promise<void> {
['remove <name>', 'Remove a CLIProxy variant profile'],
],
],
[
'Quota Management:',
[
['default <account>', 'Set default account for rotation'],
['pause <account>', 'Pause account (skip in rotation)'],
['resume <account>', 'Resume paused account'],
['quota', 'Show quota status for all accounts'],
],
],
[
'Proxy Lifecycle:',
[
@@ -691,6 +707,202 @@ function formatQuotaBar(percentage: number): string {
return `[${filledChar.repeat(filled)}${' '.repeat(empty)}]`;
}
// ============================================================================
// QUOTA MANAGEMENT COMMANDS
// ============================================================================
async function handleSetDefault(args: string[]): Promise<void> {
await initUI();
const parsed = parseProfileArgs(args);
if (!parsed.name) {
console.log(fail('Usage: ccs cliproxy default <account> [--provider <provider>]'));
console.log('');
console.log('Examples:');
console.log(' ccs cliproxy default ultra@gmail.com');
console.log(' ccs cliproxy default john --provider agy');
process.exit(1);
}
const provider = (parsed.provider || 'agy') as CLIProxyProvider;
const account = findAccountByQuery(provider, parsed.name);
if (!account) {
console.log(fail(`Account not found: ${parsed.name}`));
console.log('');
const accounts = getProviderAccounts(provider);
if (accounts.length > 0) {
console.log('Available accounts:');
for (const acc of accounts) {
const badge = acc.isDefault ? color(' (current default)', 'info') : '';
console.log(` - ${acc.email || acc.id}${badge}`);
}
} else {
console.log(`No accounts found for provider: ${provider}`);
console.log(`Run: ccs ${provider} --auth`);
}
process.exit(1);
}
const success = setDefaultAccount(provider, account.id);
if (success) {
console.log(ok(`Default account set to: ${account.email || account.id}`));
console.log(info(`Provider: ${provider}`));
} else {
console.log(fail('Failed to set default account'));
process.exit(1);
}
}
async function handlePauseAccount(args: string[]): Promise<void> {
await initUI();
const parsed = parseProfileArgs(args);
if (!parsed.name) {
console.log(fail('Usage: ccs cliproxy pause <account> [--provider <provider>]'));
console.log('');
console.log('Pauses an account so it will be skipped in quota rotation.');
process.exit(1);
}
const provider = (parsed.provider || 'agy') as CLIProxyProvider;
const account = findAccountByQuery(provider, parsed.name);
if (!account) {
console.log(fail(`Account not found: ${parsed.name}`));
process.exit(1);
}
if (account.paused) {
console.log(warn(`Account already paused: ${account.email || account.id}`));
console.log(info(`Paused at: ${account.pausedAt || 'unknown'}`));
return;
}
const success = pauseAccount(provider, account.id);
if (success) {
console.log(ok(`Account paused: ${account.email || account.id}`));
console.log(info('Account will be skipped in quota rotation'));
} else {
console.log(fail('Failed to pause account'));
process.exit(1);
}
}
async function handleResumeAccount(args: string[]): Promise<void> {
await initUI();
const parsed = parseProfileArgs(args);
if (!parsed.name) {
console.log(fail('Usage: ccs cliproxy resume <account> [--provider <provider>]'));
console.log('');
console.log('Resumes a paused account for quota rotation.');
process.exit(1);
}
const provider = (parsed.provider || 'agy') as CLIProxyProvider;
const account = findAccountByQuery(provider, parsed.name);
if (!account) {
console.log(fail(`Account not found: ${parsed.name}`));
process.exit(1);
}
if (!account.paused) {
console.log(warn(`Account is not paused: ${account.email || account.id}`));
return;
}
const success = resumeAccount(provider, account.id);
if (success) {
console.log(ok(`Account resumed: ${account.email || account.id}`));
console.log(info('Account is now active in quota rotation'));
} else {
console.log(fail('Failed to resume account'));
process.exit(1);
}
}
async function handleQuotaStatus(): Promise<void> {
await initUI();
console.log(header('Quota Status'));
console.log('');
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(dim('Fetching quotas...'));
const quotaResult = await fetchAllProviderQuotas(provider);
// Build table rows
const rows: string[][] = [];
for (const account of accounts) {
const quotaData = quotaResult.accounts.find((q) => q.account.id === account.id);
const quota = quotaData?.quota;
// Calculate average quota
let avgQuota = 'N/A';
if (quota?.success && quota.models.length > 0) {
const avg = Math.round(
quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length
);
avgQuota = `${avg}%`;
}
// Build status badges
const statusParts: string[] = [];
if (account.paused) statusParts.push(color('PAUSED', 'warning'));
if (isOnCooldown(provider, account.id)) statusParts.push(color('COOLDOWN', 'warning'));
const defaultMark = account.isDefault ? color('*', 'success') : ' ';
const tier = account.tier || 'unknown';
const status = statusParts.join(', ');
rows.push([
defaultMark,
account.nickname || account.email || account.id,
tier,
avgQuota,
status,
]);
}
console.log('');
console.log(
table(rows, {
head: ['', 'Account', 'Tier', 'Quota', 'Status'],
colWidths: [3, 30, 10, 10, 20],
})
);
console.log('');
console.log(info(`Default account marked with ${color('*', 'success')}`));
console.log('');
// Show summary of paused/cooldown accounts
const pausedCount = accounts.filter((a) => a.paused).length;
const cooldownCount = accounts.filter((a) => isOnCooldown(provider, a.id)).length;
if (pausedCount > 0) {
console.log(
warn(`${pausedCount} account(s) paused - use 'ccs cliproxy resume <account>' to re-enable`)
);
}
if (cooldownCount > 0) {
console.log(info(`${cooldownCount} account(s) on cooldown (exhausted recently)`));
}
if (pausedCount > 0 || cooldownCount > 0) {
console.log('');
}
}
// ============================================================================
// MAIN ROUTER
// ============================================================================
@@ -734,6 +946,27 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
return;
}
// Quota management commands
if (command === 'default') {
await handleSetDefault(args.slice(1));
return;
}
if (command === 'pause') {
await handlePauseAccount(args.slice(1));
return;
}
if (command === 'resume') {
await handleResumeAccount(args.slice(1));
return;
}
if (command === 'quota') {
await handleQuotaStatus();
return;
}
const installIdx = args.indexOf('--install');
if (installIdx !== -1) {
let version = args[installIdx + 1];
+4
View File
@@ -259,6 +259,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['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'],
['', ''], // Spacer
['ccs cliproxy pause <p> <a>', 'Pause account from rotation'],
['ccs cliproxy resume <p> <a>', 'Resume paused account'],
['ccs cliproxy status [provider]', 'Show quota/tier/pause status'],
]);
// CLI Proxy configuration flags (new)
+30
View File
@@ -17,6 +17,7 @@ import {
DEFAULT_COPILOT_CONFIG,
DEFAULT_GLOBAL_ENV,
DEFAULT_CLIPROXY_SERVER_CONFIG,
DEFAULT_QUOTA_MANAGEMENT_CONFIG,
GlobalEnvConfig,
} from './unified-config-types';
import { isUnifiedConfigEnabled } from './feature-flags';
@@ -212,6 +213,35 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
DEFAULT_CLIPROXY_SERVER_CONFIG.local.auto_start,
},
},
// Quota management config - hybrid auto+manual account selection
quota_management: {
mode: partial.quota_management?.mode ?? DEFAULT_QUOTA_MANAGEMENT_CONFIG.mode,
auto: {
preflight_check:
partial.quota_management?.auto?.preflight_check ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.preflight_check,
exhaustion_threshold:
partial.quota_management?.auto?.exhaustion_threshold ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.exhaustion_threshold,
tier_priority:
partial.quota_management?.auto?.tier_priority ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.tier_priority,
cooldown_minutes:
partial.quota_management?.auto?.cooldown_minutes ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.cooldown_minutes,
},
manual: {
paused_accounts:
partial.quota_management?.manual?.paused_accounts ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.paused_accounts,
forced_default:
partial.quota_management?.manual?.forced_default ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.forced_default,
tier_lock:
partial.quota_management?.manual?.tier_lock ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.tier_lock,
},
},
};
}
+87 -2
View File
@@ -16,8 +16,9 @@
* Version 4 = Copilot API integration (GitHub Copilot proxy)
* Version 5 = Remote proxy configuration (connect to remote CLIProxyAPI)
* Version 6 = Customizable auth tokens (API key and management secret)
* Version 7 = Quota management for hybrid auto+manual account control
*/
export const UNIFIED_CONFIG_VERSION = 6;
export const UNIFIED_CONFIG_VERSION = 7;
/**
* Account configuration (formerly in profiles.json).
@@ -342,12 +343,93 @@ export interface WebSearchConfig {
customMcp?: unknown[];
}
// ============================================================================
// QUOTA MANAGEMENT CONFIGURATION (v7+)
// ============================================================================
/**
* Auto quota management configuration.
* Controls automatic failover behavior.
*/
export interface AutoQuotaConfig {
/** Enable pre-flight quota check before requests (default: true) */
preflight_check: boolean;
/** Quota percentage below which account is "exhausted" (default: 5) */
exhaustion_threshold: number;
/** Tier priority for failover, highest to lowest (default: ['paid']) */
tier_priority: string[];
/** Minutes to skip exhausted account before retry (default: 5) */
cooldown_minutes: number;
}
/**
* Manual quota management configuration.
* User-controlled overrides for account selection.
*/
export interface ManualQuotaConfig {
/** User-paused accounts (stored in accounts.json) */
paused_accounts: string[];
/** Force use of specific account (overrides auto-selection) */
forced_default: string | null;
/** Lock to specific tier only */
tier_lock: string | null;
}
/**
* Quota management mode.
* - auto: Fully automatic failover based on quota
* - manual: User controls everything, no auto-switching
* - hybrid: Auto-failover with user overrides (default)
*/
export type QuotaManagementMode = 'auto' | 'manual' | 'hybrid';
/**
* Quota management configuration section.
* Controls hybrid auto+manual account selection for multi-account setups.
*/
export interface QuotaManagementConfig {
/** Management mode (default: hybrid) */
mode: QuotaManagementMode;
/** Auto mode settings */
auto: AutoQuotaConfig;
/** Manual mode settings */
manual: ManualQuotaConfig;
}
/**
* Default auto quota configuration.
*/
export const DEFAULT_AUTO_QUOTA_CONFIG: AutoQuotaConfig = {
preflight_check: true,
exhaustion_threshold: 5,
tier_priority: ['paid'],
cooldown_minutes: 5,
};
/**
* Default manual quota configuration.
*/
export const DEFAULT_MANUAL_QUOTA_CONFIG: ManualQuotaConfig = {
paused_accounts: [],
forced_default: null,
tier_lock: null,
};
/**
* Default quota management configuration.
*/
export const DEFAULT_QUOTA_MANAGEMENT_CONFIG: QuotaManagementConfig = {
mode: 'hybrid',
auto: { ...DEFAULT_AUTO_QUOTA_CONFIG },
manual: { ...DEFAULT_MANUAL_QUOTA_CONFIG },
};
/**
* Main unified configuration structure.
* Stored in ~/.ccs/config.yaml
*/
export interface UnifiedConfig {
/** Config version (5 for remote proxy support) */
/** Config version (7 for quota management) */
version: number;
/** Default profile name to use when none specified */
default?: string;
@@ -367,6 +449,8 @@ export interface UnifiedConfig {
copilot?: CopilotConfig;
/** CLIProxy server configuration for remote/local mode */
cliproxy_server?: CliproxyServerConfig;
/** Quota management configuration (v7+) */
quota_management?: QuotaManagementConfig;
}
/**
@@ -455,6 +539,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
},
copilot: { ...DEFAULT_COPILOT_CONFIG },
cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG },
quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG },
};
}
+15 -1
View File
@@ -32,8 +32,22 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
// JSON body parsing
// JSON body parsing with error handler for malformed JSON
app.use(express.json());
app.use(
(
err: Error & { status?: number; body?: string },
_req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
res.status(400).json({ error: 'Invalid JSON in request body' });
return;
}
next(err);
}
);
// REST API routes (modularized)
const { apiRoutes } = await import('./routes/index');
@@ -23,6 +23,8 @@ import {
getProviderAccounts,
setDefaultAccount as setDefaultAccountFn,
removeAccount as removeAccountFn,
pauseAccount as pauseAccountFn,
resumeAccount as resumeAccountFn,
touchAccount,
PROVIDERS_WITHOUT_EMAIL,
validateNickname,
@@ -271,6 +273,71 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v
}
});
/**
* POST /api/cliproxy/accounts/:provider/:accountId/pause - Pause an account
* Paused accounts are skipped during quota rotation
*/
router.post('/accounts/:provider/:accountId/pause', (req: Request, res: Response): void => {
const target = getProxyTarget();
if (target.isRemote) {
res.status(501).json({ error: 'Account management not available in remote mode' });
return;
}
const { provider, accountId } = req.params;
if (!validProviders.includes(provider as CLIProxyProvider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
try {
const success = pauseAccountFn(provider as CLIProxyProvider, accountId);
if (success) {
res.json({ provider, accountId, paused: true });
} else {
res
.status(404)
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to pause account';
res.status(500).json({ error: message });
}
});
/**
* POST /api/cliproxy/accounts/:provider/:accountId/resume - Resume a paused account
*/
router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Response): void => {
const target = getProxyTarget();
if (target.isRemote) {
res.status(501).json({ error: 'Account management not available in remote mode' });
return;
}
const { provider, accountId } = req.params;
if (!validProviders.includes(provider as CLIProxyProvider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
try {
const success = resumeAccountFn(provider as CLIProxyProvider, accountId);
if (success) {
res.json({ provider, accountId, paused: false });
} else {
res
.status(404)
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to resume account';
res.status(500).json({ error: message });
}
});
/**
* POST /api/cliproxy/auth/:provider/start - Start OAuth flow for a provider
* Opens browser for authentication and returns account info when complete
@@ -22,6 +22,8 @@ import {
Loader2,
CheckCircle2,
HelpCircle,
Pause,
Play,
} from 'lucide-react';
import {
cn,
@@ -86,7 +88,9 @@ export function AccountItem({
account,
onSetDefault,
onRemove,
onPauseToggle,
isRemoving,
isPausingAccount,
privacyMode,
showQuota,
}: AccountItemProps) {
@@ -139,6 +143,27 @@ export function AccountItem({
Default
</Badge>
)}
{account.tier && account.tier !== 'unknown' && (
<Badge
variant="outline"
className={cn(
'text-[10px] h-4 px-1.5 uppercase',
account.tier === 'paid' && 'border-blue-500 text-blue-600',
account.tier === 'free' && 'border-gray-400 text-gray-500'
)}
>
{account.tier}
</Badge>
)}
{account.paused && (
<Badge
variant="outline"
className="text-[10px] h-4 px-1.5 border-yellow-500 text-yellow-600"
>
<Pause className="w-2 h-2 mr-0.5" />
Paused
</Badge>
)}
</div>
{account.lastUsedAt && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
@@ -162,6 +187,24 @@ export function AccountItem({
Set as default
</DropdownMenuItem>
)}
{onPauseToggle && (
<DropdownMenuItem
onClick={() => onPauseToggle(!account.paused)}
disabled={isPausingAccount}
>
{account.paused ? (
<>
<Play className="w-4 h-4 mr-2" />
{isPausingAccount ? 'Resuming...' : 'Resume account'}
</>
) : (
<>
<Pause className="w-4 h-4 mr-2" />
{isPausingAccount ? 'Pausing...' : 'Pause account'}
</>
)}
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onRemove}
@@ -15,7 +15,10 @@ interface AccountsSectionProps {
onAddAccount: () => void;
onSetDefault: (accountId: string) => void;
onRemoveAccount: (accountId: string) => void;
onPauseToggle?: (accountId: string, paused: boolean) => void;
isRemovingAccount?: boolean;
/** Pause/resume mutation in progress */
isPausingAccount?: boolean;
privacyMode?: boolean;
/** Show quota bars for accounts (only applicable for 'agy' provider) */
showQuota?: boolean;
@@ -31,7 +34,9 @@ export function AccountsSection({
onAddAccount,
onSetDefault,
onRemoveAccount,
onPauseToggle,
isRemovingAccount,
isPausingAccount,
privacyMode,
showQuota,
isKiro,
@@ -65,7 +70,11 @@ export function AccountsSection({
account={account}
onSetDefault={() => onSetDefault(account.id)}
onRemove={() => onRemoveAccount(account.id)}
onPauseToggle={
onPauseToggle ? (paused) => onPauseToggle(account.id, paused) : undefined
}
isRemoving={isRemovingAccount}
isPausingAccount={isPausingAccount}
privacyMode={privacyMode}
showQuota={showQuota}
/>
@@ -38,7 +38,9 @@ export function ProviderEditor({
onAddAccount,
onSetDefault,
onRemoveAccount,
onPauseToggle,
isRemovingAccount,
isPausingAccount,
}: ProviderEditorProps) {
const [customPresetOpen, setCustomPresetOpen] = useState(false);
const { privacyMode } = usePrivacy();
@@ -200,7 +202,9 @@ export function ProviderEditor({
onAddAccount={onAddAccount}
onSetDefault={onSetDefault}
onRemoveAccount={onRemoveAccount}
onPauseToggle={onPauseToggle}
isRemovingAccount={isRemovingAccount}
isPausingAccount={isPausingAccount}
privacyMode={privacyMode}
isRemoteMode={isRemoteMode}
/>
@@ -36,7 +36,10 @@ interface ModelConfigTabProps {
onAddAccount: () => void;
onSetDefault: (accountId: string) => void;
onRemoveAccount: (accountId: string) => void;
onPauseToggle?: (accountId: string, paused: boolean) => void;
isRemovingAccount?: boolean;
/** Pause/resume mutation in progress */
isPausingAccount?: boolean;
privacyMode?: boolean;
/** True if connected to remote CLIProxy (quota not available) */
isRemoteMode?: boolean;
@@ -60,7 +63,9 @@ export function ModelConfigTab({
onAddAccount,
onSetDefault,
onRemoveAccount,
onPauseToggle,
isRemovingAccount,
isPausingAccount,
privacyMode,
isRemoteMode,
}: ModelConfigTabProps) {
@@ -134,7 +139,9 @@ export function ModelConfigTab({
onAddAccount={onAddAccount}
onSetDefault={onSetDefault}
onRemoveAccount={onRemoveAccount}
onPauseToggle={onPauseToggle}
isRemovingAccount={isRemovingAccount}
isPausingAccount={isPausingAccount}
privacyMode={privacyMode}
showQuota={provider === 'agy' && !isRemoteMode}
isKiro={isKiro}
@@ -30,14 +30,20 @@ export interface ProviderEditorProps {
onAddAccount: () => void;
onSetDefault: (accountId: string) => void;
onRemoveAccount: (accountId: string) => void;
onPauseToggle?: (accountId: string, paused: boolean) => void;
isRemovingAccount?: boolean;
/** Pause/resume mutation in progress */
isPausingAccount?: boolean;
}
export interface AccountItemProps {
account: OAuthAccount;
onSetDefault: () => void;
onRemove: () => void;
onPauseToggle?: (paused: boolean) => void;
isRemoving?: boolean;
/** Pause/resume mutation in progress */
isPausingAccount?: boolean;
privacyMode?: boolean;
/** Show quota bar (only for 'agy' provider) */
showQuota?: boolean;
+34
View File
@@ -118,6 +118,40 @@ export function useRemoveAccount() {
});
}
export function usePauseAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) =>
api.cliproxy.accounts.pause(provider, accountId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
toast.success('Account paused');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
export function useResumeAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) =>
api.cliproxy.accounts.resume(provider, accountId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
toast.success('Account resumed');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
// OAuth flow hook
export function useStartAuth() {
const queryClient = useQueryClient();
+17
View File
@@ -71,11 +71,18 @@ export interface UpdateVariant {
export interface OAuthAccount {
id: string;
email?: string;
nickname?: string;
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp';
isDefault: boolean;
tokenFile: string;
createdAt: string;
lastUsedAt?: string;
/** Whether account is paused (skipped in quota rotation) */
paused?: boolean;
/** ISO timestamp when account was paused */
pausedAt?: string;
/** Account tier: free or paid (Pro/Ultra combined) */
tier?: 'free' | 'paid' | 'unknown';
}
export interface AuthStatus {
@@ -392,6 +399,16 @@ export const api = {
}),
remove: (provider: string, accountId: string) =>
request(`/cliproxy/auth/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
pause: (provider: string, accountId: string) =>
request<{ provider: string; accountId: string; paused: boolean }>(
`/cliproxy/auth/accounts/${provider}/${accountId}/pause`,
{ method: 'POST' }
),
resume: (provider: string, accountId: string) =>
request<{ provider: string; accountId: string; paused: boolean }>(
`/cliproxy/auth/accounts/${provider}/${accountId}/resume`,
{ method: 'POST' }
),
},
// OAuth flow
auth: {
+22
View File
@@ -21,6 +21,8 @@ import {
useCliproxyAuth,
useSetDefaultAccount,
useRemoveAccount,
usePauseAccount,
useResumeAccount,
useDeleteVariant,
} from '@/hooks/use-cliproxy';
import type { AuthStatus, Variant } from '@/lib/api-client';
@@ -179,6 +181,8 @@ export function CliproxyPage() {
const { data: variantsData, isFetching } = useCliproxy();
const setDefaultMutation = useSetDefaultAccount();
const removeMutation = useRemoveAccount();
const pauseMutation = usePauseAccount();
const resumeMutation = useResumeAccount();
const deleteMutation = useDeleteVariant();
// Selection state: either a provider or a variant
@@ -216,6 +220,16 @@ export function CliproxyPage() {
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
};
const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => {
// Prevent rapid clicks while mutation is pending
if (pauseMutation.isPending || resumeMutation.isPending) return;
if (paused) {
pauseMutation.mutate({ provider, accountId });
} else {
resumeMutation.mutate({ provider, accountId });
}
};
const handleSelectProvider = (provider: string) => {
setSelectedProvider(provider);
setSelectedVariant(null);
@@ -361,7 +375,11 @@ export function CliproxyPage() {
accountId,
})
}
onPauseToggle={(accountId, paused) =>
handlePauseToggle(selectedVariantData.provider, accountId, paused)
}
isRemovingAccount={removeMutation.isPending}
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
/>
) : selectedStatus ? (
<ProviderEditor
@@ -389,7 +407,11 @@ export function CliproxyPage() {
accountId,
})
}
onPauseToggle={(accountId, paused) =>
handlePauseToggle(selectedStatus.provider, accountId, paused)
}
isRemovingAccount={removeMutation.isPending}
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
/>
) : (
<EmptyProviderState onSetup={() => setWizardOpen(true)} />