Merge pull request #331 from kaitranntt/fix/quota-fix

fix(cliproxy): return null for unknown quota, add verbose diagnostics
This commit is contained in:
Kai (Tam Nhu) Tran
2026-01-14 11:38:31 -05:00
committed by GitHub
4 changed files with 82 additions and 34 deletions
+3 -1
View File
@@ -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)`);
}
}
}
+61 -17
View File
@@ -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<QuotaResult> {
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<AllAccountsQuotaResult> {
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
+11 -10
View File
@@ -85,7 +85,8 @@ export function clearQuotaCache(): void {
*/
async function fetchQuotaWithDedup(
provider: CLIProxyProvider,
accountId: string
accountId: string,
verbose = false
): Promise<QuotaResult> {
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<Prefli
}
// Calculate average quota
const avgQuota = calculateAverageQuota(quota);
const avgQuota = calculateAverageQuota(quota) ?? 0;
const threshold = quotaConfig.auto?.exhaustion_threshold ?? 5;
if (avgQuota < threshold) {
@@ -352,7 +353,7 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
return {
proceed: true,
accountId: defaultAccount.id,
quotaPercent: avgQuota,
quotaPercent: calculateAverageQuota(quota),
};
}
@@ -363,7 +364,7 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
accounts: Array<{
account: AccountInfo;
quota: number;
quota: number | null;
paused: boolean;
onCooldown: boolean;
isDefault: boolean;
@@ -379,7 +380,7 @@ export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
quota = await fetchQuotaWithDedup(provider, account.id);
}
const avgQuota = quota ? calculateAverageQuota(quota) : 100;
const avgQuota = quota ? calculateAverageQuota(quota) : null;
return {
account,
+7 -6
View File
@@ -576,6 +576,7 @@ async function showHelp(): Promise<void> {
['--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<void> {
// DOCTOR COMMAND - Quota diagnostics and shared project detection
// ============================================================================
async function handleDoctor(): Promise<void> {
async function handleDoctor(verbose = false): Promise<void> {
await initUI();
console.log(header('CLIProxy Quota Diagnostics'));
console.log('');
@@ -621,7 +622,7 @@ async function handleDoctor(): Promise<void> {
// 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<void> {
}
}
async function handleQuotaStatus(): Promise<void> {
async function handleQuotaStatus(verbose = false): Promise<void> {
await initUI();
console.log(header('Quota Status'));
console.log('');
@@ -841,7 +842,7 @@ async function handleQuotaStatus(): Promise<void> {
}
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<void> {
}
if (command === 'doctor' || command === 'diag') {
await handleDoctor();
await handleDoctor(verbose);
return;
}
@@ -963,7 +964,7 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
}
if (command === 'quota') {
await handleQuotaStatus();
await handleQuotaStatus(verbose);
return;
}