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
This commit is contained in:
kaitranntt committed 2026-01-14 10:19:27 -05:00
1 parent 3b4c29ddcb
commit 1ac19415ce
4 files changed
+79 -32

No files matched your search

+3 -1
View File
@@ -485,8 +485,10 @@ export async function execClaudeWithCLIProxy(
if (preflight.switchedFrom) { if (preflight.switchedFrom) {
console.log(info(`Auto-switched to ${preflight.accountId}`)); console.log(info(`Auto-switched to ${preflight.accountId}`));
console.log(` Reason: ${preflight.reason}`); 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)}%`); console.log(` New account quota: ${preflight.quotaPercent.toFixed(1)}%`);
} else {
console.log(` New account quota: N/A (fetch unavailable)`);
} }
} }
} }
+61 -17
View File
@@ -179,8 +179,11 @@ function isTokenExpired(expiredStr?: string): boolean {
* This allows CCS to get fresh tokens independently of CLIProxyAPI * This allows CCS to get fresh tokens independently of CLIProxyAPI
*/ */
async function refreshAccessToken( async function refreshAccessToken(
refreshToken: string refreshToken: string,
verbose = false
): Promise<{ accessToken: string | null; error?: string }> { ): Promise<{ accessToken: string | null; error?: string }> {
if (verbose) console.log('[i] Refreshing access token...');
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); const timeoutId = setTimeout(() => controller.abort(), 10000);
@@ -201,26 +204,36 @@ async function refreshAccessToken(
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (verbose) console.log(`[i] Token refresh status: ${response.status}`);
const data = (await response.json()) as TokenRefreshResponse; const data = (await response.json()) as TokenRefreshResponse;
if (!response.ok || data.error) { 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 { return {
accessToken: null, accessToken: null,
error: data.error_description || data.error || `OAuth error: ${response.status}`, error,
}; };
} }
if (!data.access_token) { 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' }; return { accessToken: null, error: 'No access_token in response' };
} }
if (verbose) console.log('[i] Token refresh: success');
return { accessToken: data.access_token }; return { accessToken: data.access_token };
} catch (err) { } catch (err) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (err instanceof Error && err.name === 'AbortError') { const errorMsg =
return { accessToken: null, error: 'Token refresh timeout' }; err instanceof Error && err.name === 'AbortError'
} ? 'Token refresh timeout'
return { accessToken: null, error: err instanceof Error ? err.message : 'Unknown error' }; : 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 provider - Provider name (only 'agy' supported)
* @param accountId - Account identifier (email) * @param accountId - Account identifier (email)
* @param verbose - Show detailed diagnostics
* @returns Quota result with models and percentages * @returns Quota result with models and percentages
*/ */
export async function fetchAccountQuota( export async function fetchAccountQuota(
provider: CLIProxyProvider, provider: CLIProxyProvider,
accountId: string accountId: string,
verbose = false
): Promise<QuotaResult> { ): Promise<QuotaResult> {
if (verbose) console.log(`[i] Fetching quota for ${accountId}...`);
// Only Antigravity supports quota fetching // Only Antigravity supports quota fetching
if (provider !== 'agy') { if (provider !== 'agy') {
const error = `Quota not supported for provider: ${provider}`;
if (verbose) console.log(`[!] Error: ${error}`);
return { return {
success: false, success: false,
models: [], models: [],
lastUpdated: Date.now(), lastUpdated: Date.now(),
error: `Quota not supported for provider: ${provider}`, error,
}; };
} }
// Read auth data from auth file // Read auth data from auth file
const authData = readAuthData(provider, accountId); const authData = readAuthData(provider, accountId);
if (!authData) { if (!authData) {
const error = 'Auth file not found for account';
if (verbose) console.log(`[!] Error: ${error}`);
return { return {
success: false, success: false,
models: [], models: [],
lastUpdated: Date.now(), 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) // Proactive refresh: refresh 5 minutes before expiry (matches CLIProxyAPIPlus behavior)
let accessToken = authData.accessToken; let accessToken = authData.accessToken;
const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; // 5 minutes const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; // 5 minutes
let tokenRefreshed = false;
if (authData.refreshToken) { if (authData.refreshToken) {
const shouldRefresh = const shouldRefresh =
@@ -558,14 +580,19 @@ export async function fetchAccountQuota(
new Date(authData.expiresAt).getTime() - Date.now() < REFRESH_LEAD_TIME_MS; // Expiring soon new Date(authData.expiresAt).getTime() - Date.now() < REFRESH_LEAD_TIME_MS; // Expiring soon
if (shouldRefresh) { if (shouldRefresh) {
const refreshResult = await refreshAccessToken(authData.refreshToken); const refreshResult = await refreshAccessToken(authData.refreshToken, verbose);
if (refreshResult.accessToken) { if (refreshResult.accessToken) {
accessToken = refreshResult.accessToken; accessToken = refreshResult.accessToken;
tokenRefreshed = true;
} }
// If refresh fails, fall back to existing token (might still work) // 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 // Get project ID and tier - prefer stored project ID, but always call API for tier
let projectId = authData.projectId; let projectId = authData.projectId;
let apiTier: AccountTier = 'unknown'; let apiTier: AccountTier = 'unknown';
@@ -576,18 +603,20 @@ export async function fetchAccountQuota(
if (!lastProjectResult.projectId && !projectId) { if (!lastProjectResult.projectId && !projectId) {
// If project ID fetch fails, it might be token issue - try refresh if we haven't // If project ID fetch fails, it might be token issue - try refresh if we haven't
if (authData.refreshToken && accessToken === authData.accessToken) { if (authData.refreshToken && accessToken === authData.accessToken) {
const refreshResult = await refreshAccessToken(authData.refreshToken); const refreshResult = await refreshAccessToken(authData.refreshToken, verbose);
if (refreshResult.accessToken) { if (refreshResult.accessToken) {
accessToken = refreshResult.accessToken; accessToken = refreshResult.accessToken;
lastProjectResult = await getProjectId(accessToken); lastProjectResult = await getProjectId(accessToken);
} }
} }
if (!lastProjectResult.projectId) { if (!lastProjectResult.projectId) {
const error = lastProjectResult.error || 'Failed to retrieve project ID';
if (verbose) console.log(`[!] Error: ${error}`);
return { return {
success: false, success: false,
models: [], models: [],
lastUpdated: Date.now(), lastUpdated: Date.now(),
error: lastProjectResult.error || 'Failed to retrieve project ID', error,
isUnprovisioned: lastProjectResult.isUnprovisioned, isUnprovisioned: lastProjectResult.isUnprovisioned,
}; };
} }
@@ -597,12 +626,16 @@ export async function fetchAccountQuota(
projectId = lastProjectResult.projectId || projectId; projectId = lastProjectResult.projectId || projectId;
apiTier = lastProjectResult.tier || 'unknown'; apiTier = lastProjectResult.tier || 'unknown';
if (verbose) console.log(`[i] Project ID: ${projectId || 'not found'}`);
// Fetch models with quota // Fetch models with quota
const result = await fetchAvailableModels(accessToken, projectId as string); 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 quota fetch fails with auth error and we haven't refreshed yet, try refresh
if (!result.success && result.error?.includes('expired') && authData.refreshToken) { 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) { if (refreshResult.accessToken) {
const retryResult = await fetchAvailableModels( const retryResult = await fetchAvailableModels(
refreshResult.accessToken, refreshResult.accessToken,
@@ -617,6 +650,9 @@ export async function fetchAccountQuota(
retryResult.tier = finalTier; retryResult.tier = finalTier;
retryResult.accountId = accountId; retryResult.accountId = accountId;
setAccountTier(provider, accountId, finalTier); setAccountTier(provider, accountId, finalTier);
if (verbose && retryResult.error) {
console.log(`[!] Error: ${retryResult.error}`);
}
} }
return retryResult; return retryResult;
} }
@@ -633,6 +669,10 @@ export async function fetchAccountQuota(
setAccountTier(provider, accountId, finalTier); setAccountTier(provider, accountId, finalTier);
} }
if (verbose && result.error) {
console.log(`[!] Error: ${result.error}`);
}
return result; return result;
} }
@@ -668,10 +708,12 @@ export interface AllAccountsQuotaResult {
* Also detects accounts sharing same GCP project (failover won't help) * Also detects accounts sharing same GCP project (failover won't help)
* *
* @param provider - Provider name (only 'agy' supported for quota) * @param provider - Provider name (only 'agy' supported for quota)
* @param verbose - Show detailed diagnostics
* @returns Results for all accounts with project grouping * @returns Results for all accounts with project grouping
*/ */
export async function fetchAllProviderQuotas( export async function fetchAllProviderQuotas(
provider: CLIProxyProvider provider: CLIProxyProvider,
verbose = false
): Promise<AllAccountsQuotaResult> { ): Promise<AllAccountsQuotaResult> {
const accounts = getProviderAccounts(provider); const accounts = getProviderAccounts(provider);
const results: AllAccountsQuotaResult = { const results: AllAccountsQuotaResult = {
@@ -687,7 +729,7 @@ export async function fetchAllProviderQuotas(
// Fetch quota for each account in parallel // Fetch quota for each account in parallel
const quotaPromises = accounts.map(async (account) => { 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 // Read project ID from auth file if not in quota result
let projectId = quota.projectId; let projectId = quota.projectId;
@@ -724,13 +766,15 @@ export async function fetchAllProviderQuotas(
* *
* @param provider - Provider name * @param provider - Provider name
* @param excludeAccountId - Account to exclude (current exhausted account) * @param excludeAccountId - Account to exclude (current exhausted account)
* @param verbose - Show detailed diagnostics
* @returns Account with available quota, or null if none available * @returns Account with available quota, or null if none available
*/ */
export async function findAvailableAccount( export async function findAvailableAccount(
provider: CLIProxyProvider, provider: CLIProxyProvider,
excludeAccountId?: string excludeAccountId?: string,
verbose = false
): Promise<{ account: AccountInfo; quota: QuotaResult } | null> { ): 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 // Get excluded account's project ID to avoid switching to same-project accounts
const excludedProjectId = allQuotas.accounts.find((a) => a.account.id === excludeAccountId)?.quota const excludedProjectId = allQuotas.accounts.find((a) => a.account.id === excludeAccountId)?.quota
+8 -8
View File
@@ -176,15 +176,15 @@ export interface PreflightResult {
/** Reason for switch or failure */ /** Reason for switch or failure */
reason?: string; reason?: string;
/** Average quota percentage of selected account */ /** Average quota percentage of selected account */
quotaPercent?: number; quotaPercent?: number | null;
} }
/** /**
* Calculate average quota percentage from models * Calculate average quota percentage from models
*/ */
function calculateAverageQuota(quota: QuotaResult): number { function calculateAverageQuota(quota: QuotaResult): number | null {
if (!quota.success || quota.models.length === 0) { 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); const total = quota.models.reduce((sum, m) => sum + m.percentage, 0);
return total / quota.models.length; return total / quota.models.length;
@@ -220,7 +220,7 @@ export async function findHealthyAccount(
quota = await fetchQuotaWithDedup(provider, account.id); quota = await fetchQuotaWithDedup(provider, account.id);
} }
const avgQuota = calculateAverageQuota(quota); const avgQuota = calculateAverageQuota(quota) ?? 0;
return { return {
id: account.id, id: account.id,
@@ -336,7 +336,7 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
} }
// Calculate average quota // Calculate average quota
const avgQuota = calculateAverageQuota(quota); const avgQuota = calculateAverageQuota(quota) ?? 0;
const threshold = quotaConfig.auto?.exhaustion_threshold ?? 5; const threshold = quotaConfig.auto?.exhaustion_threshold ?? 5;
if (avgQuota < threshold) { if (avgQuota < threshold) {
@@ -352,7 +352,7 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
return { return {
proceed: true, proceed: true,
accountId: defaultAccount.id, accountId: defaultAccount.id,
quotaPercent: avgQuota, quotaPercent: calculateAverageQuota(quota),
}; };
} }
@@ -363,7 +363,7 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{ export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
accounts: Array<{ accounts: Array<{
account: AccountInfo; account: AccountInfo;
quota: number; quota: number | null;
paused: boolean; paused: boolean;
onCooldown: boolean; onCooldown: boolean;
isDefault: boolean; isDefault: boolean;
@@ -379,7 +379,7 @@ export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
quota = await fetchQuotaWithDedup(provider, account.id); quota = await fetchQuotaWithDedup(provider, account.id);
} }
const avgQuota = quota ? calculateAverageQuota(quota) : 100; const avgQuota = quota ? calculateAverageQuota(quota) : null;
return { return {
account, account,
+7 -6
View File
@@ -576,6 +576,7 @@ async function showHelp(): Promise<void> {
['--update', 'Unpin and update to latest version'], ['--update', 'Unpin and update to latest version'],
], ],
], ],
['Options:', [['--verbose, -v', 'Show detailed quota fetch diagnostics']]],
]; ];
for (const [title, cmds] of sections) { for (const [title, cmds] of sections) {
@@ -601,7 +602,7 @@ async function showHelp(): Promise<void> {
// DOCTOR COMMAND - Quota diagnostics and shared project detection // DOCTOR COMMAND - Quota diagnostics and shared project detection
// ============================================================================ // ============================================================================
async function handleDoctor(): Promise<void> { async function handleDoctor(verbose = false): Promise<void> {
await initUI(); await initUI();
console.log(header('CLIProxy Quota Diagnostics')); console.log(header('CLIProxy Quota Diagnostics'));
console.log(''); console.log('');
@@ -621,7 +622,7 @@ async function handleDoctor(): Promise<void> {
// Fetch quota for all accounts // Fetch quota for all accounts
console.log(dim('Fetching quotas...')); console.log(dim('Fetching quotas...'));
const quotaResult = await fetchAllProviderQuotas(provider); const quotaResult = await fetchAllProviderQuotas(provider, verbose);
// Display per-account quota status // Display per-account quota status
for (const { account, quota } of quotaResult.accounts) { for (const { account, quota } of quotaResult.accounts) {
@@ -826,7 +827,7 @@ async function handleResumeAccount(args: string[]): Promise<void> {
} }
} }
async function handleQuotaStatus(): Promise<void> { async function handleQuotaStatus(verbose = false): Promise<void> {
await initUI(); await initUI();
console.log(header('Quota Status')); console.log(header('Quota Status'));
console.log(''); console.log('');
@@ -841,7 +842,7 @@ async function handleQuotaStatus(): Promise<void> {
} }
console.log(dim('Fetching quotas...')); console.log(dim('Fetching quotas...'));
const quotaResult = await fetchAllProviderQuotas(provider); const quotaResult = await fetchAllProviderQuotas(provider, verbose);
// Build table rows // Build table rows
const rows: string[][] = []; const rows: string[][] = [];
@@ -942,7 +943,7 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
} }
if (command === 'doctor' || command === 'diag') { if (command === 'doctor' || command === 'diag') {
await handleDoctor(); await handleDoctor(verbose);
return; return;
} }
@@ -963,7 +964,7 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
} }
if (command === 'quota') { if (command === 'quota') {
await handleQuotaStatus(); await handleQuotaStatus(verbose);
return; return;
} }