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..b009da92 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.error('[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.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.error(`[!] 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.error('[!] Token refresh failed: No access_token in response'); return { accessToken: null, error: 'No access_token in response' }; } + if (verbose) console.error('[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.error(`[!] 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.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.error(`[!] 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.error(`[!] 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.error('[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.error(`[!] 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.error(`[i] Project ID: ${projectId || 'not found'}`); + // Fetch models with quota const result = await fetchAvailableModels(accessToken, projectId as string); + 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) { - 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..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; @@ -176,15 +177,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 +221,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 +337,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; }