From d96c67ba810fb933f4a26bf43b6c011e44ed5d47 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 12:01:38 -0500 Subject: [PATCH 01/22] fix(cliproxy): use nickname as accountId for kiro/ghcp providers Kiro/GHCP OAuth tokens have empty email field, causing all accounts to use accountId='default' and overwrite each other. This fix: - Add PROVIDERS_WITHOUT_EMAIL constant for kiro/ghcp identification - Require nickname for kiro/ghcp during registration (CLI + web UI) - Use nickname as accountId instead of email for these providers - Enforce nickname uniqueness to prevent collisions - Update discoverExistingAccounts() to generate unique IDs (kiro-1, etc.) Closes #258, #267 --- src/cliproxy/account-manager.ts | 99 +++++++++++++++++-- src/cliproxy/auth/oauth-handler.ts | 80 ++++++++++++++- src/web-server/routes/cliproxy-auth-routes.ts | 35 +++++++ .../cliproxy/account-manager-discover.test.js | 71 ++++++++++++- 4 files changed, 271 insertions(+), 14 deletions(-) diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index a65947c9..2fced28c 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -14,6 +14,12 @@ import { CLIProxyProvider } from './types'; import { getCliproxyDir, getAuthDir } from './config-generator'; import { PROVIDER_TYPE_VALUES } from './auth/auth-types'; +/** + * Providers that typically have empty email in OAuth token files. + * For these providers, nickname is used as accountId instead of email. + */ +export const PROVIDERS_WITHOUT_EMAIL: CLIProxyProvider[] = ['kiro', 'ghcp']; + /** Account information */ export interface AccountInfo { /** Account identifier (email or custom name) */ @@ -257,6 +263,14 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A /** * Register a new account * Called after successful OAuth to record the account + * + * For providers without email (kiro, ghcp): + * - nickname is REQUIRED and used as accountId + * - Uniqueness is enforced to prevent overwriting + * + * For providers with email: + * - email is used as accountId + * - nickname is auto-generated from email if not provided */ export function registerAccount( provider: CLIProxyProvider, @@ -279,12 +293,44 @@ export function registerAccount( throw new Error('Failed to initialize provider accounts'); } - // Determine account ID - use email if available, otherwise extract from filename - const accountId = extractAccountIdFromTokenFile(tokenFile, email); - const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; + // Determine account ID based on provider type + let accountId: string; + let accountNickname: string; - // Generate nickname if not provided - const accountNickname = nickname || generateNickname(email); + if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) { + // For kiro/ghcp: nickname is REQUIRED and used as accountId + if (!nickname || nickname === 'default') { + throw new Error( + `Nickname is required when adding ${provider} accounts. ` + + `Use --nickname or provide a nickname in the UI.` + ); + } + + // Validate nickname format + const validationError = validateNickname(nickname); + if (validationError) { + throw new Error(validationError); + } + + // Check uniqueness + for (const [existingId, _account] of Object.entries(providerAccounts.accounts)) { + if (existingId.toLowerCase() === nickname.toLowerCase()) { + throw new Error( + `An account with nickname "${nickname}" already exists for ${provider}. ` + + `Choose a different nickname.` + ); + } + } + + accountId = nickname; + accountNickname = nickname; + } else { + // For other providers: use email as accountId, fallback to filename extraction + accountId = extractAccountIdFromTokenFile(tokenFile, email); + accountNickname = nickname || generateNickname(email); + } + + const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; // Create or update account providerAccounts.accounts[accountId] = { @@ -428,6 +474,10 @@ export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: stri /** * Auto-discover accounts from existing token files * Called during migration or first run to populate accounts registry + * + * For kiro/ghcp providers without email, generates unique accountId from: + * 1. OAuth provider + profile ID from filename (e.g., github-ABC123) + * 2. Fallback: provider + index (e.g., kiro-1, kiro-2) */ export function discoverExistingAccounts(): void { const authDir = getAuthDir(); @@ -478,13 +528,10 @@ export function discoverExistingAccounts(): void { } } - // Use unified ID extraction: email or filename-based unique ID - const accountId = extractAccountIdFromTokenFile(file, email); - // Initialize provider section if needed if (!registry.providers[provider]) { registry.providers[provider] = { - default: accountId, + default: 'default', accounts: {}, }; } @@ -492,11 +539,45 @@ export function discoverExistingAccounts(): void { const providerAccounts = registry.providers[provider]; if (!providerAccounts) continue; + // Skip if token file already registered (under any accountId) + const existingTokenFiles = Object.values(providerAccounts.accounts).map((a) => a.tokenFile); + if (existingTokenFiles.includes(file)) { + continue; + } + + // Determine accountId based on provider type + let accountId: string; + + if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email) { + // For kiro/ghcp without email: extract from filename or generate unique + // Pattern: kiro-github-ABC123.json -> github-ABC123 + const filenameId = extractAccountIdFromTokenFile(file, undefined); + + if (filenameId !== 'default') { + accountId = filenameId; + } else { + // Generate unique ID: provider + incrementing index + let index = 1; + while (providerAccounts.accounts[`${provider}-${index}`]) { + index++; + } + accountId = `${provider}-${index}`; + } + } else { + // For providers with email: use email or filename extraction + accountId = extractAccountIdFromTokenFile(file, email); + } + // Skip if account already registered if (providerAccounts.accounts[accountId]) { continue; } + // Set as default if first account + if (Object.keys(providerAccounts.accounts).length === 0) { + providerAccounts.default = accountId; + } + // Get file stats for creation time const stats = fs.statSync(filePath); diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 34ecabbc..fea5b39f 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -20,6 +20,8 @@ import { getProviderAccounts, getDefaultAccount, touchAccount, + PROVIDERS_WITHOUT_EMAIL, + validateNickname, } from '../account-manager'; import { enhancedPreflightOAuthCheck, @@ -49,6 +51,66 @@ async function promptAddAccount(): Promise { }); } +/** + * Prompt user for account nickname (required for kiro/ghcp) + * Returns null if user cancels + */ +async function promptNickname( + provider: CLIProxyProvider, + existingAccounts: AccountInfo[] +): Promise { + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const existingNicknames = existingAccounts.map( + (a) => a.nickname?.toLowerCase() || a.id.toLowerCase() + ); + + console.log(''); + console.log(info(`${provider} accounts require a unique nickname to distinguish them.`)); + if (existingNicknames.length > 0) { + console.log(` Existing: ${existingNicknames.join(', ')}`); + } + + return new Promise((resolve) => { + // Handle Ctrl+C gracefully + rl.on('close', () => resolve(null)); + + const askForNickname = () => { + rl.question('[?] Enter a nickname for this account: ', (answer) => { + const nickname = answer.trim(); + + if (!nickname) { + console.log(fail('Nickname cannot be empty')); + askForNickname(); + return; + } + + const validationError = validateNickname(nickname); + if (validationError) { + console.log(fail(validationError)); + askForNickname(); + return; + } + + if (existingNicknames.includes(nickname.toLowerCase())) { + console.log(fail(`Nickname "${nickname}" is already in use. Choose a different one.`)); + askForNickname(); + return; + } + + rl.close(); + resolve(nickname); + }); + }; + + askForNickname(); + }); +} + /** * Run pre-flight OAuth checks */ @@ -127,7 +189,21 @@ export async function triggerOAuth( options: OAuthOptions = {} ): Promise { const oauthConfig = getOAuthConfig(provider); - const { verbose = false, add = false, nickname, fromUI = false, noIncognito = true } = options; + const { verbose = false, add = false, fromUI = false, noIncognito = true } = options; + let { nickname } = options; + + // Check for existing accounts + const existingAccounts = getProviderAccounts(provider); + + // For kiro/ghcp: require nickname if not provided (CLI only, not fromUI) + if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !nickname && !fromUI) { + const promptedNickname = await promptNickname(provider, existingAccounts); + if (!promptedNickname) { + console.log(info('Cancelled')); + return null; + } + nickname = promptedNickname; + } // Handle --import flag: skip OAuth and import from Kiro IDE directly if (options.import && provider === 'kiro') { @@ -144,8 +220,6 @@ export async function triggerOAuth( const headless = options.headless ?? isHeadlessEnvironment(); const isDeviceCodeFlow = callbackPort === null; - // Check for existing accounts - const existingAccounts = getProviderAccounts(provider); if (existingAccounts.length > 0 && !add) { console.log(''); console.log( diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index baa5d167..306b30ac 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -24,6 +24,8 @@ import { setDefaultAccount as setDefaultAccountFn, removeAccount as removeAccountFn, touchAccount, + PROVIDERS_WITHOUT_EMAIL, + validateNickname, } from '../../cliproxy/account-manager'; import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; @@ -283,6 +285,39 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise a.nickname?.toLowerCase() || a.id.toLowerCase() + ); + if (existingNicknames.includes(nickname.toLowerCase())) { + res.status(400).json({ + error: `Nickname "${nickname}" is already in use. Choose a different one.`, + code: 'NICKNAME_EXISTS', + }); + return; + } + } + // Check Kiro no-incognito setting from config (or request body) // Default to true (use normal browser) for reliability - incognito often fails let noIncognito = true; diff --git a/tests/unit/cliproxy/account-manager-discover.test.js b/tests/unit/cliproxy/account-manager-discover.test.js index ae3cf72a..3f48f1e1 100644 --- a/tests/unit/cliproxy/account-manager-discover.test.js +++ b/tests/unit/cliproxy/account-manager-discover.test.js @@ -164,7 +164,7 @@ describe('Account Manager - discoverExistingAccounts', () => { assert.strictEqual(accountIds[0], 'actual@email.com', 'Should use data.email when available'); }); - it('falls back to "default" when no email in file or filename', () => { + it('generates unique ID for kiro/ghcp when no email in file or filename', () => { createAuthFile('kiro-nomail.json', { type: 'kiro', email: '', @@ -174,7 +174,8 @@ describe('Account Manager - discoverExistingAccounts', () => { const accounts = getAccountsFile(); const accountIds = Object.keys(accounts.providers.kiro.accounts); - assert.strictEqual(accountIds[0], 'default', 'Should fall back to "default"'); + // For kiro/ghcp without email, generates unique ID like "kiro-1" + assert.strictEqual(accountIds[0], 'kiro-1', 'Should generate unique ID for kiro without email'); }); it('handles dots in email local part', () => { @@ -274,6 +275,72 @@ describe('Account Manager - discoverExistingAccounts', () => { 'First account should be default' ); }); + + it('generates sequential IDs for multiple kiro files without email', () => { + // Create multiple kiro files without email in filename + // Files like "kiro-nomail.json" don't match oauth pattern (need 2+ hyphens) + createAuthFile('kiro-account1.json', { + type: 'kiro', + email: '', + }); + createAuthFile('kiro-account2.json', { + type: 'kiro', + email: '', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + const accountIds = Object.keys(accounts.providers.kiro.accounts); + assert.strictEqual(accountIds.length, 2, 'Should have 2 accounts'); + // Both should have kiro-N format since filenames don't match oauth pattern (1 hyphen only) + assert(accountIds.includes('kiro-1'), 'Should have kiro-1'); + assert(accountIds.includes('kiro-2'), 'Should have kiro-2'); + }); + + it('skips to next ID when collision exists', () => { + // Pre-create accounts.json with kiro-1 already registered + const accountsPath = path.join(testDir, '.ccs', 'cliproxy', 'accounts.json'); + const existingRegistry = { + version: 1, + providers: { + kiro: { + default: 'kiro-1', + accounts: { + 'kiro-1': { + tokenFile: 'kiro-existing.json', + createdAt: new Date().toISOString(), + }, + }, + }, + }, + }; + fs.writeFileSync(accountsPath, JSON.stringify(existingRegistry)); + + // Create the existing token file (matches registry) + createAuthFile('kiro-existing.json', { + type: 'kiro', + email: '', + }); + + // Create new file that will need auto-generated ID (single hyphen = no oauth pattern match) + createAuthFile('kiro-newaccount.json', { + type: 'kiro', + email: '', + }); + + // Reload module to pick up pre-existing accounts.json + delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')]; + accountManager = require('../../../dist/cliproxy/account-manager'); + accountManager.discoverExistingAccounts(); + + const accounts = getAccountsFile(); + const accountIds = Object.keys(accounts.providers.kiro.accounts); + + assert.strictEqual(accountIds.length, 2, 'Should have 2 accounts'); + assert(accountIds.includes('kiro-1'), 'Should keep existing kiro-1'); + assert(accountIds.includes('kiro-2'), 'New account should be kiro-2 (skipped kiro-1)'); + }); }); // ========================================================================= From 11ffca33bdeb30b2b3631295ca64a17a480d8954 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 12:17:34 -0500 Subject: [PATCH 02/22] feat(cliproxy): add hybrid quota management core - add paused state and tier field to OAuthAccount - create quota-manager.ts for central quota/tier logic - add tier detection and 30s cache to quota-fetcher - add quota_management schema to unified config Refs #282 --- src/cliproxy/account-manager.ts | 79 ++++++ src/cliproxy/quota-fetcher.ts | 59 ++++- src/cliproxy/quota-manager.ts | 379 +++++++++++++++++++++++++++++ src/config/unified-config-types.ts | 89 ++++++- 4 files changed, 602 insertions(+), 4 deletions(-) create mode 100644 src/cliproxy/quota-manager.ts diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index a65947c9..fc42bc12 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -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 */ +export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; + /** Account information */ export interface AccountInfo { /** Account identifier (email or custom name) */ @@ -32,6 +35,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, pro, ultra */ + tier?: AccountTier; } /** Provider accounts configuration */ @@ -330,6 +339,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 */ diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index d2daa236..6748601d 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -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,6 +111,38 @@ interface TokenRefreshResponse { error_description?: string; } +/** + * Detect account tier from quota API model list. + * Ultra accounts have access to experimental/preview models. + * Pro accounts have standard model access. + * Free/unknown accounts have limited model access. + */ +export function detectTier(quotaResult: QuotaResult): AccountTier { + if (!quotaResult.success || quotaResult.models.length === 0) { + return 'unknown'; + } + + const modelNames = quotaResult.models.map((m) => m.name.toLowerCase()); + + // Ultra indicators: experimental, preview, ultra in name + const ultraIndicators = ['ultra', 'experimental', 'preview', '2.5-pro', '3-ultra']; + const hasUltra = modelNames.some((name) => + ultraIndicators.some((indicator) => name.includes(indicator)) + ); + + if (hasUltra) return 'ultra'; + + // Pro indicators: gemini-2.0, gemini-3, pro models + const proIndicators = ['gemini-2', 'gemini-3', '-pro']; + const hasPro = modelNames.some((name) => + proIndicators.some((indicator) => name.includes(indicator)) + ); + + if (hasPro) return 'pro'; + + return 'free'; +} + /** loadCodeAssist response */ interface LoadCodeAssistResponse { cloudaicompanionProject?: string | { id?: string }; @@ -522,10 +561,26 @@ export async function fetchAccountQuota( 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); + // Detect and persist tier for retry result + if (retryResult.success) { + const tier = detectTier(retryResult); + retryResult.tier = tier; + retryResult.accountId = accountId; + setAccountTier(provider, accountId, tier); + } + return retryResult; } } + // Detect and persist tier for successful result + if (result.success) { + const tier = detectTier(result); + result.tier = tier; + result.accountId = accountId; + setAccountTier(provider, accountId, tier); + } + return result; } diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts new file mode 100644 index 00000000..69a60005 --- /dev/null +++ b/src/cliproxy/quota-manager.ts @@ -0,0 +1,379 @@ +/** + * 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(); + +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(); +} + +// ============================================================================ +// COOLDOWN TRACKING +// ============================================================================ + +interface CooldownEntry { + until: number; // timestamp when cooldown expires +} + +const cooldownMap = new Map(); + +/** + * 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 ?? ['ultra', 'pro']; + 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) + const withQuotas = await Promise.all( + available.map(async (account) => { + let quota = getCachedQuota(provider, account.id); + if (!quota) { + try { + quota = await fetchAccountQuota(provider, account.id); + setCachedQuota(provider, account.id, quota); + } catch { + quota = { success: false, models: [], lastUpdated: Date.now() }; + } + } + + const avgQuota = calculateAverageQuota(quota); + + return { + id: account.id, + tier: account.tier || 'pro', + 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 { + 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 { + // 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) + let quota = getCachedQuota(provider, defaultAccount.id); + if (!quota) { + try { + quota = await fetchAccountQuota(provider, defaultAccount.id); + setCachedQuota(provider, defaultAccount.id, quota); + } catch { + // API failure: proceed anyway (graceful degradation) + return { + proceed: true, + accountId: defaultAccount.id, + reason: 'Quota check failed, proceeding', + }; + } + } + + // 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') { + try { + quota = await fetchAccountQuota(provider, account.id); + setCachedQuota(provider, account.id, quota); + } catch { + quota = { success: false, models: [], lastUpdated: Date.now() }; + } + } + + 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 }; +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 6a20a4d4..ac17e17d 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -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: ['ultra', 'pro']) */ + 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: ['ultra', 'pro'], + 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 }, }; } From 10e3eec16f46b3318dfef5d33dc903cfbf9cae1d Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 12:17:40 -0500 Subject: [PATCH 03/22] feat(cliproxy): integrate pre-flight quota check - skip paused accounts during rotation - check quota before request execution Refs #282 --- src/cliproxy/cliproxy-executor.ts | 39 ++++++++++--------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 6a031402..83086798 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -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 = { @@ -465,35 +465,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)}%`); } } } From b55cd795ab5da18ea5363aa378712b467a17bf22 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 12:17:40 -0500 Subject: [PATCH 04/22] fix(cliproxy): update lastUsedAt on normal execution Previously touchAccount was only called when switching accounts or auto- switching due to quota exhaustion. Now lastUsedAt is updated for every OAuth provider execution, ensuring dashboard shows accurate timestamps. --- src/cliproxy/cliproxy-executor.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 6a031402..5775dedd 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -462,6 +462,13 @@ export async function execClaudeWithCLIProxy( if (tokenResult.refreshed) { log('Token was refreshed proactively'); } + + // 3a-1. Update lastUsedAt for the account being used + // This ensures dashboard shows accurate "Last used" timestamps + const usedAccount = getDefaultAccount(provider); + if (usedAccount) { + touchAccount(provider, usedAccount.id); + } } // 3b. Preflight quota check - auto-switch to account with quota before launch From cfd8dd974e875b858f78bb73e74e44062b72d38e Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 12:17:48 -0500 Subject: [PATCH 05/22] feat(cli): add pause, resume, status subcommands - ccs cliproxy pause - ccs cliproxy resume - ccs cliproxy status [provider] - show quota/tier info Refs #282 --- src/commands/cliproxy-command.ts | 235 ++++++++++++++++++++++++++++++- 1 file changed, 234 insertions(+), 1 deletion(-) diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index 080d271b..c90408a6 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -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 { ['remove ', 'Remove a CLIProxy variant profile'], ], ], + [ + 'Quota Management:', + [ + ['default ', 'Set default account for rotation'], + ['pause ', 'Pause account (skip in rotation)'], + ['resume ', '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 { + await initUI(); + const parsed = parseProfileArgs(args); + + if (!parsed.name) { + console.log(fail('Usage: ccs cliproxy default [--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 { + await initUI(); + const parsed = parseProfileArgs(args); + + if (!parsed.name) { + console.log(fail('Usage: ccs cliproxy pause [--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 { + await initUI(); + const parsed = parseProfileArgs(args); + + if (!parsed.name) { + console.log(fail('Usage: ccs cliproxy resume [--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 { + 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 ' 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 { 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]; From c13003d940a217d22b2b5a027815053ef93d9046 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 12:17:55 -0500 Subject: [PATCH 06/22] feat(api): add pause/resume account endpoints - POST /api/cliproxy/auth/:provider/accounts/:accountId/pause - POST /api/cliproxy/auth/:provider/accounts/:accountId/resume Refs #282 --- src/web-server/routes/cliproxy-auth-routes.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index baa5d167..5446b25e 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -23,6 +23,8 @@ import { getProviderAccounts, setDefaultAccount as setDefaultAccountFn, removeAccount as removeAccountFn, + pauseAccount as pauseAccountFn, + resumeAccount as resumeAccountFn, touchAccount, } from '../../cliproxy/account-manager'; import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; @@ -269,6 +271,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 From b92a35d09b203427a105bc28a487302c8a726f21 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 12:18:02 -0500 Subject: [PATCH 07/22] feat(ui): add pause/resume API hooks - add api.cliproxy.accounts.pause/resume methods - add usePauseAccount, useResumeAccount hooks Refs #282 --- ui/src/hooks/use-cliproxy.ts | 34 ++++++++++++++++++++++++++++++++++ ui/src/lib/api-client.ts | 17 +++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index 13e8efee..3d310f02 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -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(); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index aae75b09..f8ef47e1 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -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, pro, ultra */ + tier?: 'free' | 'pro' | 'ultra' | '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: { From 4ad7292700c991e1d2f8478da4d6ed33ce14982d Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 12:18:12 -0500 Subject: [PATCH 08/22] feat(ui): add pause/resume toggle and tier badges - add pause/resume dropdown menu option in account item - display tier badges (ultra/pro/free) with color coding - show paused badge when account is paused - wire onPauseToggle through component tree Refs #282 --- .../cliproxy/provider-editor/account-item.tsx | 40 +++++++++++++++++++ .../provider-editor/accounts-section.tsx | 5 +++ .../cliproxy/provider-editor/index.tsx | 2 + .../provider-editor/model-config-tab.tsx | 3 ++ .../cliproxy/provider-editor/types.ts | 2 + ui/src/pages/cliproxy.tsx | 18 +++++++++ 6 files changed, 70 insertions(+) diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 0ef2c276..e12d1a29 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -22,6 +22,8 @@ import { Loader2, CheckCircle2, HelpCircle, + Pause, + Play, } from 'lucide-react'; import { cn, @@ -86,6 +88,7 @@ export function AccountItem({ account, onSetDefault, onRemove, + onPauseToggle, isRemoving, privacyMode, showQuota, @@ -139,6 +142,28 @@ export function AccountItem({ Default )} + {account.tier && account.tier !== 'unknown' && ( + + {account.tier} + + )} + {account.paused && ( + + + Paused + + )} {account.lastUsedAt && (
@@ -162,6 +187,21 @@ export function AccountItem({ Set as default )} + {onPauseToggle && ( + onPauseToggle(!account.paused)}> + {account.paused ? ( + <> + + Resume account + + ) : ( + <> + + Pause account + + )} + + )} void; onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; + onPauseToggle?: (accountId: string, paused: boolean) => void; isRemovingAccount?: boolean; privacyMode?: boolean; /** Show quota bars for accounts (only applicable for 'agy' provider) */ @@ -31,6 +32,7 @@ export function AccountsSection({ onAddAccount, onSetDefault, onRemoveAccount, + onPauseToggle, isRemovingAccount, privacyMode, showQuota, @@ -65,6 +67,9 @@ export function AccountsSection({ account={account} onSetDefault={() => onSetDefault(account.id)} onRemove={() => onRemoveAccount(account.id)} + onPauseToggle={ + onPauseToggle ? (paused) => onPauseToggle(account.id, paused) : undefined + } isRemoving={isRemovingAccount} privacyMode={privacyMode} showQuota={showQuota} diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 53338e3c..1cc552fa 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -38,6 +38,7 @@ export function ProviderEditor({ onAddAccount, onSetDefault, onRemoveAccount, + onPauseToggle, isRemovingAccount, }: ProviderEditorProps) { const [customPresetOpen, setCustomPresetOpen] = useState(false); @@ -200,6 +201,7 @@ export function ProviderEditor({ onAddAccount={onAddAccount} onSetDefault={onSetDefault} onRemoveAccount={onRemoveAccount} + onPauseToggle={onPauseToggle} isRemovingAccount={isRemovingAccount} privacyMode={privacyMode} isRemoteMode={isRemoteMode} diff --git a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx index a8552eb2..25ca0306 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -36,6 +36,7 @@ interface ModelConfigTabProps { onAddAccount: () => void; onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; + onPauseToggle?: (accountId: string, paused: boolean) => void; isRemovingAccount?: boolean; privacyMode?: boolean; /** True if connected to remote CLIProxy (quota not available) */ @@ -60,6 +61,7 @@ export function ModelConfigTab({ onAddAccount, onSetDefault, onRemoveAccount, + onPauseToggle, isRemovingAccount, privacyMode, isRemoteMode, @@ -134,6 +136,7 @@ export function ModelConfigTab({ onAddAccount={onAddAccount} onSetDefault={onSetDefault} onRemoveAccount={onRemoveAccount} + onPauseToggle={onPauseToggle} isRemovingAccount={isRemovingAccount} privacyMode={privacyMode} showQuota={provider === 'agy' && !isRemoteMode} diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index 52e5a7d6..80010f9f 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -30,6 +30,7 @@ export interface ProviderEditorProps { onAddAccount: () => void; onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; + onPauseToggle?: (accountId: string, paused: boolean) => void; isRemovingAccount?: boolean; } @@ -37,6 +38,7 @@ export interface AccountItemProps { account: OAuthAccount; onSetDefault: () => void; onRemove: () => void; + onPauseToggle?: (paused: boolean) => void; isRemoving?: boolean; privacyMode?: boolean; /** Show quota bar (only for 'agy' provider) */ diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 872e3832..bd13b9dc 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -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,14 @@ export function CliproxyPage() { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); }; + const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => { + if (paused) { + pauseMutation.mutate({ provider, accountId }); + } else { + resumeMutation.mutate({ provider, accountId }); + } + }; + const handleSelectProvider = (provider: string) => { setSelectedProvider(provider); setSelectedVariant(null); @@ -361,6 +373,9 @@ export function CliproxyPage() { accountId, }) } + onPauseToggle={(accountId, paused) => + handlePauseToggle(selectedVariantData.provider, accountId, paused) + } isRemovingAccount={removeMutation.isPending} /> ) : selectedStatus ? ( @@ -389,6 +404,9 @@ export function CliproxyPage() { accountId, }) } + onPauseToggle={(accountId, paused) => + handlePauseToggle(selectedStatus.provider, accountId, paused) + } isRemovingAccount={removeMutation.isPending} /> ) : ( From dde0b9fb6ffbc11c13a72612e21862f545e8546d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 6 Jan 2026 17:51:26 +0000 Subject: [PATCH 09/22] chore(release): 7.14.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7789ae65..813a158c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.14.0", + "version": "7.14.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 5970e70e2641e7d77b6f77d9624cd6990a1b81ba Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 13:00:50 -0500 Subject: [PATCH 10/22] fix(cliproxy): harden nickname validation and race condition handling - Trim nickname in API route for consistency with CLI - Block URL-unsafe chars (%, /, &, ?, #) in nickname - Block reserved patterns (kiro-N, ghcp-N) used by auto-discovery - Add reload-merge pattern in discovery to reduce race condition --- src/cliproxy/account-manager.ts | 35 +++++++++++++++++-- src/web-server/routes/cliproxy-auth-routes.ts | 4 ++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 2fced28c..393b3c0a 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -95,7 +95,7 @@ export function generateNickname(email?: string): string { /** * Validate nickname - * Rules: 1-50 chars, any non-whitespace allowed (permissive) + * Rules: 1-50 chars, no whitespace, URL-safe, no reserved patterns * @returns null if valid, error message if invalid */ export function validateNickname(nickname: string): string | null { @@ -108,6 +108,14 @@ export function validateNickname(nickname: string): string | null { if (/\s/.test(nickname)) { return 'Nickname cannot contain whitespace'; } + // Block URL-unsafe chars that break routing + if (/[%\/&?#]/.test(nickname)) { + return 'Nickname cannot contain special URL characters (%, /, &, ?, #)'; + } + // Block reserved patterns used by auto-discovery (kiro-1, ghcp-2, etc.) + if (/^(kiro|ghcp)-\d+$/i.test(nickname)) { + return 'Nickname cannot match reserved pattern (kiro-N, ghcp-N)'; + } return null; } @@ -597,7 +605,30 @@ export function discoverExistingAccounts(): void { } } - saveAccountsRegistry(registry); + // Reload-merge pattern: reduce race condition with concurrent OAuth registration + // Reload fresh registry and merge discovered accounts (fresh registry wins on conflicts) + const freshRegistry = loadAccountsRegistry(); + for (const [providerName, discovered] of Object.entries(registry.providers)) { + if (!discovered) continue; + const prov = providerName as CLIProxyProvider; + if (!freshRegistry.providers[prov]) { + freshRegistry.providers[prov] = discovered; + } else { + // Merge accounts, preferring fresh registry's existing entries + const freshProviderAccounts = freshRegistry.providers[prov]; + if (!freshProviderAccounts) continue; + for (const [id, meta] of Object.entries(discovered.accounts)) { + if (!freshProviderAccounts.accounts[id]) { + freshProviderAccounts.accounts[id] = meta; + // Set default if none exists + if (!freshProviderAccounts.default || freshProviderAccounts.default === 'default') { + freshProviderAccounts.default = id; + } + } + } + } + } + saveAccountsRegistry(freshRegistry); } /** diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 306b30ac..e7570a8f 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -277,7 +277,9 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v */ router.post('/:provider/start', async (req: Request, res: Response): Promise => { const { provider } = req.params; - const { nickname, noIncognito: noIncognitoBody } = req.body; + const { nickname: nicknameRaw, noIncognito: noIncognitoBody } = req.body; + // Trim nickname for consistency with CLI (oauth-handler.ts trims input) + const nickname = typeof nicknameRaw === 'string' ? nicknameRaw.trim() : nicknameRaw; // Validate provider if (!validProviders.includes(provider as CLIProxyProvider)) { From a32fdc8cfb2160771762ca07c62c30905a817d1d Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 13:05:45 -0500 Subject: [PATCH 11/22] fix(quota): address edge cases from code review - Add isPausingAccount disabled state to pause/resume dropdown (#30) - Add rapid click prevention guard in cliproxy.tsx (#31) - Add request deduplication via pendingFetches Map in quota-manager (#8) - Add JSON parse error handler middleware in web-server (#26) --- src/cliproxy/quota-manager.ts | 66 ++++++++++++------- src/web-server/index.ts | 16 ++++- .../cliproxy/provider-editor/account-item.tsx | 10 ++- .../provider-editor/accounts-section.tsx | 4 ++ .../cliproxy/provider-editor/index.tsx | 2 + .../provider-editor/model-config-tab.tsx | 4 ++ .../cliproxy/provider-editor/types.ts | 4 ++ ui/src/pages/cliproxy.tsx | 4 ++ 8 files changed, 81 insertions(+), 29 deletions(-) diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index 69a60005..abc09d80 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -36,6 +36,9 @@ interface CacheEntry { const CACHE_TTL_MS = 30_000; // 30 seconds const quotaCache = new Map(); +// Request deduplication: track in-flight fetch promises to avoid parallel duplicate requests +const pendingFetches = new Map>(); + function getCacheKey(provider: CLIProxyProvider, accountId: string): string { return `${provider}:${accountId}`; } @@ -76,6 +79,39 @@ 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 { + 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 // ============================================================================ @@ -176,17 +212,12 @@ export async function findHealthyAccount( if (available.length === 0) return null; - // Fetch quota for each available account (with caching) + // 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) { - try { - quota = await fetchAccountQuota(provider, account.id); - setCachedQuota(provider, account.id, quota); - } catch { - quota = { success: false, models: [], lastUpdated: Date.now() }; - } + quota = await fetchQuotaWithDedup(provider, account.id); } const avgQuota = calculateAverageQuota(quota); @@ -298,20 +329,10 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise { let quota = getCachedQuota(provider, account.id); if (!quota && provider === 'agy') { - try { - quota = await fetchAccountQuota(provider, account.id); - setCachedQuota(provider, account.id, quota); - } catch { - quota = { success: false, models: [], lastUpdated: Date.now() }; - } + quota = await fetchQuotaWithDedup(provider, account.id); } const avgQuota = quota ? calculateAverageQuota(quota) : 100; diff --git a/src/web-server/index.ts b/src/web-server/index.ts index c5d4cfb2..5d43299c 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -32,8 +32,22 @@ export async function startServer(options: ServerOptions): Promise { + 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'); diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index e12d1a29..072094ef 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -90,6 +90,7 @@ export function AccountItem({ onRemove, onPauseToggle, isRemoving, + isPausingAccount, privacyMode, showQuota, }: AccountItemProps) { @@ -188,16 +189,19 @@ export function AccountItem({ )} {onPauseToggle && ( - onPauseToggle(!account.paused)}> + onPauseToggle(!account.paused)} + disabled={isPausingAccount} + > {account.paused ? ( <> - Resume account + {isPausingAccount ? 'Resuming...' : 'Resume account'} ) : ( <> - Pause account + {isPausingAccount ? 'Pausing...' : 'Pause account'} )} diff --git a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx index 2b566635..f9d43ed8 100644 --- a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx @@ -17,6 +17,8 @@ interface AccountsSectionProps { 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; @@ -34,6 +36,7 @@ export function AccountsSection({ onRemoveAccount, onPauseToggle, isRemovingAccount, + isPausingAccount, privacyMode, showQuota, isKiro, @@ -71,6 +74,7 @@ export function AccountsSection({ onPauseToggle ? (paused) => onPauseToggle(account.id, paused) : undefined } isRemoving={isRemovingAccount} + isPausingAccount={isPausingAccount} privacyMode={privacyMode} showQuota={showQuota} /> diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 1cc552fa..b2d5b089 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -40,6 +40,7 @@ export function ProviderEditor({ onRemoveAccount, onPauseToggle, isRemovingAccount, + isPausingAccount, }: ProviderEditorProps) { const [customPresetOpen, setCustomPresetOpen] = useState(false); const { privacyMode } = usePrivacy(); @@ -203,6 +204,7 @@ export function ProviderEditor({ onRemoveAccount={onRemoveAccount} onPauseToggle={onPauseToggle} isRemovingAccount={isRemovingAccount} + isPausingAccount={isPausingAccount} privacyMode={privacyMode} isRemoteMode={isRemoteMode} /> diff --git a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx index 25ca0306..51e46d55 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -38,6 +38,8 @@ interface ModelConfigTabProps { 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; @@ -63,6 +65,7 @@ export function ModelConfigTab({ onRemoveAccount, onPauseToggle, isRemovingAccount, + isPausingAccount, privacyMode, isRemoteMode, }: ModelConfigTabProps) { @@ -138,6 +141,7 @@ export function ModelConfigTab({ onRemoveAccount={onRemoveAccount} onPauseToggle={onPauseToggle} isRemovingAccount={isRemovingAccount} + isPausingAccount={isPausingAccount} privacyMode={privacyMode} showQuota={provider === 'agy' && !isRemoteMode} isKiro={isKiro} diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index 80010f9f..743a1bf3 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -32,6 +32,8 @@ export interface ProviderEditorProps { onRemoveAccount: (accountId: string) => void; onPauseToggle?: (accountId: string, paused: boolean) => void; isRemovingAccount?: boolean; + /** Pause/resume mutation in progress */ + isPausingAccount?: boolean; } export interface AccountItemProps { @@ -40,6 +42,8 @@ export interface AccountItemProps { 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; diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index bd13b9dc..52d2027e 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -221,6 +221,8 @@ export function CliproxyPage() { }; 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 { @@ -377,6 +379,7 @@ export function CliproxyPage() { handlePauseToggle(selectedVariantData.provider, accountId, paused) } isRemovingAccount={removeMutation.isPending} + isPausingAccount={pauseMutation.isPending || resumeMutation.isPending} /> ) : selectedStatus ? ( ) : ( setWizardOpen(true)} /> From 107e2813f96624c105bab7d227336b0779648f12 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 13:52:14 -0500 Subject: [PATCH 12/22] fix(cliproxy): prevent race in promptNickname close handler --- src/cliproxy/auth/oauth-handler.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index fea5b39f..f4944e16 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -76,8 +76,15 @@ async function promptNickname( } return new Promise((resolve) => { - // Handle Ctrl+C gracefully - rl.on('close', () => resolve(null)); + let resolved = false; + + // Handle Ctrl+C gracefully (only if not already resolved) + rl.on('close', () => { + if (!resolved) { + resolved = true; + resolve(null); + } + }); const askForNickname = () => { rl.question('[?] Enter a nickname for this account: ', (answer) => { @@ -102,6 +109,7 @@ async function promptNickname( return; } + resolved = true; rl.close(); resolve(nickname); }); From e8716db0a1f8bfc446f3fbc06d59a3aeeceda159 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 6 Jan 2026 19:20:53 +0000 Subject: [PATCH 13/22] chore(release): 7.14.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 813a158c..ef35d8ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.14.0-dev.1", + "version": "7.14.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 0af185f6a0b40d3a10215ba183f583b25a3d9967 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 14:32:20 -0500 Subject: [PATCH 14/22] fix(quota): correct tier detection - remove 2.5-pro from ultra indicators 2.5-pro is a standard pro-tier model, not ultra. This caused all accounts with gemini-2.5-pro access to incorrectly display ULTRA badge. Tier will be re-detected on next quota fetch for existing accounts. --- src/cliproxy/quota-fetcher.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 6748601d..966d5f54 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -125,14 +125,15 @@ export function detectTier(quotaResult: QuotaResult): AccountTier { const modelNames = quotaResult.models.map((m) => m.name.toLowerCase()); // Ultra indicators: experimental, preview, ultra in name - const ultraIndicators = ['ultra', 'experimental', 'preview', '2.5-pro', '3-ultra']; + // Note: 2.5-pro is a standard pro model, not ultra + const ultraIndicators = ['ultra', 'experimental', 'preview', '3-ultra']; const hasUltra = modelNames.some((name) => ultraIndicators.some((indicator) => name.includes(indicator)) ); if (hasUltra) return 'ultra'; - // Pro indicators: gemini-2.0, gemini-3, pro models + // Pro indicators: gemini-2.x, gemini-3, pro models (includes 2.5-pro) const proIndicators = ['gemini-2', 'gemini-3', '-pro']; const hasPro = modelNames.some((name) => proIndicators.some((indicator) => name.includes(indicator)) From aad0d44069b78f395285e3b71c0a9563b7abe4eb Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 14:41:25 -0500 Subject: [PATCH 15/22] fix(quota): use API tier detection instead of model-based heuristics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier detection now uses paidTier/currentTier from loadCodeAssist API response instead of inferring from model names. This is the correct approach - all Antigravity accounts have access to same models regardless of tier. - Add TierInfo interface and tier fields to LoadCodeAssistResponse - Add mapTierString() helper for API → AccountTier conversion - Update getProjectId() to extract and return tier - Update fetchAccountQuota() to use API tier - Remove flawed detectTier() function --- src/cliproxy/quota-fetcher.ts | 136 +++++++++++++++++----------------- 1 file changed, 69 insertions(+), 67 deletions(-) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 966d5f54..e061b9dc 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -111,42 +111,18 @@ interface TokenRefreshResponse { error_description?: string; } -/** - * Detect account tier from quota API model list. - * Ultra accounts have access to experimental/preview models. - * Pro accounts have standard model access. - * Free/unknown accounts have limited model access. - */ -export function detectTier(quotaResult: QuotaResult): AccountTier { - if (!quotaResult.success || quotaResult.models.length === 0) { - return 'unknown'; - } - - const modelNames = quotaResult.models.map((m) => m.name.toLowerCase()); - - // Ultra indicators: experimental, preview, ultra in name - // Note: 2.5-pro is a standard pro model, not ultra - const ultraIndicators = ['ultra', 'experimental', 'preview', '3-ultra']; - const hasUltra = modelNames.some((name) => - ultraIndicators.some((indicator) => name.includes(indicator)) - ); - - if (hasUltra) return 'ultra'; - - // Pro indicators: gemini-2.x, gemini-3, pro models (includes 2.5-pro) - const proIndicators = ['gemini-2', 'gemini-3', '-pro']; - const hasPro = modelNames.some((name) => - proIndicators.some((indicator) => name.includes(indicator)) - ); - - if (hasPro) return 'pro'; - - return 'free'; +/** Tier info from loadCodeAssist */ +interface TierInfo { + id?: string; } /** 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; } /** fetchAvailableModels response model */ @@ -308,11 +284,27 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | } /** - * Get project ID via loadCodeAssist endpoint + * Map API tier string to AccountTier type + * API returns: "FREE", "PRO", "ULTRA" (or variants like "pro", "ultra") */ -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')) return 'ultra'; + if (normalized.includes('pro')) return 'pro'; + if (normalized.includes('free')) return 'free'; + return 'unknown'; +} + +/** + * Get project ID and subscription tier via loadCodeAssist endpoint + */ +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(); @@ -367,7 +359,11 @@ async function getProjectId( }; } - return { projectId: projectId.trim() }; + // Extract tier: priority paidTier > currentTier (paid reflects actual subscription) + const 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') { @@ -529,57 +525,63 @@ 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) { - const retryResult = await fetchAvailableModels(refreshResult.accessToken, projectId); - // Detect and persist tier for retry result + const retryResult = await fetchAvailableModels( + refreshResult.accessToken, + projectId as string + ); + // Use API tier (from loadCodeAssist) instead of model-based detection if (retryResult.success) { - const tier = detectTier(retryResult); - retryResult.tier = tier; + retryResult.tier = apiTier; retryResult.accountId = accountId; - setAccountTier(provider, accountId, tier); + setAccountTier(provider, accountId, apiTier); } return retryResult; } } - // Detect and persist tier for successful result + // Use API tier (from loadCodeAssist) instead of model-based detection if (result.success) { - const tier = detectTier(result); - result.tier = tier; + result.tier = apiTier; result.accountId = accountId; - setAccountTier(provider, accountId, tier); + setAccountTier(provider, accountId, apiTier); } return result; From a5f1472047fc7e70329d066b41a5ba051b412051 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 14:55:36 -0500 Subject: [PATCH 16/22] fix(quota): handle 'standard-tier' as free in tier mapping API returns 'standard-tier' ID for free accounts, not 'FREE'. Updated mapTierString() to recognize 'standard' substring. --- src/cliproxy/quota-fetcher.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index e061b9dc..eb1429e8 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -285,14 +285,16 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | /** * Map API tier string to AccountTier type - * API returns: "FREE", "PRO", "ULTRA" (or variants like "pro", "ultra") + * API returns tier IDs like: "standard-tier" (free), "pro-tier" (pro), "ultra-tier" (ultra) + * Also handles legacy formats: "FREE", "PRO", "ULTRA" */ function mapTierString(tierStr: string | undefined): AccountTier { if (!tierStr) return 'unknown'; const normalized = tierStr.toLowerCase(); if (normalized.includes('ultra')) return 'ultra'; if (normalized.includes('pro')) return 'pro'; - if (normalized.includes('free')) return 'free'; + // "standard-tier" or "free" both map to free + if (normalized.includes('standard') || normalized.includes('free')) return 'free'; return 'unknown'; } From db071e2ff2de3c880651445f1f9094a4a43bec74 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 16:53:21 -0500 Subject: [PATCH 17/22] refactor(quota): simplify AccountTier to free|paid|unknown - consolidate 'pro'/'ultra' into single 'paid' tier (no distinction needed) - update mapTierString() and inferTierFromModels() in quota-fetcher.ts - fix tier_priority default from ['ultra','pro'] to ['paid'] - add missing quota_management to mergeWithDefaults() in config-loader - update UI tier badge styling for 'paid' tier - update api-client.ts tier type definition --- src/cliproxy/account-manager.ts | 6 +- src/cliproxy/quota-fetcher.ts | 79 +++++++++++++++---- src/cliproxy/quota-manager.ts | 4 +- src/config/unified-config-loader.ts | 30 +++++++ src/config/unified-config-types.ts | 4 +- .../cliproxy/provider-editor/account-item.tsx | 3 +- ui/src/lib/api-client.ts | 4 +- 7 files changed, 103 insertions(+), 27 deletions(-) diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 9919ace1..fc450c7b 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -14,8 +14,8 @@ import { CLIProxyProvider } from './types'; import { getCliproxyDir, getAuthDir } from './config-generator'; import { PROVIDER_TYPE_VALUES } from './auth/auth-types'; -/** Account tier for quota management */ -export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; +/** 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. @@ -45,7 +45,7 @@ export interface AccountInfo { paused?: boolean; /** ISO timestamp when paused */ pausedAt?: string; - /** Account tier: free, pro, ultra */ + /** Account tier: free or paid (Pro/Ultra combined) */ tier?: AccountTier; } diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index eb1429e8..4ba769a2 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -114,6 +114,7 @@ interface TokenRefreshResponse { /** Tier info from loadCodeAssist */ interface TierInfo { id?: string; + isDefault?: boolean; } /** loadCodeAssist response */ @@ -123,6 +124,8 @@ interface LoadCodeAssistResponse { 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 */ @@ -284,22 +287,49 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | } /** - * Map API tier string to AccountTier type - * API returns tier IDs like: "standard-tier" (free), "pro-tier" (pro), "ultra-tier" (ultra) - * Also handles legacy formats: "FREE", "PRO", "ULTRA" + * Map tier ID string to AccountTier type + * Simplified: anything with 'pro' or 'ultra' = paid, 'free'/'legacy' = free */ function mapTierString(tierStr: string | undefined): AccountTier { if (!tierStr) return 'unknown'; const normalized = tierStr.toLowerCase(); - if (normalized.includes('ultra')) return 'ultra'; - if (normalized.includes('pro')) return 'pro'; - // "standard-tier" or "free" both map to free - if (normalized.includes('standard') || normalized.includes('free')) return 'free'; + 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'; } /** - * Get project ID and subscription tier via loadCodeAssist endpoint + * 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; @@ -361,8 +391,17 @@ async function getProjectId(accessToken: string): Promise<{ }; } - // Extract tier: priority paidTier > currentTier (paid reflects actual subscription) - const tierStr = data.paidTier?.id || data.currentTier?.id; + // 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 }; @@ -569,21 +608,29 @@ export async function fetchAccountQuota( refreshResult.accessToken, projectId as string ); - // Use API tier (from loadCodeAssist) instead of model-based detection + // Determine tier: model access (Claude = Ultra) > API tier > fallback if (retryResult.success) { - retryResult.tier = apiTier; + let finalTier = inferTierFromModels(retryResult.models); + if (finalTier === 'unknown') { + finalTier = apiTier !== 'unknown' ? apiTier : 'paid'; + } + retryResult.tier = finalTier; retryResult.accountId = accountId; - setAccountTier(provider, accountId, apiTier); + setAccountTier(provider, accountId, finalTier); } return retryResult; } } - // Use API tier (from loadCodeAssist) instead of model-based detection + // Determine tier: model access > API tier > fallback to paid if (result.success) { - result.tier = apiTier; + let finalTier = inferTierFromModels(result.models); + if (finalTier === 'unknown') { + finalTier = apiTier !== 'unknown' ? apiTier : 'paid'; + } + result.tier = finalTier; result.accountId = accountId; - setAccountTier(provider, accountId, apiTier); + setAccountTier(provider, accountId, finalTier); } return result; diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index abc09d80..0b1d8dc7 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -199,7 +199,7 @@ export async function findHealthyAccount( exclude: string[] ): Promise<{ id: string; tier: string; lastQuota: number } | null> { const config = loadOrCreateUnifiedConfig(); - const tierPriority = config.quota_management?.auto?.tier_priority ?? ['ultra', 'pro']; + const tierPriority = config.quota_management?.auto?.tier_priority ?? ['paid']; const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5; const accounts = getProviderAccounts(provider); @@ -224,7 +224,7 @@ export async function findHealthyAccount( return { id: account.id, - tier: account.tier || 'pro', + tier: account.tier || 'paid', lastQuota: avgQuota, }; }) diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 3a23ba1e..9f3935b2 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -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 { 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, + }, + }, }; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index ac17e17d..e5eec79b 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -356,7 +356,7 @@ export interface AutoQuotaConfig { preflight_check: boolean; /** Quota percentage below which account is "exhausted" (default: 5) */ exhaustion_threshold: number; - /** Tier priority for failover, highest to lowest (default: ['ultra', 'pro']) */ + /** Tier priority for failover, highest to lowest (default: ['paid']) */ tier_priority: string[]; /** Minutes to skip exhausted account before retry (default: 5) */ cooldown_minutes: number; @@ -402,7 +402,7 @@ export interface QuotaManagementConfig { export const DEFAULT_AUTO_QUOTA_CONFIG: AutoQuotaConfig = { preflight_check: true, exhaustion_threshold: 5, - tier_priority: ['ultra', 'pro'], + tier_priority: ['paid'], cooldown_minutes: 5, }; diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 072094ef..8c6ac2f0 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -148,8 +148,7 @@ export function AccountItem({ variant="outline" className={cn( 'text-[10px] h-4 px-1.5 uppercase', - account.tier === 'ultra' && 'border-purple-500 text-purple-600', - account.tier === 'pro' && 'border-blue-500 text-blue-600', + account.tier === 'paid' && 'border-blue-500 text-blue-600', account.tier === 'free' && 'border-gray-400 text-gray-500' )} > diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index f8ef47e1..22d67bb0 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -81,8 +81,8 @@ export interface OAuthAccount { paused?: boolean; /** ISO timestamp when account was paused */ pausedAt?: string; - /** Account tier: free, pro, ultra */ - tier?: 'free' | 'pro' | 'ultra' | 'unknown'; + /** Account tier: free or paid (Pro/Ultra combined) */ + tier?: 'free' | 'paid' | 'unknown'; } export interface AuthStatus { From 4b7328b3880a3fa1d71a21f6b73616968cd8737a Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 17:12:02 -0500 Subject: [PATCH 18/22] docs(cli): add cliproxy pause/resume/status to --help Add new quota management subcommands to help output: - ccs cliproxy pause - ccs cliproxy resume - ccs cliproxy status [provider] --- src/commands/help-command.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 840ad3d5..b443b5f2 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -259,6 +259,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs cliproxy doctor', 'Quota diagnostics (Antigravity)'], ['ccs cliproxy --install ', 'Install specific version (e.g., 6.6.6)'], ['ccs cliproxy --latest', 'Update to latest version'], + ['', ''], // Spacer + ['ccs cliproxy pause

', 'Pause account from rotation'], + ['ccs cliproxy resume

', 'Resume paused account'], + ['ccs cliproxy status [provider]', 'Show quota/tier/pause status'], ]); // CLI Proxy configuration flags (new) From 113cc06add969879148d4541fe0517b1046c74f3 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 17:16:55 -0500 Subject: [PATCH 19/22] docs(CLAUDE.md): add help location reference and documentation requirements - Add Help Location Reference table for respective --help handlers - Clarify lib/ccs and lib/ccs.ps1 are bootstrap wrappers only - Add Documentation Requirements (MANDATORY) section - Specify CCS docs submodule workflow for owner (@kaitranntt) - Add guidance for external contributors - Update Pre-Commit Checklist with documentation items --- CLAUDE.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1f41c7bb..4142f8dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.mdx` | +| New feature | `features/.mdx` | + +**Workflow for docs submodule:** +```bash +cd ~/CloudPersonal/ccs/docs/ +git checkout main && git pull +# Make changes +git add -A && git commit -m "docs: " +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 From ec2e491c189ed1ea31a62a8aad722646623e624b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 6 Jan 2026 22:20:18 +0000 Subject: [PATCH 20/22] chore(release): 7.14.0-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ef35d8ea..5c67349f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.14.0-dev.2", + "version": "7.14.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From ec4c2c2f7b5314d4d45968805126f581da7db3d7 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 18:05:05 -0500 Subject: [PATCH 21/22] docs: update documentation for CCS v7.14.x with quota management --- docs/code-standards.md | 2 +- docs/codebase-summary.md | 11 ++++--- docs/project-overview-pdr.md | 26 ++++++++++++++-- docs/project-roadmap.md | 7 +++-- docs/system-architecture.md | 59 +++++++++++++++++++++++++++++++++--- docs/websearch.md | 2 ++ 6 files changed, 93 insertions(+), 14 deletions(-) diff --git a/docs/code-standards.md b/docs/code-standards.md index b4389cbc..64cca0e8 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -1,6 +1,6 @@ # CCS Code Standards -Last Updated: 2025-12-21 +Last Updated: 2026-01-06 Code standards, modularization patterns, and conventions for the CCS codebase. diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index f8b4ad44..67479076 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,8 +1,8 @@ # CCS Codebase Summary -Last Updated: 2025-12-22 +Last Updated: 2026-01-06 -Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, and v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers. +Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, and v7.14 Hybrid Quota Management. ## Repository Structure @@ -71,6 +71,8 @@ src/ │ ├── cliproxy-executor.ts # Main executor (666 lines) │ ├── config-generator.ts # Config file generation (531 lines) │ ├── account-manager.ts # Account management (509 lines) +│ ├── quota-manager.ts # Hybrid quota management (NEW v7.14) +│ ├── quota-fetcher.ts # Provider quota API integration (NEW v7.14) │ ├── platform-detector.ts # OS/arch detection │ ├── binary-manager.ts # Binary download/update │ ├── auth-handler.ts # Authentication handling @@ -79,8 +81,8 @@ src/ │ ├── service-manager.ts # Background service │ ├── proxy-detector.ts # Running proxy detection │ ├── startup-lock.ts # Race condition prevention -│ ├── remote-proxy-client.ts # Remote proxy health checks (NEW v7.1) -│ ├── proxy-config-resolver.ts # CLI/env/config merging (NEW v7.1) +│ ├── remote-proxy-client.ts # Remote proxy health checks (v7.1) +│ ├── proxy-config-resolver.ts # CLI/env/config merging (v7.1) │ ├── types.ts # ResolvedProxyConfig for local/remote modes │ └── [more files...] │ @@ -164,6 +166,7 @@ src/ | Auth | `auth/`, `cliproxy/auth/` | Authentication across providers | | Config | `config/`, `types/` | Configuration & type definitions | | Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations (7 CLIProxy providers: gemini, codex, agy, qwen, iflow, kiro, ghcp) | +| Quota | `cliproxy/quota-*.ts`, `account-manager.ts` | Hybrid quota management (v7.14) | | Remote Proxy | `cliproxy/remote-*.ts`, `proxy-config-resolver.ts` | Remote CLIProxy support (v7.1) | | Services | `web-server/`, `api/` | HTTP server, API services | | Utilities | `utils/`, `management/` | Helpers, diagnostics | diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index f1f7db05..361bed83 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -1,6 +1,6 @@ # CCS Product Development Requirements (PDR) -Last Updated: 2025-12-22 +Last Updated: 2026-01-06 ## Product Overview @@ -8,9 +8,9 @@ Last Updated: 2025-12-22 **Tagline**: The universal AI profile manager for Claude Code -**Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, OpenRouter) with a React-based dashboard for configuration management. Supports both local and remote CLIProxyAPI instances. +**Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, OpenRouter, Qwen, Kimi, DeepSeek) with a React-based dashboard for configuration management. Supports both local and remote CLIProxyAPI instances with hybrid quota management. -**Current Version**: v7.2.x (Kiro + GitHub Copilot OAuth providers) +**Current Version**: v7.14.x (Hybrid Quota Management + Pause/Resume) --- @@ -98,6 +98,14 @@ CCS provides: - Protocol-based default ports (443 for HTTPS, 8317 for HTTP) - Dashboard UI for remote server configuration and testing +### FR-009: Quota Management (v7.14) +- Pause/resume individual accounts via `ccs cliproxy pause/resume ` +- Check quota status via `ccs cliproxy status [account]` +- Auto-failover when account exhausted +- Tier detection: free/paid/unknown +- Pre-flight quota checks before session start +- Dashboard UI with pause/resume toggles and tier badges + --- ## Non-Functional Requirements @@ -216,6 +224,13 @@ CCS provides: - [x] Authorization Code flow for Kiro (port 9876) - [x] Device Code flow for ghcp (no local port needed) +### v7.14 Release (Complete) +- [x] Hybrid quota management with auto-failover +- [x] `ccs cliproxy pause/resume/status` commands +- [x] API tier detection (free/paid/unknown) +- [x] Dashboard pause/resume toggles and tier badges +- [x] Pre-flight quota checks before session start + ### v8.0 Release (Planned - Q1 2026) - [ ] Multiple CLIProxyAPI instances (load balancing, failover) - [ ] Native git worktree support @@ -239,6 +254,11 @@ CCS provides: - AWS Kiro (Authorization Code OAuth) - Z.AI GLM API - OpenRouter API +- Moonshot Kimi API +- DeepSeek API +- Alibaba Qwen API +- Minimax API +- Azure Foundry API ### Third-Party Libraries - Express.js (web server) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 832a16a3..089907f2 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2025-12-22 +Last Updated: 2026-01-06 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -23,6 +23,7 @@ All major modularization work is complete. The codebase evolved from monolithic | 9 | Test Infrastructure | 99 UI tests + 539 CLI tests, 90% coverage | | 10 | Remote CLIProxy | `proxy-config-resolver.ts`, `remote-proxy-client.ts` | | 11 | Kiro + ghcp Providers | OAuth support via CLIProxyAPIPlus (v7.2) | +| 12 | Hybrid Quota Management | `quota-manager.ts`, `quota-fetcher.ts` (v7.14) | **Metrics Achieved**: - Files >500 lines: 12 -> 5 (-58%) @@ -153,6 +154,7 @@ worktrees: - **#158**: Fix AGY OAuth flow - **#157**: ~~Add Kiro auth support from CLIProxyAPIPlus~~ **COMPLETE** (v7.2) - GitHub Copilot (ghcp) Device Code flow **COMPLETE** (v7.2) +- Hybrid quota management **COMPLETE** (v7.14) --- @@ -163,9 +165,10 @@ worktrees: | Modularization (Phases 1-9) | COMPLETE | - | | Remote CLIProxy Support (#142) | COMPLETE | v7.1 | | Kiro + GitHub Copilot OAuth (#157) | COMPLETE | v7.2 | +| Hybrid Quota Management | COMPLETE | v7.14 | | Critical Bug Fixes (#158, #155, #124) | PLANNED | Q1 2026 | | Multiple CLIProxyAPI Instances | PLANNED | Q1 2026 | -| Git Worktree Support | PLANNED | Q1 2026 | +| Git Worktree Support | PLANNED | Q2 2026 | | Enhanced Model Support | PLANNED | Q2 2026 | --- diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 32d1e091..ac8f2daa 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -1,6 +1,6 @@ # CCS System Architecture -Last Updated: 2025-12-22 +Last Updated: 2026-01-06 High-level architecture documentation for the CCS (Claude Code Switch) system. @@ -8,12 +8,12 @@ High-level architecture documentation for the CCS (Claude Code Switch) system. ## System Overview -CCS is a CLI wrapper that enables seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, Kiro, GitHub Copilot). It consists of two main components: +CCS is a CLI wrapper that enables seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, Kiro, GitHub Copilot, OpenRouter, Qwen, Kimi, DeepSeek). It consists of two main components: 1. **CLI Application** (`src/`) - Node.js TypeScript CLI 2. **Dashboard UI** (`ui/`) - React web application served by Express -CCS v7.2 adds Kiro (AWS) and GitHub Copilot (ghcp) OAuth providers via CLIProxyAPIPlus. +CCS v7.14 adds Hybrid Quota Management with pause/resume/status commands and auto-failover. ``` +===========================================================================+ @@ -131,7 +131,9 @@ CCS v7.2 adds Kiro (AWS) and GitHub Copilot (ghcp) OAuth providers via CLIProxyA | cliproxy-executor| | copilot-package- | | glmt-proxy | | config-generator | | manager | | delta-accumulator| | account-manager | | [copilot logic] | | pipeline/ | - | auth/ | +------------------+ +------------------+ + | quota-manager | +------------------+ +------------------+ + | quota-fetcher | + | auth/ | | binary/ | | services/ | +------------------+ @@ -424,6 +426,55 @@ CCS v7.2 adds Kiro (AWS) and GitHub Copilot (ghcp) OAuth providers via CLIProxyA CCS_PROXY_FALLBACK_ENABLED Enable fallback (true/false) ``` +### Quota Management Flow (v7.14) + +``` ++===========================================================================+ +| Quota Management Architecture | ++===========================================================================+ + + Pre-Flight Check (before session start) + | + v + +------------------+ + | quota-manager.ts | Hybrid quota management + +------------------+ + | + +---> Get all active accounts for provider + | + +---> For each account: + | | + | v + | +------------------+ + | | quota-fetcher.ts | Provider-specific API calls + | +------------------+ + | | + | +---> Check isPaused flag --> Skip if paused + | | + | +---> Fetch quota from provider API + | | - Gemini: /models endpoint + | | - Codex: /api/v1/account + | | - Kiro: /api/usage + | | + | +---> Detect tier (free/paid/unknown) + | | + | +---> Check exhaustion status + | + +---> Select best account (not paused, not exhausted) + | + +---> Auto-failover to next account if current exhausted + + CLI Commands: + ccs cliproxy pause --> Set isPaused=true in account-manager + ccs cliproxy resume --> Set isPaused=false + ccs cliproxy status [account] --> Display quota + tier info + + Dashboard UI: + - Pause/Resume toggle per account + - Tier badge (free/paid/unknown) + - Quota usage display +``` + --- ## Configuration Architecture diff --git a/docs/websearch.md b/docs/websearch.md index 184588df..c7f92b58 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -1,5 +1,7 @@ # WebSearch Configuration Guide +Last Updated: 2026-01-06 + CCS provides automatic web search capability for all profiles, including third-party providers that cannot access Anthropic's native WebSearch API. ## How WebSearch Works From ef759a5c4b72926503dbe476162785915a85dc13 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 6 Jan 2026 23:06:15 +0000 Subject: [PATCH 22/22] chore(release): 7.14.0-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5c67349f..3d7c13e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.14.0-dev.3", + "version": "7.14.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",