From 1ac19415ce835df15f3fcefbb698f12ec89ec5e9 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 14 Jan 2026 10:19:27 -0500 Subject: [PATCH 1/7] 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/7] 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; From b740816dd926ccb2336fca8f60f8212b266c7fd0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 14 Jan 2026 16:39:33 +0000 Subject: [PATCH 3/7] chore(release): 7.20.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index edf9f0e9..0b1b532a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.20.0", + "version": "7.20.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From d9631be81a018d9e007f241bcb6b928664cc6991 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 14 Jan 2026 13:06:58 -0500 Subject: [PATCH 4/7] fix(deps): add express-rate-limit to production dependencies express-rate-limit was imported in auth-middleware.ts but only the @types/express-rate-limit package was declared in devDependencies. This caused "Cannot find module 'express-rate-limit'" error when running `ccs config` after installing v7.20.0 globally. Closes #333 --- bun.lock | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/bun.lock b/bun.lock index 501cfb5e..a9f354a1 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "chokidar": "^3.6.0", "cli-table3": "^0.6.5", "express": "^4.18.2", + "express-rate-limit": "^8.2.1", "express-session": "^1.18.2", "get-port": "^5.1.1", "gradient-string": "^2.0.2", diff --git a/package.json b/package.json index 0b1b532a..324e938e 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "chokidar": "^3.6.0", "cli-table3": "^0.6.5", "express": "^4.18.2", + "express-rate-limit": "^8.2.1", "express-session": "^1.18.2", "get-port": "^5.1.1", "gradient-string": "^2.0.2", From 43aaa6dbf405b9363a041f4350861b30ed0b210f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 14 Jan 2026 18:12:00 +0000 Subject: [PATCH 5/7] chore(release): 7.20.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 324e938e..099d5eb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.20.0-dev.1", + "version": "7.20.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From ac7b324d4989883c7a8e92030891e51bfc040cc3 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 14 Jan 2026 13:31:18 -0500 Subject: [PATCH 6/7] fix(ci): expand ai-review allowedTools to prevent token waste - Add more allowed bash patterns: gh issue view, gh api, git commands - Add base tools: Edit, LS for better flexibility - Update prompt to write pr_review.md in working dir (not /tmp/) - Add step to Read before Write to avoid security block - Add explicit rules about avoiding shell operators (||, &&, <<) - Allow rm pr_review.md for cleanup Previous run had 9 tool rejections wasting ~5000 tokens due to: - gh issue view not allowed (only gh pr view was) - /tmp/ outside working directory - Write without Read blocked - Complex shell operators blocked --- .github/workflows/ai-review.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 18fee2e4..518904f8 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -160,15 +160,19 @@ jobs: ## IMPORTANT: Posting the Review After completing your analysis, post the review as a PR comment. - REQUIRED METHOD (use Write tool + --body-file): - 1. Write your review to /tmp/pr_review.md using the Write tool - 2. Post with: gh pr comment ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} --body-file /tmp/pr_review.md + STEP 1: First, use the Read tool to check if pr_review.md exists (it may not exist yet, that's OK) + STEP 2: Use the Write tool to write your review to pr_review.md in the current working directory + STEP 3: Post with: gh pr comment ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} --body-file pr_review.md - DO NOT use inline --body with multiline content - it will fail pattern matching. + IMPORTANT RULES: + - Write to pr_review.md (in working directory), NOT /tmp/pr_review.md + - Do NOT use shell operators like || or && in bash commands + - Do NOT use heredoc (<<) syntax in bash commands + - Use simple, single-purpose bash commands only claude_args: | --model ${{ env.REVIEW_MODEL }} - --allowedTools "Bash(gh pr comment *),Bash(gh pr diff *),Bash(gh pr view *),Bash(echo *),Write,Read,Glob,Grep" + --allowedTools "Edit,Glob,Grep,LS,Read,Write,Bash(gh pr comment *),Bash(gh pr diff *),Bash(gh pr view *),Bash(gh issue view *),Bash(gh api *),Bash(git diff *),Bash(git log *),Bash(git status *),Bash(cat *),Bash(ls *),Bash(rm pr_review.md)" continue-on-error: true - name: Add success reaction From bb9549cb22afbe8f3cd9a74aefde0c880b0bbfdc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 14 Jan 2026 18:32:34 +0000 Subject: [PATCH 7/7] chore(release): 7.20.0-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 099d5eb0..ee76e13c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.20.0-dev.2", + "version": "7.20.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",