From 2385d9028ae37d00200516c9728cb29b9c45fd21 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 22 Feb 2026 01:19:02 +0700 Subject: [PATCH] feat(cliproxy): add Claude quota windows and account failover --- README.md | 6 +- src/cliproxy/executor/index.ts | 11 +- src/cliproxy/executor/retry-handler.ts | 4 +- src/cliproxy/quota-fetcher-claude.ts | 546 ++++++++++++++++++ src/cliproxy/quota-manager.ts | 134 ++++- src/cliproxy/quota-types.ts | 80 ++- src/commands/cliproxy/help-subcommand.ts | 4 +- src/commands/cliproxy/index.ts | 38 +- src/commands/cliproxy/quota-subcommand.ts | 192 +++++- .../routes/cliproxy-stats-routes.ts | 63 +- .../cliproxy/quota-fetcher-claude.test.ts | 257 +++++++++ .../account/flow-viz/account-card.tsx | 43 +- .../cliproxy/provider-editor/account-item.tsx | 39 +- .../provider-editor/accounts-section.tsx | 2 +- .../cliproxy/provider-editor/types.ts | 2 +- .../shared/quota-tooltip-content.tsx | 90 +++ ui/src/hooks/use-cliproxy-stats.ts | 36 +- ui/src/lib/api-client.ts | 56 ++ ui/src/lib/utils.ts | 70 ++- 19 files changed, 1599 insertions(+), 74 deletions(-) create mode 100644 src/cliproxy/quota-fetcher-claude.ts create mode 100644 tests/unit/cliproxy/quota-fetcher-claude.test.ts diff --git a/README.md b/README.md index b234b704..47cf77ce 100644 --- a/README.md +++ b/README.md @@ -249,14 +249,14 @@ ccs sync Re-creates symlinks for shared commands, skills, and settings. -### Antigravity Quota Management +### Quota Management ```bash ccs cliproxy doctor # Check quota status for all agy accounts -ccs cliproxy quota # Show agy/codex/gemini quotas (Codex: 5h + weekly reset schedule) +ccs cliproxy quota # Show agy/claude/codex/gemini/ghcp quotas (Claude/Codex: 5h + weekly reset schedule) ``` -**Auto-Failover**: When an Antigravity account runs out of quota, CCS automatically switches to another account with remaining capacity. Shared GCP project accounts are excluded (pooled quota). +**Auto-Failover**: When a managed account runs out of quota, CCS automatically switches to another account with remaining capacity. Shared GCP project accounts are excluded (pooled quota). ### CLIProxy Lifecycle diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 2d76146c..220ccdd2 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -610,12 +610,15 @@ export async function execClaudeWithCLIProxy( } } - // 3b. Preflight quota check (Antigravity only) + // 3b. Preflight quota check (providers with quota-based rotation) if (!skipLocalAuth) { - // Multi-tier quota check for composite variants (check if ANY tier uses 'agy') + // Multi-tier quota check for composite variants (check if any tier uses a managed provider) if (compositeProviders.length > 0) { - if (compositeProviders.includes('agy')) { - await handleQuotaCheck('agy'); + const managedQuotaProviders = ['agy', 'claude'] as const; + for (const managedProvider of managedQuotaProviders) { + if (compositeProviders.includes(managedProvider)) { + await handleQuotaCheck(managedProvider); + } } } else { await handleQuotaCheck(provider); diff --git a/src/cliproxy/executor/retry-handler.ts b/src/cliproxy/executor/retry-handler.ts index 7149cdad..e7cb50f9 100644 --- a/src/cliproxy/executor/retry-handler.ts +++ b/src/cliproxy/executor/retry-handler.ts @@ -77,10 +77,10 @@ export async function handleTokenExpiration( } /** - * Handle quota check and auto-switching for Antigravity + * Handle quota check and auto-switching for providers with quota-based rotation. */ export async function handleQuotaCheck(provider: CLIProxyProvider): Promise { - if (provider !== 'agy') return; + if (provider !== 'agy' && provider !== 'claude') return; const { preflightCheck } = await import('../quota-manager'); const preflight = await preflightCheck(provider); diff --git a/src/cliproxy/quota-fetcher-claude.ts b/src/cliproxy/quota-fetcher-claude.ts new file mode 100644 index 00000000..2be1c248 --- /dev/null +++ b/src/cliproxy/quota-fetcher-claude.ts @@ -0,0 +1,546 @@ +/** + * Quota Fetcher for Claude (Anthropic) Accounts + * + * Fetches policy limits from Claude API and normalizes 5h + weekly windows. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { getAuthDir } from './config-generator'; +import { getPausedDir, getProviderAccounts } from './account-manager'; +import { sanitizeEmail, isTokenExpired } from './auth-utils'; +import type { ClaudeQuotaResult, ClaudeQuotaWindow, ClaudeCoreUsageSummary } from './quota-types'; +import { clampPercent } from '../utils/percentage'; + +const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits'; +const CLAUDE_QUOTA_TIMEOUT_MS = 10000; +const CLAUDE_QUOTA_MAX_ATTEMPTS = 2; +const CLAUDE_USER_AGENT = 'ccs-cli/claude-quota'; + +interface ClaudeAuthData { + accessToken: string; + isExpired: boolean; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function asBoolean(value: unknown): boolean | undefined { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + if (value === 'true') return true; + if (value === 'false') return false; + } + return undefined; +} + +function asNumber(value: unknown): number | null { + if (typeof value === 'number' && isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + return isFinite(parsed) ? parsed : null; + } + return null; +} + +function normalizeTimestamp(value: unknown): string | null { + const asNum = asNumber(value); + if (asNum !== null) { + const millis = asNum > 1e12 ? asNum : asNum * 1000; + const date = new Date(millis); + return isNaN(date.getTime()) ? null : date.toISOString(); + } + + const str = asString(value); + if (!str) return null; + + // Numeric strings can be either epoch seconds or epoch milliseconds. + if (/^\d+$/.test(str)) { + const numeric = Number(str); + if (isFinite(numeric)) { + const millis = numeric > 1e12 ? numeric : numeric * 1000; + const date = new Date(millis); + return isNaN(date.getTime()) ? null : date.toISOString(); + } + } + + const date = new Date(str); + return isNaN(date.getTime()) ? null : date.toISOString(); +} + +function getClaudeWindowLabel(rateLimitType: string): string { + switch (rateLimitType) { + case 'five_hour': + return 'Session limit'; + case 'seven_day': + return 'Weekly limit'; + case 'seven_day_opus': + return 'Opus limit'; + case 'seven_day_sonnet': + return 'Sonnet limit'; + case 'overage': + return 'Extra usage'; + default: + return rateLimitType || 'Unknown limit'; + } +} + +function normalizeUtilization(raw: Record): { + utilization: number | null; + usedPercent: number; + remainingPercent: number; +} { + const utilizationRaw = asNumber(raw['utilization']); + const usedPercentRaw = asNumber(raw['usedPercent'] ?? raw['used_percent']); + const remainingPercentRaw = asNumber(raw['remainingPercent'] ?? raw['remaining_percent']); + + if (utilizationRaw !== null) { + const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100; + const usedPercent = clampPercent(ratio * 100); + return { + utilization: ratio, + usedPercent, + remainingPercent: clampPercent(100 - usedPercent), + }; + } + + if (usedPercentRaw !== null) { + const usedPercent = clampPercent(usedPercentRaw); + return { + utilization: usedPercent / 100, + usedPercent, + remainingPercent: clampPercent(100 - usedPercent), + }; + } + + if (remainingPercentRaw !== null) { + const remainingPercent = clampPercent(remainingPercentRaw); + const usedPercent = clampPercent(100 - remainingPercent); + return { + utilization: usedPercent / 100, + usedPercent, + remainingPercent, + }; + } + + return { + utilization: null, + usedPercent: 0, + remainingPercent: 100, + }; +} + +function normalizeRateLimitType(value: unknown, fallbackKey?: string): string { + const direct = asString(value); + if (direct) return direct; + if (fallbackKey) return fallbackKey; + return 'unknown'; +} + +function toObject(value: unknown): Record | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function normalizeRestriction( + raw: Record, + fallbackKey?: string +): ClaudeQuotaWindow | null { + const rateLimitType = normalizeRateLimitType( + raw['rateLimitType'] ?? raw['rate_limit_type'] ?? raw['claim'] ?? raw['claimAbbrev'], + fallbackKey + ); + if (!rateLimitType || rateLimitType === 'unknown') return null; + + const status = asString(raw['status']) || 'unknown'; + const resetAt = + normalizeTimestamp(raw['resetsAt'] ?? raw['resets_at'] ?? raw['resetAt'] ?? raw['reset_at']) || + null; + const overageResetsAt = + normalizeTimestamp( + raw['overageResetsAt'] ?? + raw['overage_resets_at'] ?? + raw['overageResetAt'] ?? + raw['overage_reset_at'] + ) || null; + + const { utilization, usedPercent, remainingPercent } = normalizeUtilization(raw); + + return { + rateLimitType, + label: getClaudeWindowLabel(rateLimitType), + status, + utilization, + usedPercent, + remainingPercent, + resetAt, + surpassedThreshold: asBoolean(raw['surpassedThreshold'] ?? raw['surpassed_threshold']), + severity: asString(raw['severity']) || undefined, + overageStatus: asString(raw['overageStatus'] ?? raw['overage_status']) || undefined, + overageResetsAt, + overageDisabledReason: + asString(raw['overageDisabledReason'] ?? raw['overage_disabled_reason']) || undefined, + isUsingOverage: asBoolean(raw['isUsingOverage'] ?? raw['is_using_overage']), + hasExtraUsageEnabled: asBoolean(raw['hasExtraUsageEnabled'] ?? raw['has_extra_usage_enabled']), + }; +} + +/** + * Parse raw policy limits response into normalized windows. + * Supports both array and object-map `restrictions` shapes. + */ +export function buildClaudeQuotaWindows(payload: Record): ClaudeQuotaWindow[] { + const rawRestrictions = payload['restrictions']; + const windows: ClaudeQuotaWindow[] = []; + + if (Array.isArray(rawRestrictions)) { + for (const item of rawRestrictions) { + const raw = toObject(item); + if (!raw) continue; + const window = normalizeRestriction(raw); + if (window) windows.push(window); + } + } else if (toObject(rawRestrictions)) { + for (const [key, value] of Object.entries(rawRestrictions as Record)) { + const raw = toObject(value); + if (!raw) continue; + const window = normalizeRestriction(raw, key); + if (window) windows.push(window); + } + } else if (toObject(payload)) { + // Some responses may contain a single restriction object directly. + const direct = normalizeRestriction(payload); + if (direct) windows.push(direct); + } + + const seen = new Set(); + const unique: ClaudeQuotaWindow[] = []; + for (const window of windows) { + const key = `${window.rateLimitType}:${window.resetAt ?? ''}:${window.status}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(window); + } + + return unique.sort((a, b) => a.rateLimitType.localeCompare(b.rateLimitType)); +} + +function toEpochMs(iso: string | null): number | null { + if (!iso) return null; + const value = new Date(iso).getTime(); + return isNaN(value) ? null : value; +} + +function pickMostRestrictiveWeekly(windows: ClaudeQuotaWindow[]): ClaudeQuotaWindow | null { + if (windows.length === 0) return null; + return [...windows].sort((a, b) => { + if (a.remainingPercent !== b.remainingPercent) { + return a.remainingPercent - b.remainingPercent; + } + const aReset = toEpochMs(a.resetAt); + const bReset = toEpochMs(b.resetAt); + if (aReset === null && bReset === null) return 0; + if (aReset === null) return 1; + if (bReset === null) return -1; + return aReset - bReset; + })[0]; +} + +function mapCoreWindow(window: ClaudeQuotaWindow | null): ClaudeCoreUsageSummary['fiveHour'] { + if (!window) return null; + return { + rateLimitType: window.rateLimitType, + label: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + status: window.status, + }; +} + +/** + * Build explicit 5h + weekly usage summary from Claude policy windows. + */ +export function buildClaudeCoreUsageSummary(windows: ClaudeQuotaWindow[]): ClaudeCoreUsageSummary { + if (!windows || windows.length === 0) { + return { fiveHour: null, weekly: null }; + } + + const fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null; + const weeklyCandidates = windows.filter((window) => + ['seven_day', 'seven_day_opus', 'seven_day_sonnet'].includes(window.rateLimitType) + ); + const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates); + + // Fallback: infer shortest/longest reset windows from non-overage limits. + if (!fiveHourWindow || !weeklyWindow) { + const nonOverage = windows.filter((window) => window.rateLimitType !== 'overage'); + const withReset = nonOverage + .map((window) => ({ + window, + resetMs: toEpochMs(window.resetAt), + })) + .filter((entry) => entry.resetMs !== null) + .sort((a, b) => (a.resetMs as number) - (b.resetMs as number)); + + const inferredFiveHour = + fiveHourWindow || + (withReset.length > 0 + ? withReset[0].window + : nonOverage.length > 0 + ? pickMostRestrictiveWeekly(nonOverage) + : null); + const inferredWeekly = + weeklyWindow || + (withReset.length > 1 + ? withReset[withReset.length - 1].window + : nonOverage.find((window) => window !== inferredFiveHour) || null); + + return { + fiveHour: mapCoreWindow(inferredFiveHour), + weekly: mapCoreWindow(inferredWeekly), + }; + } + + return { + fiveHour: mapCoreWindow(fiveHourWindow), + weekly: mapCoreWindow(weeklyWindow), + }; +} + +function extractAccessToken(data: Record): string | null { + const direct = asString(data['access_token']); + if (direct) return direct; + + const nested = toObject(data['token']); + if (nested) { + const nestedToken = asString(nested['access_token']); + if (nestedToken) return nestedToken; + } + + return null; +} + +function extractExpiry(data: Record): string | null { + const direct = asString(data['expired']); + if (direct) return direct; + + const nested = toObject(data['token']); + if (nested) { + return asString(nested['expiry']); + } + + return null; +} + +function readClaudeAuthData(accountId: string): ClaudeAuthData | null { + const authDirs = [getAuthDir(), getPausedDir()]; + const sanitizedId = sanitizeEmail(accountId); + const expectedFiles = [`claude-${sanitizedId}.json`, `anthropic-${sanitizedId}.json`]; + + for (const authDir of authDirs) { + if (!fs.existsSync(authDir)) continue; + + for (const expectedFile of expectedFiles) { + const filePath = path.join(authDir, expectedFile); + if (!fs.existsSync(filePath)) continue; + + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record; + const accessToken = extractAccessToken(data); + if (!accessToken) continue; + + const expiry = extractExpiry(data); + return { + accessToken, + isExpired: isTokenExpired(expiry ?? undefined), + }; + } catch { + continue; + } + } + + const files = fs.readdirSync(authDir); + for (const file of files) { + if ( + !file.endsWith('.json') || + (!file.startsWith('claude-') && !file.startsWith('anthropic-')) + ) { + continue; + } + + const filePath = path.join(authDir, file); + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record; + const accessToken = extractAccessToken(data); + if (!accessToken) continue; + + const fileEmail = asString(data['email']); + const typeValue = asString(data['type']); + const isClaudeType = + typeValue === null || typeValue === 'claude' || typeValue === 'anthropic'; + const matchesEmail = fileEmail === accountId; + const matchesFile = file.includes(sanitizedId); + + if ((matchesEmail || matchesFile) && isClaudeType) { + const expiry = extractExpiry(data); + return { + accessToken, + isExpired: isTokenExpired(expiry ?? undefined), + }; + } + } catch { + continue; + } + } + } + + return null; +} + +function buildEmptyResult( + error: string, + accountId: string, + needsReauth = false +): ClaudeQuotaResult { + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: Date.now(), + error, + accountId, + needsReauth, + }; +} + +/** + * Fetch quota for a single Claude account. + */ +export async function fetchClaudeQuota( + accountId: string, + verbose = false +): Promise { + const authData = readClaudeAuthData(accountId); + if (!authData) { + return buildEmptyResult('Auth file not found for Claude account', accountId); + } + + if (authData.isExpired) { + return buildEmptyResult( + 'Token expired - re-authenticate with ccs cliproxy auth claude', + accountId, + true + ); + } + + let lastError = 'Unknown error'; + + for (let attempt = 1; attempt <= CLAUDE_QUOTA_MAX_ATTEMPTS; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), CLAUDE_QUOTA_TIMEOUT_MS); + + try { + const response = await fetch(CLAUDE_POLICY_LIMITS_URL, { + method: 'GET', + signal: controller.signal, + headers: { + Authorization: `Bearer ${authData.accessToken}`, + Accept: 'application/json', + 'User-Agent': CLAUDE_USER_AGENT, + }, + }); + + clearTimeout(timeoutId); + if (verbose) { + console.error(`[i] Claude policy limits status: ${response.status} (attempt ${attempt})`); + } + + if (response.status === 401) { + return buildEmptyResult('Authentication required for policy limits', accountId, true); + } + + if (response.status === 404) { + // Some accounts may not expose policy limits; treat as empty but successful. + return { + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: Date.now(), + accountId, + }; + } + + if (response.status === 403) { + return buildEmptyResult('Not authorized for policy limits', accountId); + } + + if (!response.ok) { + lastError = `Policy limits API error: ${response.status}`; + if ( + attempt < CLAUDE_QUOTA_MAX_ATTEMPTS && + (response.status === 429 || response.status >= 500) + ) { + continue; + } + return buildEmptyResult(lastError, accountId); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + return buildEmptyResult('Invalid policy limits format', accountId); + } + + if (!toObject(payload)) { + return buildEmptyResult('Invalid policy limits format', accountId); + } + + const windows = buildClaudeQuotaWindows(payload as Record); + const coreUsage = buildClaudeCoreUsageSummary(windows); + + return { + success: true, + windows, + coreUsage, + lastUpdated: Date.now(), + accountId, + }; + } catch (error) { + clearTimeout(timeoutId); + lastError = + error instanceof Error && error.name === 'AbortError' + ? 'Policy limits request timeout' + : error instanceof Error + ? error.message + : 'Unknown error'; + + if (verbose) { + console.error(`[!] Claude policy limits failed (attempt ${attempt}): ${lastError}`); + } + + if (attempt >= CLAUDE_QUOTA_MAX_ATTEMPTS) { + return buildEmptyResult(lastError, accountId); + } + } + } + + return buildEmptyResult(lastError, accountId); +} + +/** + * Fetch quota for all Claude accounts. + */ +export async function fetchAllClaudeQuotas( + verbose = false +): Promise<{ account: string; quota: ClaudeQuotaResult }[]> { + const accounts = getProviderAccounts('claude'); + const results = await Promise.all( + accounts.map(async (account) => ({ + account: account.id, + quota: await fetchClaudeQuota(account.id, verbose), + })) + ); + return results; +} diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index f4091c8a..2aea73fc 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -14,6 +14,8 @@ import { CLIProxyProvider } from './types'; import { QuotaResult, fetchAccountQuota } from './quota-fetcher'; +import { fetchClaudeQuota } from './quota-fetcher-claude'; +import type { ClaudeQuotaResult } from './quota-types'; import { getDefaultAccount, getProviderAccounts, @@ -25,12 +27,21 @@ import { import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import type { RuntimeMonitorConfig } from '../config/unified-config-types'; +type ManagedQuotaProvider = 'agy' | 'claude'; +type ManagedQuotaResult = QuotaResult | ClaudeQuotaResult; + +const MANAGED_QUOTA_PROVIDERS: readonly ManagedQuotaProvider[] = ['agy', 'claude']; + +function isManagedQuotaProvider(provider: CLIProxyProvider): provider is ManagedQuotaProvider { + return MANAGED_QUOTA_PROVIDERS.includes(provider as ManagedQuotaProvider); +} + // ============================================================================ // QUOTA CACHE (30-second TTL) // ============================================================================ interface CacheEntry { - result: QuotaResult; + result: ManagedQuotaResult; timestamp: number; } @@ -38,7 +49,7 @@ const CACHE_TTL_MS = 30_000; // 30 seconds const quotaCache = new Map(); // Request deduplication: track in-flight fetch promises to avoid parallel duplicate requests -const pendingFetches = new Map>(); +const pendingFetches = new Map>(); function getCacheKey(provider: CLIProxyProvider, accountId: string): string { return `${provider}:${accountId}`; @@ -47,7 +58,10 @@ function getCacheKey(provider: CLIProxyProvider, accountId: string): string { /** * Get cached quota result if still valid */ -export function getCachedQuota(provider: CLIProxyProvider, accountId: string): QuotaResult | null { +export function getCachedQuota( + provider: CLIProxyProvider, + accountId: string +): ManagedQuotaResult | null { const key = getCacheKey(provider, accountId); const entry = quotaCache.get(key); @@ -67,7 +81,7 @@ export function getCachedQuota(provider: CLIProxyProvider, accountId: string): Q export function setCachedQuota( provider: CLIProxyProvider, accountId: string, - result: QuotaResult + result: ManagedQuotaResult ): void { const key = getCacheKey(provider, accountId); quotaCache.set(key, { result, timestamp: Date.now() }); @@ -85,10 +99,10 @@ export function clearQuotaCache(): void { * If a fetch for this account is already in progress, return the existing promise */ async function fetchQuotaWithDedup( - provider: CLIProxyProvider, + provider: ManagedQuotaProvider, accountId: string, verbose = false -): Promise { +): Promise { const key = getCacheKey(provider, accountId); // Check if fetch already in progress @@ -98,12 +112,22 @@ async function fetchQuotaWithDedup( } // Start new fetch and track it - const fetchPromise = fetchAccountQuota(provider, accountId, verbose) + const fetchPromise = fetchManagedQuota(provider, accountId, verbose) .then((result) => { setCachedQuota(provider, accountId, result); return result; }) - .catch((): QuotaResult => { + .catch((): ManagedQuotaResult => { + if (provider === 'claude') { + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: Date.now(), + error: 'Failed to fetch Claude quota', + accountId, + }; + } return { success: false, models: [], lastUpdated: Date.now() }; }) .finally(() => { @@ -114,6 +138,17 @@ async function fetchQuotaWithDedup( return fetchPromise; } +async function fetchManagedQuota( + provider: ManagedQuotaProvider, + accountId: string, + verbose: boolean +): Promise { + if (provider === 'claude') { + return fetchClaudeQuota(accountId, verbose); + } + return fetchAccountQuota(provider, accountId, verbose); +} + // ============================================================================ // COOLDOWN TRACKING // ============================================================================ @@ -201,17 +236,40 @@ export interface PreflightResult { quotaPercent?: number | null; } -/** - * Calculate average quota percentage from models - */ -function calculateAverageQuota(quota: QuotaResult): number | null { - if (!quota.success || quota.models.length === 0) { - return null; // No data available - } - const total = quota.models.reduce((sum, m) => sum + m.percentage, 0); +function calculateAgyQuotaPercent(quota: QuotaResult): number | null { + if (!quota.success || quota.models.length === 0) return null; + const total = quota.models.reduce((sum, model) => sum + model.percentage, 0); return total / quota.models.length; } +function calculateClaudeQuotaPercent(quota: ClaudeQuotaResult): number | null { + if (!quota.success) return null; + + const coreWindows = [quota.coreUsage?.fiveHour, quota.coreUsage?.weekly].filter( + (window): window is NonNullable => !!window + ); + if (coreWindows.length > 0) { + return Math.min(...coreWindows.map((window) => window.remainingPercent)); + } + + const usageWindows = quota.windows.filter((window) => window.rateLimitType !== 'overage'); + if (usageWindows.length > 0) { + return Math.min(...usageWindows.map((window) => window.remainingPercent)); + } + + return null; +} + +/** + * Calculate normalized quota percentage for managed providers. + */ +function calculateQuotaPercent(quota: ManagedQuotaResult): number | null { + if ('models' in quota) { + return calculateAgyQuotaPercent(quota); + } + return calculateClaudeQuotaPercent(quota); +} + /** * Find healthy account with remaining quota * Respects tier priority and skips paused/cooldown accounts @@ -220,6 +278,10 @@ export async function findHealthyAccount( provider: CLIProxyProvider, exclude: string[] ): Promise<{ id: string; tier: string; lastQuota: number } | null> { + if (!isManagedQuotaProvider(provider)) { + return null; + } + const config = loadOrCreateUnifiedConfig(); const tierPriority = config.quota_management?.auto?.tier_priority ?? ['ultra', 'pro', 'free']; const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5; @@ -243,7 +305,7 @@ export async function findHealthyAccount( quota = await fetchQuotaWithDedup(provider, account.id); } - const avgQuota = calculateAverageQuota(quota) ?? 0; + const avgQuota = calculateQuotaPercent(quota) ?? 0; return { id: account.id, @@ -276,7 +338,7 @@ export async function findHealthyAccount( * Find and switch to a healthy account */ async function findAndSwitch( - provider: CLIProxyProvider, + provider: ManagedQuotaProvider, excludeAccountId: string, reason: string ): Promise { @@ -310,12 +372,12 @@ async function findAndSwitch( * Checks if default account has sufficient quota, auto-switches if needed. * Respects paused accounts, tier priority, and cooldown settings. * - * @param provider - CLIProxy provider (only 'agy' supports quota) + * @param provider - CLIProxy provider * @returns PreflightResult with account to use and any switch info */ export async function preflightCheck(provider: CLIProxyProvider): Promise { - // Only Antigravity supports quota checking - if (provider !== 'agy') { + // Only providers with quota-based account rotation need preflight checks. + if (!isManagedQuotaProvider(provider)) { const defaultAccount = getDefaultAccount(provider); return { proceed: true, accountId: defaultAccount?.id || '' }; } @@ -359,8 +421,18 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise { let quota = getCachedQuota(provider, account.id); - if (!quota && provider === 'agy') { + if (!quota && isManagedQuotaProvider(provider)) { quota = await fetchQuotaWithDedup(provider, account.id); } - const avgQuota = quota ? calculateAverageQuota(quota) : null; + const avgQuota = quota ? calculateQuotaPercent(quota) : null; return { account, @@ -436,7 +508,7 @@ let monitorStopped = false; * Uses setTimeout chain (not setInterval) for dynamic interval switching. */ function scheduleNextPoll( - provider: CLIProxyProvider, + provider: ManagedQuotaProvider, accountId: string, monitorConfig: RuntimeMonitorConfig, intervalMs: number @@ -448,7 +520,7 @@ function scheduleNextPoll( try { const quota = await fetchQuotaWithDedup(provider, accountId); if (monitorStopped) return; // Re-check after async fetch - const avgQuota = calculateAverageQuota(quota) ?? 100; + const avgQuota = calculateQuotaPercent(quota) ?? 100; if (avgQuota <= monitorConfig.exhaustion_threshold) { // EXHAUSTED: cooldown + switch default + stop monitoring. @@ -502,12 +574,12 @@ function scheduleNextPoll( * critical_interval (60s) when quota hits warn_threshold (20%). * Auto-stops on exhaustion or when stopQuotaMonitor() is called. * - * Only monitors 'agy' provider (only one with quota API). + * Only monitors providers with quota-based account rotation. * No-op for other providers, manual mode, or if disabled in config. */ export function startQuotaMonitor(provider: CLIProxyProvider, accountId: string): void { - // Only Antigravity supports quota - if (provider !== 'agy') return; + // Only managed providers support runtime quota monitoring. + if (!isManagedQuotaProvider(provider)) return; // Prevent duplicate monitors if (monitorTimer) return; diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index 5966212b..51fba8f6 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -2,11 +2,11 @@ * Shared Quota Type Definitions * * Unified types for multi-provider quota system. - * Supports Antigravity, Codex, Gemini CLI, and GitHub Copilot OAuth providers. + * Supports Antigravity, Codex, Claude, Gemini CLI, and GitHub Copilot OAuth providers. */ /** Supported quota providers */ -export type QuotaProvider = 'agy' | 'codex' | 'gemini' | 'ghcp'; +export type QuotaProvider = 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp'; // Re-export Antigravity types for unified access export type { QuotaResult as AntigravityQuotaResult } from './quota-fetcher'; @@ -71,6 +71,82 @@ export interface CodexQuotaResult { isForbidden?: boolean; } +/** + * Claude policy limit window (5h/weekly/overage) + */ +export interface ClaudeQuotaWindow { + /** Source identifier: five_hour, seven_day, seven_day_opus, seven_day_sonnet, overage, ... */ + rateLimitType: string; + /** Human-friendly label for UI/CLI display */ + label: string; + /** Upstream status: allowed, allowed_warning, rejected */ + status: string; + /** Utilization ratio (0-1) reported by API; null when unavailable */ + utilization: number | null; + /** Utilization as percentage (0-100) */ + usedPercent: number; + /** Remaining percentage (100 - usedPercent) */ + remainingPercent: number; + /** ISO timestamp when this window resets, null if unknown */ + resetAt: string | null; + /** Whether usage surpassed threshold for this window (if provided by API) */ + surpassedThreshold?: boolean; + /** Optional severity hint (warning/error) */ + severity?: string; + /** Overage status when provided by API */ + overageStatus?: string; + /** ISO timestamp when overage resets, if provided */ + overageResetsAt?: string | null; + /** Why overage is disabled, if provided */ + overageDisabledReason?: string | null; + /** Whether account is currently using overage */ + isUsingOverage?: boolean; + /** Whether extra usage is enabled */ + hasExtraUsageEnabled?: boolean; +} + +/** Core Claude usage window (5h/weekly) extracted from policy limits */ +export interface ClaudeCoreUsageWindow { + /** Source rate limit type */ + rateLimitType: string; + /** Display label */ + label: string; + /** Percentage remaining (0-100) */ + remainingPercent: number; + /** ISO timestamp when quota resets, null if unknown */ + resetAt: string | null; + /** Raw status string */ + status: string; +} + +/** Core Claude usage summary with explicit 5h + weekly windows */ +export interface ClaudeCoreUsageSummary { + /** Short-cycle usage limit window (5h/session) */ + fiveHour: ClaudeCoreUsageWindow | null; + /** Long-cycle usage limit window (weekly) */ + weekly: ClaudeCoreUsageWindow | null; +} + +/** + * Claude quota fetch result + */ +export interface ClaudeQuotaResult { + /** Whether fetch succeeded */ + success: boolean; + /** Policy limit windows */ + windows: ClaudeQuotaWindow[]; + /** Explicit core usage windows (5h + weekly) */ + coreUsage?: ClaudeCoreUsageSummary; + /** Timestamp of fetch */ + lastUpdated: number; + /** Error message if fetch failed */ + error?: string; + /** Account ID (email) this quota belongs to */ + accountId?: string; + /** True if token is expired/invalid and re-auth is required */ + needsReauth?: boolean; +} + /** * Gemini CLI quota bucket (grouped by model series and token type) */ diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 7f3219d9..780fee69 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -54,8 +54,8 @@ export async function showHelp(): Promise { ['default ', 'Set default account for rotation'], ['pause ', 'Pause account (skip in rotation)'], ['resume ', 'Resume paused account'], - ['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'], - ['quota --provider ', 'Filter by provider (agy|codex|gemini|ghcp)'], + ['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'], + ['quota --provider ', 'Filter by provider (agy|codex|claude|gemini|ghcp)'], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 60a24aec..39327bcc 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -80,10 +80,10 @@ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend { /** * Parse --provider flag from args for quota command * Returns the provider filter value and remaining args - * Accepts: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all + * Accepts: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all */ function parseProviderArg(args: string[]): { - provider: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all'; + provider: 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp' | 'all'; remainingArgs: string[]; } { const providerIdx = args.indexOf('--provider'); @@ -97,27 +97,34 @@ function parseProviderArg(args: string[]): { // Handle empty value if (!value) { console.error( - 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all' + 'Warning: --provider requires a value. Valid options: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all' ); return { provider: 'all', remainingArgs }; } - // Normalize gemini-cli to gemini + // Normalize aliases const normalized = - value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value; + value === 'gemini-cli' + ? 'gemini' + : value === 'github-copilot' + ? 'ghcp' + : value === 'anthropic' + ? 'claude' + : value; if ( normalized !== 'agy' && normalized !== 'codex' && + normalized !== 'claude' && normalized !== 'gemini' && normalized !== 'ghcp' && normalized !== 'all' ) { console.error( - `Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all` + `Invalid provider '${value}'. Valid options: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all` ); return { provider: 'all', remainingArgs }; } return { - provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all', + provider: normalized as 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp' | 'all', remainingArgs, }; } @@ -127,29 +134,36 @@ function parseProviderArg(args: string[]): { // Warn if no value or value looks like another flag if (!rawValue || rawValue.startsWith('-')) { console.error( - 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all' + 'Warning: --provider requires a value. Valid options: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all' ); } const value = rawValue?.toLowerCase() || 'all'; const remainingArgs = [...args]; remainingArgs.splice(providerIdx, 2); - // Normalize gemini-cli to gemini + // Normalize aliases const normalized = - value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value; + value === 'gemini-cli' + ? 'gemini' + : value === 'github-copilot' + ? 'ghcp' + : value === 'anthropic' + ? 'claude' + : value; if ( normalized !== 'agy' && normalized !== 'codex' && + normalized !== 'claude' && normalized !== 'gemini' && normalized !== 'ghcp' && normalized !== 'all' ) { console.error( - `Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all` + `Invalid provider '${value}'. Valid options: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all` ); return { provider: 'all', remainingArgs }; } return { - provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all', + provider: normalized as 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp' | 'all', remainingArgs, }; } diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index d3445377..c53a9ab1 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -18,10 +18,12 @@ import { } from '../../cliproxy/account-manager'; import { fetchAllProviderQuotas } from '../../cliproxy/quota-fetcher'; import { fetchAllCodexQuotas } from '../../cliproxy/quota-fetcher-codex'; +import { fetchAllClaudeQuotas } from '../../cliproxy/quota-fetcher-claude'; import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota-fetcher-gemini-cli'; import { fetchAllGhcpQuotas } from '../../cliproxy/quota-fetcher-ghcp'; import type { CodexQuotaResult, + ClaudeQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult, } from '../../cliproxy/quota-types'; @@ -378,6 +380,181 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR } } +interface ClaudeDisplayWindow { + rateLimitType: string; + label: string; + remainingPercent: number; + resetAt: string | null; + status: string; +} + +function getClaudeWindowDisplayLabel( + window: Pick +): string { + switch (window.rateLimitType) { + case 'five_hour': + return '5h usage limit'; + case 'seven_day': + return 'Weekly usage limit'; + case 'seven_day_opus': + return 'Weekly usage (Opus)'; + case 'seven_day_sonnet': + return 'Weekly usage (Sonnet)'; + case 'seven_day_oauth_apps': + return 'Weekly usage (OAuth apps)'; + case 'seven_day_cowork': + return 'Weekly usage (Cowork)'; + case 'overage': + return 'Extra usage'; + default: + return window.label; + } +} + +function toClaudeDisplayWindow(window: ClaudeQuotaResult['windows'][number]): ClaudeDisplayWindow { + return { + rateLimitType: window.rateLimitType, + label: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + status: window.status, + }; +} + +function toClaudeCoreDisplayWindow( + window: NonNullable['fiveHour'] +): ClaudeDisplayWindow | null { + if (!window) return null; + return { + rateLimitType: window.rateLimitType, + label: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + status: window.status, + }; +} + +function pickClaudeWeeklyWindow( + windows: ClaudeQuotaResult['windows'] +): ClaudeQuotaResult['windows'][number] | null { + const weeklyCandidates = windows.filter((window) => + [ + 'seven_day', + 'seven_day_opus', + 'seven_day_sonnet', + 'seven_day_oauth_apps', + 'seven_day_cowork', + ].includes(window.rateLimitType) + ); + if (weeklyCandidates.length === 0) return null; + + return [...weeklyCandidates].sort((a, b) => { + if (a.remainingPercent !== b.remainingPercent) { + return a.remainingPercent - b.remainingPercent; + } + const aReset = a.resetAt ? new Date(a.resetAt).getTime() : Number.POSITIVE_INFINITY; + const bReset = b.resetAt ? new Date(b.resetAt).getTime() : Number.POSITIVE_INFINITY; + return aReset - bReset; + })[0]; +} + +function getClaudeCoreUsageWindows(quota: ClaudeQuotaResult): { + fiveHourWindow: ClaudeDisplayWindow | null; + weeklyWindow: ClaudeDisplayWindow | null; +} { + const coreUsage = quota.coreUsage; + const fiveHourFromCore = toClaudeCoreDisplayWindow(coreUsage?.fiveHour ?? null); + const weeklyFromCore = toClaudeCoreDisplayWindow(coreUsage?.weekly ?? null); + if (fiveHourFromCore || weeklyFromCore) { + return { + fiveHourWindow: fiveHourFromCore, + weeklyWindow: weeklyFromCore, + }; + } + + const fiveHourPolicy = + quota.windows.find((window) => window.rateLimitType === 'five_hour') ?? null; + const weeklyPolicy = pickClaudeWeeklyWindow(quota.windows); + + return { + fiveHourWindow: fiveHourPolicy ? toClaudeDisplayWindow(fiveHourPolicy) : null, + weeklyWindow: weeklyPolicy ? toClaudeDisplayWindow(weeklyPolicy) : null, + }; +} + +function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuotaResult }[]): void { + console.log(subheader(`Claude (${results.length} account${results.length !== 1 ? 's' : ''})`)); + console.log(''); + + for (const { account, quota } of results) { + const accountInfo = findAccountByQuery('claude', 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; + } + + const { fiveHourWindow, weeklyWindow } = getClaudeCoreUsageWindows(quota); + const coreWindows = [fiveHourWindow, weeklyWindow].filter( + (window, index, arr): window is ClaudeDisplayWindow => + !!window && arr.indexOf(window) === index + ); + const statusWindows = + coreWindows.length > 0 ? coreWindows : quota.windows.map(toClaudeDisplayWindow); + const minQuota = + statusWindows.length > 0 + ? Math.min(...statusWindows.map((window) => window.remainingPercent)) + : null; + const statusIcon = + minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail(''); + + console.log(` ${statusIcon}${account}${defaultMark}`); + + const resetParts: string[] = []; + if (fiveHourWindow?.resetAt) + resetParts.push(`5h ${formatResetTimeISO(fiveHourWindow.resetAt)}`); + if (weeklyWindow?.resetAt) + resetParts.push(`weekly ${formatResetTimeISO(weeklyWindow.resetAt)}`); + if (resetParts.length > 0) { + console.log(` ${dim(`Reset schedule: ${resetParts.join(' | ')}`)}`); + } + + const orderedWindows = [...coreWindows, ...quota.windows.map(toClaudeDisplayWindow)].filter( + (window, index, arr) => + arr.findIndex( + (candidate) => + candidate.rateLimitType === window.rateLimitType && + candidate.resetAt === window.resetAt && + candidate.status === window.status + ) === index + ); + + if (orderedWindows.length === 0) { + console.log(` ${dim('Policy limits unavailable for this account')}`); + console.log(''); + continue; + } + + for (const window of orderedWindows) { + const bar = formatQuotaBar(window.remainingPercent); + const resetLabel = window.resetAt ? dim(` Resets ${formatResetTimeISO(window.resetAt)}`) : ''; + const statusLabel = + window.status === 'rejected' + ? dim(' [blocked]') + : window.status === 'allowed_warning' + ? dim(' [warning]') + : ''; + console.log( + ` ${getClaudeWindowDisplayLabel(window).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${statusLabel}${resetLabel}` + ); + } + console.log(''); + } +} + function displayGeminiCliQuotaSection( results: { account: string; quota: GeminiCliQuotaResult }[] ): void { @@ -483,7 +660,7 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes export async function handleQuotaStatus( verbose = false, - providerFilter: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all' = 'all' + providerFilter: 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp' | 'all' = 'all' ): Promise { await initUI(); console.log(header('Quota Status')); @@ -492,15 +669,17 @@ export async function handleQuotaStatus( const shouldFetch = { agy: providerFilter === 'all' || providerFilter === 'agy', codex: providerFilter === 'all' || providerFilter === 'codex', + claude: providerFilter === 'all' || providerFilter === 'claude', gemini: providerFilter === 'all' || providerFilter === 'gemini', ghcp: providerFilter === 'all' || providerFilter === 'ghcp', }; console.log(dim('Fetching quotas...')); - const [agyResults, codexResults, geminiResults, ghcpResults] = await Promise.all([ + const [agyResults, codexResults, claudeResults, geminiResults, ghcpResults] = await Promise.all([ shouldFetch.agy ? fetchAllProviderQuotas('agy', verbose) : null, shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null, + shouldFetch.claude ? fetchAllClaudeQuotas(verbose) : null, shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null, shouldFetch.ghcp ? fetchAllGhcpQuotas(verbose) : null, ]); @@ -525,6 +704,15 @@ export async function handleQuotaStatus( console.log(''); } + if (claudeResults && claudeResults.length > 0) { + displayClaudeQuotaSection(claudeResults); + } else if (shouldFetch.claude) { + console.log(subheader('Claude (0 accounts)')); + console.log(info('No Claude accounts configured')); + console.log(` Run: ${color('ccs claude --auth', 'command')} to authenticate`); + console.log(''); + } + if (geminiResults && geminiResults.length > 0) { displayGeminiCliQuotaSection(geminiResults); } else if (shouldFetch.gemini) { diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 571fbc86..8fd2611e 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -14,11 +14,13 @@ import { } from '../../cliproxy/stats-fetcher'; import { fetchAccountQuota } from '../../cliproxy/quota-fetcher'; import { fetchCodexQuota } from '../../cliproxy/quota-fetcher-codex'; +import { fetchClaudeQuota } from '../../cliproxy/quota-fetcher-claude'; import { fetchGeminiCliQuota } from '../../cliproxy/quota-fetcher-gemini-cli'; import { fetchGhcpQuota } from '../../cliproxy/quota-fetcher-ghcp'; import { getCachedQuota, setCachedQuota } from '../../cliproxy/quota-response-cache'; import type { CodexQuotaResult, + ClaudeQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult, } from '../../cliproxy/quota-types'; @@ -83,6 +85,20 @@ function shouldCacheGeminiQuotaResult(result: GeminiCliQuotaResult): boolean { return false; } +function shouldCacheClaudeQuotaResult(result: ClaudeQuotaResult): boolean { + if (result.success) return true; + if (result.needsReauth) return true; + + const msg = (result.error || '').toLowerCase(); + if (!msg) return false; + if (msg.includes('timeout')) return false; + if (msg.includes('rate limited')) return false; + if (msg.includes('api error: 5')) return false; + if (msg.includes('fetch failed')) return false; + + return false; +} + function shouldCacheGhcpQuotaResult(result: GhcpQuotaResult): boolean { if (result.success) return true; if (result.needsReauth) return true; @@ -159,7 +175,7 @@ const handleStatsRequest = async (_req: Request, res: Response): Promise = if (!running) { res.status(503).json({ error: 'CLIProxy Plus not running', - message: 'Start a CLIProxy session (gemini, codex, agy, ghcp) to collect stats', + message: 'Start a CLIProxy session (gemini, codex, claude, agy, ghcp) to collect stats', }); return; } @@ -292,7 +308,7 @@ router.get('/models', async (_req: Request, res: Response): Promise => { if (!running) { res.status(503).json({ error: 'CLIProxy Plus not running', - message: 'Start a CLIProxy session (gemini, codex, agy) to fetch available models', + message: 'Start a CLIProxy session (gemini, codex, claude, agy) to fetch available models', }); return; } @@ -610,6 +626,47 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi } }); +/** + * GET /api/cliproxy/quota/claude/:accountId - Get Claude quota for a specific account + * Returns: ClaudeQuotaResult with policy windows (5h + weekly) + * Caching: 2 minute TTL to reduce Anthropic API calls + */ +router.get('/quota/claude/:accountId', async (req: Request, res: Response): Promise => { + const { accountId } = req.params; + + // Validate accountId - prevent path traversal + if ( + !accountId || + accountId.includes('..') || + accountId.includes('/') || + accountId.includes('\\') + ) { + res.status(400).json({ error: 'Invalid account ID' }); + return; + } + + try { + // Check cache first + const cached = getCachedQuota('claude', accountId); + if (cached) { + res.json({ ...cached, cached: true }); + return; + } + + // Fetch from external API + const result = await fetchClaudeQuota(accountId); + + // Cache successful and stable failure states; skip transient network failures. + if (shouldCacheClaudeQuotaResult(result)) { + setCachedQuota('claude', accountId, result); + } + + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * GET /api/cliproxy/quota/gemini/:accountId - Get Gemini quota for a specific account * Returns: GeminiCliQuotaResult with quota buckets @@ -695,7 +752,7 @@ router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promis /** * GET /api/cliproxy/quota/:provider/:accountId - Get quota for a specific account (generic) * Returns: QuotaResult with model quotas and reset times - * NOTE: This generic route MUST come after specific routes (codex, gemini, ghcp) + * NOTE: This generic route MUST come after specific routes (codex, claude, gemini, ghcp) * Caching: 2 minute TTL to reduce external API calls */ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise => { diff --git a/tests/unit/cliproxy/quota-fetcher-claude.test.ts b/tests/unit/cliproxy/quota-fetcher-claude.test.ts new file mode 100644 index 00000000..1e591067 --- /dev/null +++ b/tests/unit/cliproxy/quota-fetcher-claude.test.ts @@ -0,0 +1,257 @@ +/** + * Claude Quota Fetcher Unit Tests + * + * Covers policy limits parsing and auth/token edge cases. + */ + +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + buildClaudeQuotaWindows, + buildClaudeCoreUsageSummary, + fetchClaudeQuota, + fetchAllClaudeQuotas, +} from '../../../src/cliproxy/quota-fetcher-claude'; +import { sanitizeEmail } from '../../../src/cliproxy/auth-utils'; + +let tmpDir: string; +let originalCcsHome: string | undefined; +let originalFetch: typeof fetch; + +function createClaudeAccount( + accountId: string, + tokenPayload: Record, + tokenPrefix: 'claude' | 'anthropic' = 'claude' +): void { + const cliproxyDir = path.join(tmpDir, '.ccs', 'cliproxy'); + const authDir = path.join(cliproxyDir, 'auth'); + const sanitized = sanitizeEmail(accountId); + const tokenFile = `${tokenPrefix}-${sanitized}.json`; + + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload, null, 2)); + fs.writeFileSync( + path.join(cliproxyDir, 'accounts.json'), + JSON.stringify( + { + version: 1, + providers: { + claude: { + default: accountId, + accounts: { + [accountId]: { + email: accountId, + tokenFile, + createdAt: '2026-02-20T00:00:00.000Z', + lastUsedAt: '2026-02-20T00:00:00.000Z', + }, + }, + }, + }, + }, + null, + 2 + ) + ); +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-quota-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpDir; + originalFetch = global.fetch; +}); + +afterEach(() => { + global.fetch = originalFetch; + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('Claude Quota Fetcher', () => { + describe('buildClaudeQuotaWindows', () => { + it('parses restrictions array payload', () => { + const windows = buildClaudeQuotaWindows({ + restrictions: [ + { + rateLimitType: 'five_hour', + utilization: 0.4, + resetsAt: '2026-02-28T10:00:00Z', + status: 'allowed', + }, + { + rateLimitType: 'seven_day', + utilization: 0.8, + resetsAt: '2026-03-06T10:00:00Z', + status: 'allowed_warning', + }, + ], + }); + + expect(windows).toHaveLength(2); + expect(windows[0].rateLimitType).toBe('five_hour'); + expect(windows[0].remainingPercent).toBe(60); + expect(windows[1].rateLimitType).toBe('seven_day'); + expect(windows[1].remainingPercent).toBe(20); + }); + + it('parses object-map restrictions payload', () => { + const windows = buildClaudeQuotaWindows({ + restrictions: { + five_hour: { + utilization: 0.25, + resetsAt: '2026-02-28T10:00:00Z', + status: 'allowed', + }, + seven_day_opus: { + utilization: 0.9, + resetsAt: '2026-03-06T10:00:00Z', + status: 'allowed_warning', + }, + }, + }); + + expect(windows.map((window) => window.rateLimitType)).toEqual( + expect.arrayContaining(['five_hour', 'seven_day_opus']) + ); + }); + }); + + describe('buildClaudeCoreUsageSummary', () => { + it('selects most restrictive weekly window', () => { + const summary = buildClaudeCoreUsageSummary([ + { + rateLimitType: 'five_hour', + label: 'Session limit', + status: 'allowed', + utilization: 0.35, + usedPercent: 35, + remainingPercent: 65, + resetAt: '2026-02-28T10:00:00Z', + }, + { + rateLimitType: 'seven_day', + label: 'Weekly limit', + status: 'allowed', + utilization: 0.45, + usedPercent: 45, + remainingPercent: 55, + resetAt: '2026-03-06T10:00:00Z', + }, + { + rateLimitType: 'seven_day_opus', + label: 'Opus limit', + status: 'allowed_warning', + utilization: 0.85, + usedPercent: 85, + remainingPercent: 15, + resetAt: '2026-03-06T12:00:00Z', + }, + ]); + + expect(summary.fiveHour?.rateLimitType).toBe('five_hour'); + expect(summary.weekly?.rateLimitType).toBe('seven_day_opus'); + expect(summary.weekly?.remainingPercent).toBe(15); + }); + }); + + describe('fetchClaudeQuota', () => { + it('fetches and normalizes policy limits response', async () => { + createClaudeAccount('claude-main@example.com', { + access_token: 'claude-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + global.fetch = mock((url: string, options?: RequestInit) => { + expect(url).toBe('https://api.anthropic.com/api/claude_code/policy_limits'); + expect(options?.method).toBe('GET'); + expect(options?.headers).toMatchObject({ + Authorization: 'Bearer claude-token', + Accept: 'application/json', + }); + + return Promise.resolve( + new Response( + JSON.stringify({ + restrictions: [ + { + rateLimitType: 'five_hour', + utilization: 0.5, + resetsAt: '2026-03-01T01:00:00Z', + status: 'allowed', + }, + { + rateLimitType: 'seven_day', + utilization: 0.75, + resetsAt: '2026-03-07T01:00:00Z', + status: 'allowed_warning', + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + }) as typeof fetch; + + const result = await fetchClaudeQuota('claude-main@example.com'); + + expect(result.success).toBe(true); + expect(result.accountId).toBe('claude-main@example.com'); + expect(result.windows).toHaveLength(2); + expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(50); + expect(result.coreUsage?.weekly?.remainingPercent).toBe(25); + + const all = await fetchAllClaudeQuotas(); + expect(all).toHaveLength(1); + expect(all[0].account).toBe('claude-main@example.com'); + expect(all[0].quota.success).toBe(true); + }); + + it('returns needsReauth on 401 responses', async () => { + createClaudeAccount( + 'claude-auth@example.com', + { + access_token: 'expired-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'anthropic', + }, + 'anthropic' + ); + + global.fetch = mock(() => Promise.resolve(new Response('', { status: 401 }))) as typeof fetch; + + const result = await fetchClaudeQuota('claude-auth@example.com'); + + expect(result.success).toBe(false); + expect(result.needsReauth).toBe(true); + expect(result.error).toContain('Authentication'); + }); + + it('fails fast when auth file has no token', async () => { + createClaudeAccount('claude-missing@example.com', { + access_token: ' ', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + const fetchMock = mock(() => Promise.resolve(new Response('', { status: 200 }))); + global.fetch = fetchMock as typeof fetch; + + const result = await fetchClaudeQuota('claude-missing@example.com'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Auth file not found'); + expect(fetchMock).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 4fdf42a8..599c1c1a 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -8,6 +8,7 @@ import { getCodexQuotaBreakdown, getProviderMinQuota, getProviderResetTime, + isClaudeQuotaResult, isCodexQuotaResult, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; @@ -90,7 +91,7 @@ export function AccountCard({ const borderColor = getBorderColorStyle(zone, account.color); const connectorPosition = CONNECTOR_POSITION_MAP[zone]; - // Quota for CLIProxy accounts (agy, codex, gemini) + // Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp) const isCliproxyProvider = QUOTA_SUPPORTED_PROVIDERS.includes( account.provider as QuotaSupportedProvider ); @@ -111,6 +112,40 @@ export function AccountCard({ { label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null }, { label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null }, ].filter((row): row is { label: string; value: number } => row.value !== null); + const claudeQuotaRows = + account.provider === 'claude' && quota && isClaudeQuotaResult(quota) + ? [ + { + label: '5h', + value: + quota.coreUsage?.fiveHour?.remainingPercent ?? + quota.windows.find((window) => window.rateLimitType === 'five_hour') + ?.remainingPercent ?? + null, + }, + { + label: 'Wk', + value: + quota.coreUsage?.weekly?.remainingPercent ?? + quota.windows.find((window) => + [ + 'seven_day', + 'seven_day_opus', + 'seven_day_sonnet', + 'seven_day_oauth_apps', + 'seven_day_cowork', + ].includes(window.rateLimitType) + )?.remainingPercent ?? + null, + }, + ].filter((row): row is { label: string; value: number } => row.value !== null) + : []; + const compactQuotaRows = + account.provider === 'codex' + ? codexQuotaRows + : account.provider === 'claude' + ? claudeQuotaRows + : []; const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null; // Tier badge (AGY only) - show P for Pro, U for Ultra @@ -215,7 +250,7 @@ export function AccountCard({ failure={account.failureCount} showDetails={showDetails} /> - {/* Quota bar for CLIProxy accounts (agy, codex, gemini) */} + {/* Quota bar for CLIProxy accounts */} {isCliproxyProvider && (
{quotaLoading ? ( @@ -245,9 +280,9 @@ export function AccountCard({ {minQuotaLabel}%
- {account.provider === 'codex' && codexQuotaRows.length > 0 && ( + {compactQuotaRows.length > 0 && (
- {codexQuotaRows.map((row) => ( + {compactQuotaRows.map((row) => ( {row.label} {row.value}% diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 17dafc52..01aa19b3 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -36,6 +36,7 @@ import { getCodexQuotaBreakdown, getProviderMinQuota, getProviderResetTime, + isClaudeQuotaResult, isCodexQuotaResult, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; @@ -130,6 +131,40 @@ export function AccountItem({ { label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null }, { label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null }, ].filter((row): row is { label: string; value: number } => row.value !== null); + const claudeQuotaRows = + account.provider === 'claude' && quota && isClaudeQuotaResult(quota) + ? [ + { + label: '5h', + value: + quota.coreUsage?.fiveHour?.remainingPercent ?? + quota.windows.find((window) => window.rateLimitType === 'five_hour') + ?.remainingPercent ?? + null, + }, + { + label: 'Weekly', + value: + quota.coreUsage?.weekly?.remainingPercent ?? + quota.windows.find((window) => + [ + 'seven_day', + 'seven_day_opus', + 'seven_day_sonnet', + 'seven_day_oauth_apps', + 'seven_day_cowork', + ].includes(window.rateLimitType) + )?.remainingPercent ?? + null, + }, + ].filter((row): row is { label: string; value: number } => row.value !== null) + : []; + const dualWindowQuotaRows = + account.provider === 'codex' + ? codexQuotaRows + : account.provider === 'claude' + ? claudeQuotaRows + : []; const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null; return ( @@ -353,9 +388,9 @@ export function AccountItem({ - {account.provider === 'codex' && codexQuotaRows.length > 0 ? ( + {dualWindowQuotaRows.length > 0 ? (
- {codexQuotaRows.map((row) => ( + {dualWindowQuotaRows.map((row) => (
{row.label} diff --git a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx index 64ed3ef5..cef6301e 100644 --- a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx @@ -35,7 +35,7 @@ interface AccountsSectionProps { /** Bulk resume mutation in progress */ isBulkResuming?: boolean; privacyMode?: boolean; - /** Show quota bars for accounts (only applicable for 'agy' provider) */ + /** Show quota bars for accounts when provider supports quota API */ showQuota?: boolean; /** Kiro-specific: show "use normal browser" toggle */ isKiro?: boolean; diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index 51d980ec..0c154ca6 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -61,7 +61,7 @@ export interface AccountItemProps { /** Solo mode mutation in progress */ isSoloingAccount?: boolean; privacyMode?: boolean; - /** Show quota bar (only for 'agy' provider) */ + /** Show quota bar for providers with quota API support */ showQuota?: boolean; /** Enable checkbox for multi-select */ selectable?: boolean; diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 8030c16a..4dbac2d6 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -13,6 +13,7 @@ import { getModelsWithTiers, groupModelsByTier, isAgyQuotaResult, + isClaudeQuotaResult, isCodexQuotaResult, isGeminiQuotaResult, isGhcpQuotaResult, @@ -35,6 +36,27 @@ function formatPlanLabel(planType: string | null | undefined): string | null { return normalized.length > 0 ? normalized.join(' ') : planType; } +function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): string { + switch (rateLimitType) { + case 'five_hour': + return '5h usage limit'; + case 'seven_day': + return 'Weekly usage limit'; + case 'seven_day_opus': + return 'Weekly usage (Opus)'; + case 'seven_day_sonnet': + return 'Weekly usage (Sonnet)'; + case 'seven_day_oauth_apps': + return 'Weekly usage (OAuth apps)'; + case 'seven_day_cowork': + return 'Weekly usage (Cowork)'; + case 'overage': + return 'Extra usage'; + default: + return fallback; + } +} + /** * Renders provider-specific quota tooltip content * Uses type guards for proper TypeScript narrowing @@ -115,6 +137,74 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro ); } + // Claude provider tooltip + if (isClaudeQuotaResult(quota)) { + const coreWindows = [quota.coreUsage?.fiveHour, quota.coreUsage?.weekly] + .filter((window): window is NonNullable => !!window) + .map((window) => ({ + rateLimitType: window.rateLimitType, + label: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + status: window.status, + })); + const policyWindows = quota.windows.map((window) => ({ + rateLimitType: window.rateLimitType, + label: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + status: window.status, + })); + const orderedWindows = [...coreWindows, ...policyWindows].filter( + (window, index, arr) => + arr.findIndex( + (candidate) => + candidate.rateLimitType === window.rateLimitType && + candidate.resetAt === window.resetAt && + candidate.status === window.status + ) === index + ); + + const fiveHourResetAt = + quota.coreUsage?.fiveHour?.resetAt ?? + quota.windows.find((window) => window.rateLimitType === 'five_hour')?.resetAt ?? + null; + const weeklyResetAt = + quota.coreUsage?.weekly?.resetAt ?? + quota.windows.find((window) => + [ + 'seven_day', + 'seven_day_opus', + 'seven_day_sonnet', + 'seven_day_oauth_apps', + 'seven_day_cowork', + ].includes(window.rateLimitType) + )?.resetAt ?? + null; + + return ( +
+

Rate Limits:

+ {orderedWindows.map((window, index) => ( +
+ + {getClaudeWindowDisplayLabel(window.rateLimitType, window.label)} + + {window.remainingPercent}% +
+ ))} + +
+ ); + } + // Gemini provider tooltip if (isGeminiQuotaResult(quota)) { return ( diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 0120bddb..5cb09892 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -7,6 +7,7 @@ import type { ModelQuota, QuotaResult, CodexQuotaResult, + ClaudeQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult, } from '@/lib/api-client'; @@ -203,14 +204,21 @@ export function useCliproxyErrorLogContent(name: string | null) { } // Re-export for consumers -export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult }; +export type { + ModelQuota, + QuotaResult, + CodexQuotaResult, + ClaudeQuotaResult, + GeminiCliQuotaResult, + GhcpQuotaResult, +}; /** Providers with quota API support */ -export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini', 'ghcp'] as const; +export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'claude', 'gemini', 'ghcp'] as const; export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number]; /** - * Fetch account quota from API (Antigravity only) + * Fetch account quota from generic API route */ async function fetchAccountQuota(provider: string, accountId: string): Promise { const response = await fetch(`/api/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`); @@ -245,6 +253,24 @@ async function fetchCodexQuotaApi(accountId: string): Promise return response.json(); } +/** + * Fetch Claude quota from API + */ +async function fetchClaudeQuotaApi(accountId: string): Promise { + const response = await fetch(`/api/cliproxy/quota/claude/${encodeURIComponent(accountId)}`); + if (!response.ok) { + let message = 'Failed to fetch Claude quota'; + try { + const error = await response.json(); + message = error.message || message; + } catch { + // Use default message if response isn't JSON + } + throw new Error(message); + } + return response.json(); +} + /** * Fetch Gemini quota from API */ @@ -294,6 +320,8 @@ async function fetchQuotaByProvider( switch (provider) { case 'codex': return fetchCodexQuotaApi(accountId); + case 'claude': + return fetchClaudeQuotaApi(accountId); case 'gemini': return fetchGeminiQuotaApi(accountId); case 'ghcp': @@ -305,7 +333,7 @@ async function fetchQuotaByProvider( /** * Hook to get account quota - * Supports agy, codex, gemini, and ghcp providers + * Supports agy, codex, claude, gemini, and ghcp providers */ export function useAccountQuota(provider: string, accountId: string, enabled = true) { return useQuery({ diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index a38c2ccb..a450f44f 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -299,6 +299,59 @@ export interface CodexQuotaResult { isForbidden?: boolean; } +/** Claude policy limit window */ +export interface ClaudeQuotaWindow { + /** Source identifier: five_hour, seven_day, seven_day_opus, seven_day_sonnet, overage, ... */ + rateLimitType: string; + /** Human-friendly label for UI display */ + label: string; + /** Upstream status: allowed, allowed_warning, rejected */ + status: string; + /** Utilization ratio (0-1) when available */ + utilization: number | null; + /** Utilization as percentage (0-100) */ + usedPercent: number; + /** Remaining percentage (100 - usedPercent) */ + remainingPercent: number; + /** Reset timestamp for this window, null if unknown */ + resetAt: string | null; + surpassedThreshold?: boolean; + severity?: string; + overageStatus?: string; + overageResetsAt?: string | null; + overageDisabledReason?: string | null; + isUsingOverage?: boolean; + hasExtraUsageEnabled?: boolean; +} + +/** Core Claude usage window (5h/weekly) */ +export interface ClaudeCoreUsageWindow { + rateLimitType: string; + label: string; + remainingPercent: number; + resetAt: string | null; + status: string; +} + +/** Core Claude usage summary (5h + weekly) */ +export interface ClaudeCoreUsageSummary { + fiveHour: ClaudeCoreUsageWindow | null; + weekly: ClaudeCoreUsageWindow | null; +} + +/** Claude quota result */ +export interface ClaudeQuotaResult { + success: boolean; + windows: ClaudeQuotaWindow[]; + coreUsage?: ClaudeCoreUsageSummary; + lastUpdated: number; + error?: string; + accountId?: string; + needsReauth?: boolean; + /** True if result was served from cache */ + cached?: boolean; +} + /** Gemini CLI bucket (grouped by model series) */ export interface GeminiCliBucket { /** Unique bucket identifier (e.g., "gemini-flash-series::input") */ @@ -793,6 +846,9 @@ export const api = { /** Fetch Codex quota for a specific account */ getCodex: (accountId: string) => request(`/cliproxy/quota/codex/${encodeURIComponent(accountId)}`), + /** Fetch Claude quota for a specific account */ + getClaude: (accountId: string) => + request(`/cliproxy/quota/claude/${encodeURIComponent(accountId)}`), /** Fetch Gemini CLI quota for a specific account */ getGemini: (accountId: string) => request(`/cliproxy/quota/gemini/${encodeURIComponent(accountId)}`), diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 8f04bc45..7296690e 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -3,6 +3,7 @@ import { twMerge } from 'tailwind-merge'; import type { CodexQuotaWindow, CodexQuotaResult, + ClaudeQuotaResult, GeminiCliBucket, GeminiCliQuotaResult, GhcpQuotaResult, @@ -42,6 +43,7 @@ const PROVIDER_COLORS: Record = { agy: '#f3722c', // Pumpkin gemini: '#277da1', // Cerulean codex: '#f8961e', // Carrot + claude: '#4d908e', // Dark Cyan vertex: '#577590', // Blue Slate iflow: '#f94144', // Strawberry qwen: '#f9c74f', // Tuscan @@ -522,6 +524,48 @@ export function getCodexResetTime(windows: CodexQuotaWindow[]): string | null { return resets.sort()[0]; } +/** + * Get minimum remaining percentage across Claude policy windows. + */ +export function getMinClaudePolicyQuota(quota: ClaudeQuotaResult): number | null { + if (!quota.success) return null; + + const coreWindows = [quota.coreUsage?.fiveHour, quota.coreUsage?.weekly].filter( + (window): window is NonNullable => !!window + ); + if (coreWindows.length > 0) { + return Math.min(...coreWindows.map((window) => window.remainingPercent)); + } + + const usageWindows = quota.windows.filter((window) => window.rateLimitType !== 'overage'); + if (usageWindows.length > 0) { + return Math.min(...usageWindows.map((window) => window.remainingPercent)); + } + + return null; +} + +/** + * Get earliest reset time from Claude policy windows. + */ +export function getClaudePolicyResetTime(quota: ClaudeQuotaResult): string | null { + if (!quota.success) return null; + + const coreResets = [quota.coreUsage?.fiveHour?.resetAt, quota.coreUsage?.weekly?.resetAt].filter( + (value): value is string => !!value + ); + if (coreResets.length > 0) { + return coreResets.sort()[0]; + } + + const resets = quota.windows + .filter((window) => window.rateLimitType !== 'overage') + .map((window) => window.resetAt) + .filter((value): value is string => value !== null); + if (resets.length === 0) return null; + return resets.sort()[0]; +} + /** * Get minimum remaining percentage across Gemini CLI buckets */ @@ -570,6 +614,7 @@ export function getGhcpResetTime(quotaResetDate: string | null): string | null { export type UnifiedQuotaResult = | QuotaResult | CodexQuotaResult + | ClaudeQuotaResult | GeminiCliQuotaResult | GhcpQuotaResult; @@ -580,7 +625,18 @@ export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResul /** Type guard: Check if quota result is from Codex provider */ export function isCodexQuotaResult(quota: UnifiedQuotaResult): quota is CodexQuotaResult { - return 'windows' in quota && Array.isArray((quota as CodexQuotaResult).windows); + return ( + 'windows' in quota && 'planType' in quota && Array.isArray((quota as CodexQuotaResult).windows) + ); +} + +/** Type guard: Check if quota result is from Claude provider */ +export function isClaudeQuotaResult(quota: UnifiedQuotaResult): quota is ClaudeQuotaResult { + return ( + 'windows' in quota && + !('planType' in quota) && + Array.isArray((quota as ClaudeQuotaResult).windows) + ); } /** Type guard: Check if quota result is from Gemini CLI provider */ @@ -626,6 +682,12 @@ export function getProviderMinQuota( return getMinCodexQuota(quota.windows); } return null; + case 'claude': + case 'anthropic': + if (isClaudeQuotaResult(quota)) { + return getMinClaudePolicyQuota(quota); + } + return null; case 'gemini': if (isGeminiQuotaResult(quota)) { return getMinGeminiQuota(quota.buckets); @@ -663,6 +725,12 @@ export function getProviderResetTime( return getCodexResetTime(quota.windows); } return null; + case 'claude': + case 'anthropic': + if (isClaudeQuotaResult(quota)) { + return getClaudePolicyResetTime(quota); + } + return null; case 'gemini': if (isGeminiQuotaResult(quota)) { return getGeminiResetTime(quota.buckets);