From 1ac19415ce835df15f3fcefbb698f12ec89ec5e9 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 14 Jan 2026 10:19:27 -0500 Subject: [PATCH 1/2] fix(cliproxy): return null for unknown quota, add verbose diagnostics - Change calculateAverageQuota() to return null instead of 100 when no model data - Update callers to use null coalescing (?? 0) for health check thresholds - Display "N/A (fetch unavailable)" for null quota values - Add --verbose flag to quota-related functions for troubleshooting - Add debug logging: token refresh status, API response, errors --- src/cliproxy/cliproxy-executor.ts | 4 +- src/cliproxy/quota-fetcher.ts | 78 ++++++++++++++++++++++++------- src/cliproxy/quota-manager.ts | 16 +++---- src/commands/cliproxy-command.ts | 13 +++--- 4 files changed, 79 insertions(+), 32 deletions(-) diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 03c8c04f..764db7ca 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -485,8 +485,10 @@ export async function execClaudeWithCLIProxy( if (preflight.switchedFrom) { console.log(info(`Auto-switched to ${preflight.accountId}`)); console.log(` Reason: ${preflight.reason}`); - if (preflight.quotaPercent !== undefined) { + if (preflight.quotaPercent !== undefined && preflight.quotaPercent !== null) { console.log(` New account quota: ${preflight.quotaPercent.toFixed(1)}%`); + } else { + console.log(` New account quota: N/A (fetch unavailable)`); } } } diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 4ba769a2..6011ea41 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -179,8 +179,11 @@ function isTokenExpired(expiredStr?: string): boolean { * This allows CCS to get fresh tokens independently of CLIProxyAPI */ async function refreshAccessToken( - refreshToken: string + refreshToken: string, + verbose = false ): Promise<{ accessToken: string | null; error?: string }> { + if (verbose) console.log('[i] Refreshing access token...'); + const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); @@ -201,26 +204,36 @@ async function refreshAccessToken( clearTimeout(timeoutId); + if (verbose) console.log(`[i] Token refresh status: ${response.status}`); + const data = (await response.json()) as TokenRefreshResponse; if (!response.ok || data.error) { + const error = data.error_description || data.error || `OAuth error: ${response.status}`; + if (verbose) console.log(`[!] Token refresh failed: ${error}`); return { accessToken: null, - error: data.error_description || data.error || `OAuth error: ${response.status}`, + error, }; } if (!data.access_token) { + if (verbose) console.log('[!] Token refresh failed: No access_token in response'); return { accessToken: null, error: 'No access_token in response' }; } + if (verbose) console.log('[i] Token refresh: success'); return { accessToken: data.access_token }; } catch (err) { clearTimeout(timeoutId); - if (err instanceof Error && err.name === 'AbortError') { - return { accessToken: null, error: 'Token refresh timeout' }; - } - return { accessToken: null, error: err instanceof Error ? err.message : 'Unknown error' }; + const errorMsg = + err instanceof Error && err.name === 'AbortError' + ? 'Token refresh timeout' + : err instanceof Error + ? err.message + : 'Unknown error'; + if (verbose) console.log(`[!] Token refresh failed: ${errorMsg}`); + return { accessToken: null, error: errorMsg }; } } @@ -518,30 +531,38 @@ async function fetchAvailableModels(accessToken: string, _projectId: string): Pr * * @param provider - Provider name (only 'agy' supported) * @param accountId - Account identifier (email) + * @param verbose - Show detailed diagnostics * @returns Quota result with models and percentages */ export async function fetchAccountQuota( provider: CLIProxyProvider, - accountId: string + accountId: string, + verbose = false ): Promise { + if (verbose) console.log(`[i] Fetching quota for ${accountId}...`); + // Only Antigravity supports quota fetching if (provider !== 'agy') { + const error = `Quota not supported for provider: ${provider}`; + if (verbose) console.log(`[!] Error: ${error}`); return { success: false, models: [], lastUpdated: Date.now(), - error: `Quota not supported for provider: ${provider}`, + error, }; } // Read auth data from auth file const authData = readAuthData(provider, accountId); if (!authData) { + const error = 'Auth file not found for account'; + if (verbose) console.log(`[!] Error: ${error}`); return { success: false, models: [], lastUpdated: Date.now(), - error: 'Auth file not found for account', + error, }; } @@ -550,6 +571,7 @@ export async function fetchAccountQuota( // Proactive refresh: refresh 5 minutes before expiry (matches CLIProxyAPIPlus behavior) let accessToken = authData.accessToken; const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; // 5 minutes + let tokenRefreshed = false; if (authData.refreshToken) { const shouldRefresh = @@ -558,14 +580,19 @@ export async function fetchAccountQuota( new Date(authData.expiresAt).getTime() - Date.now() < REFRESH_LEAD_TIME_MS; // Expiring soon if (shouldRefresh) { - const refreshResult = await refreshAccessToken(authData.refreshToken); + const refreshResult = await refreshAccessToken(authData.refreshToken, verbose); if (refreshResult.accessToken) { accessToken = refreshResult.accessToken; + tokenRefreshed = true; } // If refresh fails, fall back to existing token (might still work) } } + if (verbose && !tokenRefreshed) { + console.log('[i] Token refresh: skipped'); + } + // Get project ID and tier - prefer stored project ID, but always call API for tier let projectId = authData.projectId; let apiTier: AccountTier = 'unknown'; @@ -576,18 +603,20 @@ export async function fetchAccountQuota( 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); + const refreshResult = await refreshAccessToken(authData.refreshToken, verbose); if (refreshResult.accessToken) { accessToken = refreshResult.accessToken; lastProjectResult = await getProjectId(accessToken); } } if (!lastProjectResult.projectId) { + const error = lastProjectResult.error || 'Failed to retrieve project ID'; + if (verbose) console.log(`[!] Error: ${error}`); return { success: false, models: [], lastUpdated: Date.now(), - error: lastProjectResult.error || 'Failed to retrieve project ID', + error, isUnprovisioned: lastProjectResult.isUnprovisioned, }; } @@ -597,12 +626,16 @@ export async function fetchAccountQuota( projectId = lastProjectResult.projectId || projectId; apiTier = lastProjectResult.tier || 'unknown'; + if (verbose) console.log(`[i] Project ID: ${projectId || 'not found'}`); + // Fetch models with quota const result = await fetchAvailableModels(accessToken, projectId as string); + if (verbose) console.log(`[i] Models found: ${result.models.length}`); + // 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); + const refreshResult = await refreshAccessToken(authData.refreshToken, verbose); if (refreshResult.accessToken) { const retryResult = await fetchAvailableModels( refreshResult.accessToken, @@ -617,6 +650,9 @@ export async function fetchAccountQuota( retryResult.tier = finalTier; retryResult.accountId = accountId; setAccountTier(provider, accountId, finalTier); + if (verbose && retryResult.error) { + console.log(`[!] Error: ${retryResult.error}`); + } } return retryResult; } @@ -633,6 +669,10 @@ export async function fetchAccountQuota( setAccountTier(provider, accountId, finalTier); } + if (verbose && result.error) { + console.log(`[!] Error: ${result.error}`); + } + return result; } @@ -668,10 +708,12 @@ export interface AllAccountsQuotaResult { * Also detects accounts sharing same GCP project (failover won't help) * * @param provider - Provider name (only 'agy' supported for quota) + * @param verbose - Show detailed diagnostics * @returns Results for all accounts with project grouping */ export async function fetchAllProviderQuotas( - provider: CLIProxyProvider + provider: CLIProxyProvider, + verbose = false ): Promise { const accounts = getProviderAccounts(provider); const results: AllAccountsQuotaResult = { @@ -687,7 +729,7 @@ export async function fetchAllProviderQuotas( // Fetch quota for each account in parallel const quotaPromises = accounts.map(async (account) => { - const quota = await fetchAccountQuota(provider, account.id); + const quota = await fetchAccountQuota(provider, account.id, verbose); // Read project ID from auth file if not in quota result let projectId = quota.projectId; @@ -724,13 +766,15 @@ export async function fetchAllProviderQuotas( * * @param provider - Provider name * @param excludeAccountId - Account to exclude (current exhausted account) + * @param verbose - Show detailed diagnostics * @returns Account with available quota, or null if none available */ export async function findAvailableAccount( provider: CLIProxyProvider, - excludeAccountId?: string + excludeAccountId?: string, + verbose = false ): Promise<{ account: AccountInfo; quota: QuotaResult } | null> { - const allQuotas = await fetchAllProviderQuotas(provider); + const allQuotas = await fetchAllProviderQuotas(provider, verbose); // Get excluded account's project ID to avoid switching to same-project accounts const excludedProjectId = allQuotas.accounts.find((a) => a.account.id === excludeAccountId)?.quota diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index 0b1d8dc7..8b9eb30a 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -176,15 +176,15 @@ export interface PreflightResult { /** Reason for switch or failure */ reason?: string; /** Average quota percentage of selected account */ - quotaPercent?: number; + quotaPercent?: number | null; } /** * Calculate average quota percentage from models */ -function calculateAverageQuota(quota: QuotaResult): number { +function calculateAverageQuota(quota: QuotaResult): number | null { if (!quota.success || quota.models.length === 0) { - return 100; // Assume OK if no data + return null; // No data available } const total = quota.models.reduce((sum, m) => sum + m.percentage, 0); return total / quota.models.length; @@ -220,7 +220,7 @@ export async function findHealthyAccount( quota = await fetchQuotaWithDedup(provider, account.id); } - const avgQuota = calculateAverageQuota(quota); + const avgQuota = calculateAverageQuota(quota) ?? 0; return { id: account.id, @@ -336,7 +336,7 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise { ['--update', 'Unpin and update to latest version'], ], ], + ['Options:', [['--verbose, -v', 'Show detailed quota fetch diagnostics']]], ]; for (const [title, cmds] of sections) { @@ -601,7 +602,7 @@ async function showHelp(): Promise { // DOCTOR COMMAND - Quota diagnostics and shared project detection // ============================================================================ -async function handleDoctor(): Promise { +async function handleDoctor(verbose = false): Promise { await initUI(); console.log(header('CLIProxy Quota Diagnostics')); console.log(''); @@ -621,7 +622,7 @@ async function handleDoctor(): Promise { // Fetch quota for all accounts console.log(dim('Fetching quotas...')); - const quotaResult = await fetchAllProviderQuotas(provider); + const quotaResult = await fetchAllProviderQuotas(provider, verbose); // Display per-account quota status for (const { account, quota } of quotaResult.accounts) { @@ -826,7 +827,7 @@ async function handleResumeAccount(args: string[]): Promise { } } -async function handleQuotaStatus(): Promise { +async function handleQuotaStatus(verbose = false): Promise { await initUI(); console.log(header('Quota Status')); console.log(''); @@ -841,7 +842,7 @@ async function handleQuotaStatus(): Promise { } console.log(dim('Fetching quotas...')); - const quotaResult = await fetchAllProviderQuotas(provider); + const quotaResult = await fetchAllProviderQuotas(provider, verbose); // Build table rows const rows: string[][] = []; @@ -942,7 +943,7 @@ export async function handleCliproxyCommand(args: string[]): Promise { } if (command === 'doctor' || command === 'diag') { - await handleDoctor(); + await handleDoctor(verbose); return; } @@ -963,7 +964,7 @@ export async function handleCliproxyCommand(args: string[]): Promise { } if (command === 'quota') { - await handleQuotaStatus(); + await handleQuotaStatus(verbose); return; } From 04c9b087ca3466c4b2871a777906f87b19566d3c Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 14 Jan 2026 10:49:47 -0500 Subject: [PATCH 2/2] fix(cliproxy): address PR review feedback - Pass verbose flag through fetchQuotaWithDedup to fetchAccountQuota - Change verbose logging from console.log to console.error (stderr) - Keep quotaPercent optional (semantically correct for cases without quota) --- src/cliproxy/quota-fetcher.ts | 26 +++++++++++++------------- src/cliproxy/quota-manager.ts | 5 +++-- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 6011ea41..b009da92 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -182,7 +182,7 @@ async function refreshAccessToken( refreshToken: string, verbose = false ): Promise<{ accessToken: string | null; error?: string }> { - if (verbose) console.log('[i] Refreshing access token...'); + if (verbose) console.error('[i] Refreshing access token...'); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); @@ -204,13 +204,13 @@ async function refreshAccessToken( clearTimeout(timeoutId); - if (verbose) console.log(`[i] Token refresh status: ${response.status}`); + if (verbose) console.error(`[i] Token refresh status: ${response.status}`); const data = (await response.json()) as TokenRefreshResponse; if (!response.ok || data.error) { const error = data.error_description || data.error || `OAuth error: ${response.status}`; - if (verbose) console.log(`[!] Token refresh failed: ${error}`); + if (verbose) console.error(`[!] Token refresh failed: ${error}`); return { accessToken: null, error, @@ -218,11 +218,11 @@ async function refreshAccessToken( } if (!data.access_token) { - if (verbose) console.log('[!] Token refresh failed: No access_token in response'); + if (verbose) console.error('[!] Token refresh failed: No access_token in response'); return { accessToken: null, error: 'No access_token in response' }; } - if (verbose) console.log('[i] Token refresh: success'); + if (verbose) console.error('[i] Token refresh: success'); return { accessToken: data.access_token }; } catch (err) { clearTimeout(timeoutId); @@ -232,7 +232,7 @@ async function refreshAccessToken( : err instanceof Error ? err.message : 'Unknown error'; - if (verbose) console.log(`[!] Token refresh failed: ${errorMsg}`); + if (verbose) console.error(`[!] Token refresh failed: ${errorMsg}`); return { accessToken: null, error: errorMsg }; } } @@ -539,12 +539,12 @@ export async function fetchAccountQuota( accountId: string, verbose = false ): Promise { - if (verbose) console.log(`[i] Fetching quota for ${accountId}...`); + if (verbose) console.error(`[i] Fetching quota for ${accountId}...`); // Only Antigravity supports quota fetching if (provider !== 'agy') { const error = `Quota not supported for provider: ${provider}`; - if (verbose) console.log(`[!] Error: ${error}`); + if (verbose) console.error(`[!] Error: ${error}`); return { success: false, models: [], @@ -557,7 +557,7 @@ export async function fetchAccountQuota( const authData = readAuthData(provider, accountId); if (!authData) { const error = 'Auth file not found for account'; - if (verbose) console.log(`[!] Error: ${error}`); + if (verbose) console.error(`[!] Error: ${error}`); return { success: false, models: [], @@ -590,7 +590,7 @@ export async function fetchAccountQuota( } if (verbose && !tokenRefreshed) { - console.log('[i] Token refresh: skipped'); + console.error('[i] Token refresh: skipped'); } // Get project ID and tier - prefer stored project ID, but always call API for tier @@ -611,7 +611,7 @@ export async function fetchAccountQuota( } if (!lastProjectResult.projectId) { const error = lastProjectResult.error || 'Failed to retrieve project ID'; - if (verbose) console.log(`[!] Error: ${error}`); + if (verbose) console.error(`[!] Error: ${error}`); return { success: false, models: [], @@ -626,12 +626,12 @@ export async function fetchAccountQuota( projectId = lastProjectResult.projectId || projectId; apiTier = lastProjectResult.tier || 'unknown'; - if (verbose) console.log(`[i] Project ID: ${projectId || 'not found'}`); + if (verbose) console.error(`[i] Project ID: ${projectId || 'not found'}`); // Fetch models with quota const result = await fetchAvailableModels(accessToken, projectId as string); - if (verbose) console.log(`[i] Models found: ${result.models.length}`); + if (verbose) console.error(`[i] Models found: ${result.models.length}`); // If quota fetch fails with auth error and we haven't refreshed yet, try refresh if (!result.success && result.error?.includes('expired') && authData.refreshToken) { diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index 8b9eb30a..48e1bc12 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -85,7 +85,8 @@ export function clearQuotaCache(): void { */ async function fetchQuotaWithDedup( provider: CLIProxyProvider, - accountId: string + accountId: string, + verbose = false ): Promise { const key = getCacheKey(provider, accountId); @@ -96,7 +97,7 @@ async function fetchQuotaWithDedup( } // Start new fetch and track it - const fetchPromise = fetchAccountQuota(provider, accountId) + const fetchPromise = fetchAccountQuota(provider, accountId, verbose) .then((result) => { setCachedQuota(provider, accountId, result); return result;