mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 14:17:11 +00:00
feat(cliproxy): add multi-provider quota display for Codex and Gemini CLI
Add quota fetching for Codex (ChatGPT) and Gemini CLI providers with unified CLI display. Extends `ccs cliproxy quota` with --provider flag. New files: - quota-types.ts: Shared type definitions for multi-provider quota - quota-fetcher-codex.ts: Fetches from ChatGPT backend API - quota-fetcher-gemini-cli.ts: Fetches from Google Cloud Code API Features: - Parallel quota fetching from all providers - --provider flag to filter (agy|codex|gemini|all) - Rate limit windows for Codex (primary/secondary/code review) - Bucket grouping for Gemini CLI (Flash/Pro series) - Reset time display with proper formatting Edge case handling: - 429 rate limit specific error messages - Clamp percentages/fractions to valid ranges - Null/negative reset time guards - Empty --provider= value validation
This commit is contained in:
1 parent
06154165f5
commit
30e611fc28
4 files changed
+1163
-25
No files matched your search
@@ -28,6 +28,9 @@ import {
|
||||
findAccountByQuery,
|
||||
} from '../cliproxy/account-manager';
|
||||
import { fetchAllProviderQuotas } from '../cliproxy/quota-fetcher';
|
||||
import { fetchAllCodexQuotas } from '../cliproxy/quota-fetcher-codex';
|
||||
import { fetchAllGeminiCliQuotas } from '../cliproxy/quota-fetcher-gemini-cli';
|
||||
import type { CodexQuotaResult, GeminiCliQuotaResult } from '../cliproxy/quota-types';
|
||||
import { isOnCooldown } from '../cliproxy/quota-manager';
|
||||
import { DEFAULT_BACKEND, getFallbackVersion, BACKEND_CONFIG } from '../cliproxy/platform-detector';
|
||||
import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector';
|
||||
@@ -71,6 +74,66 @@ import { handleSync } from './cliproxy-sync-handler';
|
||||
// ARGUMENT PARSING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse --provider flag from args for quota command
|
||||
* Returns the provider filter value and remaining args
|
||||
* Accepts: agy, codex, gemini, gemini-cli, all
|
||||
*/
|
||||
function parseProviderArg(args: string[]): {
|
||||
provider: 'agy' | 'codex' | 'gemini' | 'all';
|
||||
remainingArgs: string[];
|
||||
} {
|
||||
const providerIdx = args.indexOf('--provider');
|
||||
if (providerIdx === -1) {
|
||||
// Also check for --provider=value format
|
||||
const providerEqualsIdx = args.findIndex((a) => a.startsWith('--provider='));
|
||||
if (providerEqualsIdx !== -1) {
|
||||
const value = args[providerEqualsIdx].split('=')[1]?.toLowerCase() || '';
|
||||
const remainingArgs = [...args];
|
||||
remainingArgs.splice(providerEqualsIdx, 1);
|
||||
// Handle empty value
|
||||
if (!value) {
|
||||
console.error(
|
||||
warn('--provider requires a value. Valid options: agy, codex, gemini, gemini-cli, all')
|
||||
);
|
||||
return { provider: 'all', remainingArgs };
|
||||
}
|
||||
// Normalize gemini-cli to gemini
|
||||
const normalized = value === 'gemini-cli' ? 'gemini' : value;
|
||||
if (
|
||||
normalized !== 'agy' &&
|
||||
normalized !== 'codex' &&
|
||||
normalized !== 'gemini' &&
|
||||
normalized !== 'all'
|
||||
) {
|
||||
console.error(
|
||||
warn(`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, all`)
|
||||
);
|
||||
return { provider: 'all', remainingArgs };
|
||||
}
|
||||
return { provider: normalized as 'agy' | 'codex' | 'gemini' | 'all', remainingArgs };
|
||||
}
|
||||
return { provider: 'all', remainingArgs: args };
|
||||
}
|
||||
const value = args[providerIdx + 1]?.toLowerCase() || 'all';
|
||||
const remainingArgs = [...args];
|
||||
remainingArgs.splice(providerIdx, 2);
|
||||
// Normalize gemini-cli to gemini
|
||||
const normalized = value === 'gemini-cli' ? 'gemini' : value;
|
||||
if (
|
||||
normalized !== 'agy' &&
|
||||
normalized !== 'codex' &&
|
||||
normalized !== 'gemini' &&
|
||||
normalized !== 'all'
|
||||
) {
|
||||
console.error(
|
||||
warn(`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, all`)
|
||||
);
|
||||
return { provider: 'all', remainingArgs };
|
||||
}
|
||||
return { provider: normalized as 'agy' | 'codex' | 'gemini' | 'all', remainingArgs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse --backend flag from args
|
||||
* Returns the backend value and remaining args without --backend flag
|
||||
@@ -635,7 +698,8 @@ async function showHelp(): Promise<void> {
|
||||
['default <account>', 'Set default account for rotation'],
|
||||
['pause <account>', 'Pause account (skip in rotation)'],
|
||||
['resume <account>', 'Resume paused account'],
|
||||
['quota', 'Show quota status for all accounts'],
|
||||
['quota', 'Show quota status for all providers'],
|
||||
['quota --provider <name>', 'Filter by provider (agy|codex|gemini)'],
|
||||
],
|
||||
],
|
||||
[
|
||||
@@ -911,22 +975,76 @@ async function handleResumeAccount(args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuotaStatus(verbose = false): Promise<void> {
|
||||
async function handleQuotaStatus(
|
||||
verbose = false,
|
||||
providerFilter: 'agy' | 'codex' | 'gemini' | 'all' = 'all'
|
||||
): Promise<void> {
|
||||
await initUI();
|
||||
console.log(header('Quota Status'));
|
||||
console.log('');
|
||||
|
||||
const shouldFetch = {
|
||||
agy: providerFilter === 'all' || providerFilter === 'agy',
|
||||
codex: providerFilter === 'all' || providerFilter === 'codex',
|
||||
gemini: providerFilter === 'all' || providerFilter === 'gemini',
|
||||
};
|
||||
|
||||
console.log(dim('Fetching quotas...'));
|
||||
|
||||
// Parallel fetch from all requested providers
|
||||
const [agyResults, codexResults, geminiResults] = await Promise.all([
|
||||
shouldFetch.agy ? fetchAllProviderQuotas('agy', verbose) : null,
|
||||
shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null,
|
||||
shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null,
|
||||
]);
|
||||
|
||||
console.log('');
|
||||
|
||||
// Display Antigravity section
|
||||
if (agyResults && agyResults.accounts.length > 0) {
|
||||
displayAntigravityQuotaSection(agyResults, verbose);
|
||||
} else if (shouldFetch.agy) {
|
||||
console.log(subheader('Antigravity (0 accounts)'));
|
||||
console.log(info('No Antigravity accounts configured'));
|
||||
console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Display Codex section
|
||||
if (codexResults && codexResults.length > 0) {
|
||||
displayCodexQuotaSection(codexResults);
|
||||
} else if (shouldFetch.codex) {
|
||||
console.log(subheader('Codex (0 accounts)'));
|
||||
console.log(info('No Codex accounts configured'));
|
||||
console.log(` Run: ${color('ccs codex --auth', 'command')} to authenticate`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Display Gemini CLI section
|
||||
if (geminiResults && geminiResults.length > 0) {
|
||||
displayGeminiCliQuotaSection(geminiResults);
|
||||
} else if (shouldFetch.gemini) {
|
||||
console.log(subheader('Gemini CLI (0 accounts)'));
|
||||
console.log(info('No Gemini CLI accounts configured'));
|
||||
console.log(` Run: ${color('ccs gemini --auth', 'command')} to authenticate`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display Antigravity quota section
|
||||
*/
|
||||
function displayAntigravityQuotaSection(
|
||||
quotaResult: Awaited<ReturnType<typeof fetchAllProviderQuotas>>,
|
||||
_verbose: boolean
|
||||
): void {
|
||||
const provider: CLIProxyProvider = 'agy';
|
||||
const accounts = getProviderAccounts(provider);
|
||||
|
||||
if (accounts.length === 0) {
|
||||
console.log(info('No Antigravity accounts configured'));
|
||||
console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(dim('Fetching quotas...'));
|
||||
const quotaResult = await fetchAllProviderQuotas(provider, verbose);
|
||||
console.log(
|
||||
subheader(`Antigravity (${accounts.length} account${accounts.length !== 1 ? 's' : ''})`)
|
||||
);
|
||||
console.log('');
|
||||
|
||||
// Build table rows
|
||||
const rows: string[][] = [];
|
||||
@@ -961,7 +1079,6 @@ async function handleQuotaStatus(verbose = false): Promise<void> {
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(
|
||||
table(rows, {
|
||||
head: ['', 'Account', 'Tier', 'Quota', 'Status'],
|
||||
@@ -969,25 +1086,120 @@ async function handleQuotaStatus(verbose = false): Promise<void> {
|
||||
})
|
||||
);
|
||||
console.log('');
|
||||
console.log(info(`Default account marked with ${color('*', 'success')}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display Codex quota section
|
||||
*/
|
||||
function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaResult }[]): void {
|
||||
console.log(subheader(`Codex (${results.length} account${results.length !== 1 ? 's' : ''})`));
|
||||
console.log('');
|
||||
|
||||
// Show summary of paused/cooldown accounts
|
||||
const pausedCount = accounts.filter((a) => a.paused).length;
|
||||
const cooldownCount = accounts.filter((a) => isOnCooldown(provider, a.id)).length;
|
||||
if (pausedCount > 0) {
|
||||
console.log(
|
||||
warn(`${pausedCount} account(s) paused - use 'ccs cliproxy resume <account>' to re-enable`)
|
||||
);
|
||||
}
|
||||
if (cooldownCount > 0) {
|
||||
console.log(info(`${cooldownCount} account(s) on cooldown (exhausted recently)`));
|
||||
}
|
||||
if (pausedCount > 0 || cooldownCount > 0) {
|
||||
for (const { account, quota } of results) {
|
||||
const accountInfo = findAccountByQuery('codex', account);
|
||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(account)}${defaultMark}`);
|
||||
console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`);
|
||||
console.log('');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate overall quota health
|
||||
const avgQuota =
|
||||
quota.windows.length > 0
|
||||
? quota.windows.reduce((sum, w) => sum + w.remainingPercent, 0) / quota.windows.length
|
||||
: 0;
|
||||
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
|
||||
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
|
||||
|
||||
console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`);
|
||||
|
||||
// Show windows
|
||||
for (const window of quota.windows) {
|
||||
const bar = formatQuotaBar(window.remainingPercent);
|
||||
const resetLabel = window.resetAfterSeconds
|
||||
? dim(` Resets ${formatResetTime(window.resetAfterSeconds)}`)
|
||||
: '';
|
||||
console.log(
|
||||
` ${window.label.padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}`
|
||||
);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display Gemini CLI quota section
|
||||
*/
|
||||
function displayGeminiCliQuotaSection(
|
||||
results: { account: string; quota: GeminiCliQuotaResult }[]
|
||||
): void {
|
||||
console.log(
|
||||
subheader(`Gemini CLI (${results.length} account${results.length !== 1 ? 's' : ''})`)
|
||||
);
|
||||
console.log('');
|
||||
|
||||
for (const { account, quota } of results) {
|
||||
const accountInfo = findAccountByQuery('gemini', account);
|
||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(account)}${defaultMark}`);
|
||||
console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`);
|
||||
console.log('');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate overall quota health
|
||||
const avgQuota =
|
||||
quota.buckets.length > 0
|
||||
? quota.buckets.reduce((sum, b) => sum + b.remainingPercent, 0) / quota.buckets.length
|
||||
: 0;
|
||||
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
|
||||
|
||||
console.log(` ${statusIcon}${account}${defaultMark}`);
|
||||
if (quota.projectId) {
|
||||
console.log(` Project: ${dim(quota.projectId)}`);
|
||||
}
|
||||
|
||||
// Show buckets
|
||||
for (const bucket of quota.buckets) {
|
||||
const bar = formatQuotaBar(bucket.remainingPercent);
|
||||
const tokenLabel = bucket.tokenType ? dim(` (${bucket.tokenType})`) : '';
|
||||
const resetLabel = bucket.resetTime
|
||||
? dim(` Resets ${formatResetTimeISO(bucket.resetTime)}`)
|
||||
: '';
|
||||
console.log(
|
||||
` ${bucket.label.padEnd(24)} ${bar} ${bucket.remainingPercent.toFixed(0)}%${tokenLabel}${resetLabel}`
|
||||
);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reset time from seconds
|
||||
*/
|
||||
function formatResetTime(seconds: number): string {
|
||||
if (seconds <= 0) return 'now';
|
||||
if (seconds < 60) return `in ${seconds}s`;
|
||||
if (seconds < 3600) return `in ${Math.round(seconds / 60)}m`;
|
||||
return `in ${Math.round(seconds / 3600)}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reset time from ISO timestamp
|
||||
*/
|
||||
function formatResetTimeISO(isoTime: string): string {
|
||||
if (!isoTime) return 'unknown';
|
||||
const resetDate = new Date(isoTime);
|
||||
if (isNaN(resetDate.getTime())) return 'unknown';
|
||||
const seconds = Math.max(0, Math.round((resetDate.getTime() - Date.now()) / 1000));
|
||||
return formatResetTime(seconds);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN ROUTER
|
||||
// ============================================================================
|
||||
@@ -1057,7 +1269,8 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
if (command === 'quota') {
|
||||
await handleQuotaStatus(verbose);
|
||||
const { provider: providerFilter } = parseProviderArg(remainingArgs.slice(1));
|
||||
await handleQuotaStatus(verbose, providerFilter);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user