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/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index 02c5f3ea..ffd8e921 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -144,6 +144,7 @@ export const CLIPROXY_PROVIDER_IDS = Object.freeze( export const QUOTA_SUPPORTED_PROVIDER_IDS = Object.freeze([ 'agy', 'codex', + 'claude', 'gemini', 'ghcp', ] as const); diff --git a/src/cliproxy/quota-fetcher-claude-normalizer.ts b/src/cliproxy/quota-fetcher-claude-normalizer.ts new file mode 100644 index 00000000..25104425 --- /dev/null +++ b/src/cliproxy/quota-fetcher-claude-normalizer.ts @@ -0,0 +1,320 @@ +/** + * Claude Quota Response Normalization Helpers + * + * Parses Anthropic policy limits payload into normalized windows and core usage summary. + */ + +import type { ClaudeCoreUsageSummary, ClaudeQuotaWindow } from './quota-types'; +import { clampPercent } from '../utils/percentage'; + +// Distinguishes epoch milliseconds from seconds (1e12 ~= 2001-09-09T01:46:40Z). +const EPOCH_MS_THRESHOLD = 1e12; + +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 > EPOCH_MS_THRESHOLD ? 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 > EPOCH_MS_THRESHOLD ? 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 'seven_day_oauth_apps': + return 'OAuth apps limit'; + case 'seven_day_cowork': + return 'Cowork limit'; + case 'overage': + return 'Extra usage'; + default: + return rateLimitType || 'Unknown limit'; + } +} + +function clampUnit(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +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 normalizedRatio = clampUnit(ratio); + const usedPercent = clampPercent(normalizedRatio * 100); + return { + utilization: normalizedRatio, + 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, + }; +} + +const WEEKLY_RATE_LIMIT_TYPES = new Set([ + 'seven_day', + 'seven_day_opus', + 'seven_day_sonnet', + 'seven_day_oauth_apps', + 'seven_day_cowork', +]); + +export function isClaudeWeeklyRateLimitType(rateLimitType: string): boolean { + return WEEKLY_RATE_LIMIT_TYPES.has(rateLimitType); +} + +export function pickMostRestrictiveClaudeWeeklyWindow( + windows: ClaudeQuotaWindow[] +): ClaudeQuotaWindow | null { + const weeklyCandidates = windows.filter((window) => + isClaudeWeeklyRateLimitType(window.rateLimitType) + ); + return pickMostRestrictiveWeekly(weeklyCandidates); +} + +/** + * 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 weeklyWindow = pickMostRestrictiveClaudeWeeklyWindow(windows); + + if (fiveHourWindow && weeklyWindow) { + return { + fiveHour: mapCoreWindow(fiveHourWindow), + weekly: mapCoreWindow(weeklyWindow), + }; + } + + // Fallback: infer shortest/longest reset windows from non-overage limits. + 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 inferredWeekly = + weeklyWindow || + [...withReset].reverse().find((entry) => entry.window !== fiveHourWindow)?.window || + pickMostRestrictiveWeekly(nonOverage.filter((window) => window !== fiveHourWindow)); + const inferredFiveHour = + fiveHourWindow || + withReset.find((entry) => entry.window !== inferredWeekly)?.window || + pickMostRestrictiveWeekly(nonOverage.filter((window) => window !== inferredWeekly)); + + return { + fiveHour: mapCoreWindow(inferredFiveHour), + weekly: mapCoreWindow(inferredWeekly), + }; +} diff --git a/src/cliproxy/quota-fetcher-claude.ts b/src/cliproxy/quota-fetcher-claude.ts new file mode 100644 index 00000000..3f0c0aaf --- /dev/null +++ b/src/cliproxy/quota-fetcher-claude.ts @@ -0,0 +1,297 @@ +/** + * Quota Fetcher for Claude (Anthropic) Accounts + * + * Fetches policy limits from Claude API and normalizes 5h + weekly windows. + */ + +import * as path from 'node:path'; +import * as fsp from 'node:fs/promises'; +import { getAuthDir } from './config-generator'; +import { getPausedDir, getProviderAccounts } from './account-manager'; +import { sanitizeEmail, isTokenExpired } from './auth-utils'; +import type { ClaudeQuotaResult } from './quota-types'; +import { + buildClaudeQuotaWindows, + buildClaudeCoreUsageSummary, +} from './quota-fetcher-claude-normalizer'; + +export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary }; + +export 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 toObject(value: unknown): Record | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + return value as Record; +} + +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 isAuthExpired(expiry: string | null): boolean { + return expiry ? isTokenExpired(expiry) : false; +} + +async function readJsonFile(filePath: string): Promise | null> { + try { + const raw = await fsp.readFile(filePath, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + return toObject(parsed); + } catch { + return null; + } +} + +async function readAuthCandidate(filePath: string): Promise { + const data = await readJsonFile(filePath); + if (!data) return null; + + const accessToken = extractAccessToken(data); + if (!accessToken) return null; + + const expiry = extractExpiry(data); + return { + accessToken, + isExpired: isAuthExpired(expiry), + }; +} + +async function readClaudeAuthData(accountId: string): Promise { + const authDirs = [getAuthDir(), getPausedDir()]; + const sanitizedId = sanitizeEmail(accountId); + const expectedFiles = [`claude-${sanitizedId}.json`, `anthropic-${sanitizedId}.json`]; + + for (const authDir of authDirs) { + for (const expectedFile of expectedFiles) { + const filePath = path.join(authDir, expectedFile); + const authData = await readAuthCandidate(filePath); + if (authData) { + return authData; + } + } + + let files: string[]; + try { + files = await fsp.readdir(authDir); + } catch { + continue; + } + + for (const file of files) { + if ( + !file.endsWith('.json') || + (!file.startsWith('claude-') && !file.startsWith('anthropic-')) + ) { + continue; + } + + const filePath = path.join(authDir, file); + const data = await readJsonFile(filePath); + if (!data) continue; + + 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: isAuthExpired(expiry), + }; + } + } + } + + 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 = await 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) { + const errorDetails = + error instanceof Error ? (error.stack ?? error.message) : JSON.stringify(error); + console.error( + `[!] Claude policy limits failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}` + ); + } + + 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..afb431e8 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 // ============================================================================ @@ -174,10 +209,14 @@ export function clearCooldown(provider: CLIProxyProvider, accountId: string): vo async function batchedMap( items: T[], fn: (item: T) => Promise, - concurrency = 10 + concurrency = 10, + delayMs = 100 ): Promise { const results: R[] = []; for (let i = 0; i < items.length; i += concurrency) { + if (i > 0 && delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } const batch = items.slice(i, i + concurrency); const batchResults = await Promise.all(batch.map(fn)); results.push(...batchResults); @@ -201,17 +240,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 +282,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 +309,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 +342,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 +376,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 +425,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 +512,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 +524,18 @@ 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); + + if (avgQuota === null) { + // Quota data unavailable: keep polling, but do not treat unknown as healthy/exhausted. + scheduleNextPoll( + provider, + accountId, + monitorConfig, + monitorConfig.normal_interval_seconds * 1000 + ); + return; + } if (avgQuota <= monitorConfig.exhaustion_threshold) { // EXHAUSTED: cooldown + switch default + stop monitoring. @@ -502,12 +589,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 9d4e41dd..01d5bd13 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -55,7 +55,7 @@ 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', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'], ['quota --provider ', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`], ], ], diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index b1735bc5..972c692e 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -97,31 +97,33 @@ function normalizeQuotaProvider(value: string): QuotaProviderFilter | null { return canonicalProvider; } -function parseProviderArg(args: string[]): { +export function parseProviderArg(args: string[]): { provider: QuotaProviderFilter; remainingArgs: string[]; + invalid: boolean; } { const extracted = extractOption(args, ['--provider']); if (!extracted.found) { - return { provider: 'all', remainingArgs: args }; + return { provider: 'all', remainingArgs: args, invalid: false }; } if (extracted.missingValue || !extracted.value) { console.error( - `Warning: --provider requires a value. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}` + `Invalid provider value. --provider requires a value. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}` ); - return { provider: 'all', remainingArgs: extracted.remainingArgs }; + return { provider: 'all', remainingArgs: extracted.remainingArgs, invalid: true }; } const value = extracted.value.toLowerCase(); const normalized = normalizeQuotaProvider(value); if (!normalized) { console.error(`Invalid provider '${value}'. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`); - return { provider: 'all', remainingArgs: extracted.remainingArgs }; + return { provider: 'all', remainingArgs: extracted.remainingArgs, invalid: true }; } return { provider: normalized, remainingArgs: extracted.remainingArgs, + invalid: false, }; } @@ -163,7 +165,11 @@ export async function handleCliproxyCommand(args: string[]): Promise { } if (command === 'quota') { - const { provider: providerFilter } = parseProviderArg(remainingArgs.slice(1)); + const { provider: providerFilter, invalid } = parseProviderArg(remainingArgs.slice(1)); + if (invalid) { + process.exitCode = 1; + return; + } await handleQuotaStatus(verbose, providerFilter); return; } diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index a6ef643a..c19dec31 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -18,10 +18,13 @@ 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 { pickMostRestrictiveClaudeWeeklyWindow } from '../../cliproxy/quota-fetcher-claude-normalizer'; 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'; @@ -382,6 +385,157 @@ 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 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 = pickMostRestrictiveClaudeWeeklyWindow(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 { @@ -514,6 +668,15 @@ const QUOTA_PROVIDER_RUNTIME: Record fetchAllClaudeQuotas(verbose), + hasData: (result) => (result as { account: string; quota: ClaudeQuotaResult }[]).length > 0, + render: (result) => + displayClaudeQuotaSection(result as { account: string; quota: ClaudeQuotaResult }[]), + emptyTitle: 'Claude (0 accounts)', + emptyMessage: 'No Claude accounts configured', + authCommand: 'ccs claude --auth', + }, gemini: { fetch: (verbose) => fetchAllGeminiCliQuotas(verbose), hasData: (result) => (result as { account: string; quota: GeminiCliQuotaResult }[]).length > 0, diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 571fbc86..4d446b90 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'; @@ -52,6 +54,36 @@ import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; const router = Router(); +const QUOTA_RATE_LIMIT_WINDOW_MS = 60_000; +const QUOTA_RATE_LIMIT_MAX_REQUESTS = 120; + +interface QuotaRateLimitEntry { + windowStart: number; + count: number; +} + +const quotaRateLimits = new Map(); + +function buildQuotaRateLimitKey(req: Request, provider: string): string { + const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; + return `${clientIp}:${provider}`; +} + +function isQuotaRouteRateLimited(req: Request, provider: string): boolean { + const key = buildQuotaRateLimitKey(req, provider); + const now = Date.now(); + const current = quotaRateLimits.get(key); + + if (!current || now - current.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS) { + quotaRateLimits.set(key, { windowStart: now, count: 1 }); + return false; + } + + current.count += 1; + quotaRateLimits.set(key, current); + return current.count > QUOTA_RATE_LIMIT_MAX_REQUESTS; +} + /** * Cache only stable failures; avoid pinning transient network failures (timeouts, 429s). */ @@ -83,6 +115,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 +205,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 +338,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; } @@ -576,6 +622,12 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise => { const { accountId } = req.params; + if (isQuotaRouteRateLimited(req, 'codex')) { + res + .status(429) + .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); + return; + } // Validate accountId - prevent path traversal if ( @@ -610,6 +662,53 @@ 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; + if (isQuotaRouteRateLimited(req, 'claude')) { + res + .status(429) + .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); + return; + } + + // 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 @@ -617,6 +716,12 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi */ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Promise => { const { accountId } = req.params; + if (isQuotaRouteRateLimited(req, 'gemini')) { + res + .status(429) + .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); + return; + } // Validate accountId - prevent path traversal if ( @@ -658,6 +763,12 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom */ router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promise => { const { accountId } = req.params; + if (isQuotaRouteRateLimited(req, 'ghcp')) { + res + .status(429) + .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); + return; + } // Validate accountId - prevent path traversal if ( @@ -695,11 +806,17 @@ 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 => { const { provider, accountId } = req.params; + if (isQuotaRouteRateLimited(req, provider)) { + res + .status(429) + .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); + return; + } // Validate provider - use canonical CLIPROXY_PROFILES const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts index 3e9a606f..e77cfc63 100644 --- a/tests/unit/cliproxy/provider-capabilities.test.ts +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -73,20 +73,22 @@ describe('provider-capabilities', () => { }); it('exposes quota-supported providers and guards correctly', () => { - expect(QUOTA_SUPPORTED_PROVIDER_IDS).toEqual(['agy', 'codex', 'gemini', 'ghcp']); + expect(QUOTA_SUPPORTED_PROVIDER_IDS).toEqual(['agy', 'codex', 'claude', 'gemini', 'ghcp']); expect(QUOTA_PROVIDER_OPTION_VALUES).toEqual([ 'agy', 'codex', + 'claude', 'gemini', 'ghcp', 'antigravity', + 'anthropic', 'gemini-cli', 'github-copilot', 'copilot', 'all', ]); expect(QUOTA_PROVIDER_HELP_TEXT).toBe( - 'agy, codex, gemini, ghcp, antigravity, gemini-cli, github-copilot, copilot, all' + 'agy, codex, claude, gemini, ghcp, antigravity, anthropic, gemini-cli, github-copilot, copilot, all' ); expect(isQuotaSupportedProvider('ghcp')).toBe(true); expect(isQuotaSupportedProvider('kiro')).toBe(false); 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..dd197288 --- /dev/null +++ b/tests/unit/cliproxy/quota-fetcher-claude.test.ts @@ -0,0 +1,538 @@ +/** + * 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']) + ); + }); + + it('clamps utilization ratio into 0..1', () => { + const windows = buildClaudeQuotaWindows({ + restrictions: [ + { + rateLimitType: 'five_hour', + utilization: 150, + status: 'allowed', + }, + { + rateLimitType: 'seven_day', + utilization: -25, + status: 'allowed', + }, + ], + }); + + expect(windows).toHaveLength(2); + expect(windows[0].utilization).toBe(1); + expect(windows[0].remainingPercent).toBe(0); + expect(windows[1].utilization).toBe(0); + expect(windows[1].remainingPercent).toBe(100); + }); + + it('parses direct single restriction payload shape', () => { + const windows = buildClaudeQuotaWindows({ + rateLimitType: 'five_hour', + utilization: 0.6, + status: 'allowed', + resetsAt: '2026-02-28T10:00:00Z', + }); + + expect(windows).toHaveLength(1); + expect(windows[0].rateLimitType).toBe('five_hour'); + expect(windows[0].remainingPercent).toBe(40); + }); + }); + + 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); + }); + + it('considers oauth/cowork weekly windows in core summary', () => { + 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_oauth_apps', + label: 'OAuth apps limit', + status: 'allowed_warning', + utilization: 0.92, + usedPercent: 92, + remainingPercent: 8, + resetAt: '2026-03-06T10:00:00Z', + }, + { + rateLimitType: 'seven_day_cowork', + label: 'Cowork limit', + status: 'allowed', + utilization: 0.4, + usedPercent: 40, + remainingPercent: 60, + resetAt: '2026-03-06T12:00:00Z', + }, + ]); + + expect(summary.fiveHour?.rateLimitType).toBe('five_hour'); + expect(summary.weekly?.rateLimitType).toBe('seven_day_oauth_apps'); + expect(summary.weekly?.remainingPercent).toBe(8); + }); + + it('does not duplicate weekly window into fiveHour when only weekly exists', () => { + const summary = buildClaudeCoreUsageSummary([ + { + rateLimitType: 'seven_day', + label: 'Weekly limit', + status: 'allowed', + utilization: 0.45, + usedPercent: 45, + remainingPercent: 55, + resetAt: '2026-03-06T10:00:00Z', + }, + ]); + + expect(summary.fiveHour).toBeNull(); + expect(summary.weekly?.rateLimitType).toBe('seven_day'); + }); + + it('uses earliest reset as tie-breaker for equal weekly remaining quota', () => { + const summary = buildClaudeCoreUsageSummary([ + { + rateLimitType: 'five_hour', + label: 'Session limit', + status: 'allowed', + utilization: 0.2, + usedPercent: 20, + remainingPercent: 80, + resetAt: '2026-02-28T10:00:00Z', + }, + { + rateLimitType: 'seven_day_opus', + label: 'Opus limit', + status: 'allowed', + utilization: 0.6, + usedPercent: 60, + remainingPercent: 40, + resetAt: '2026-03-06T12:00:00Z', + }, + { + rateLimitType: 'seven_day_sonnet', + label: 'Sonnet limit', + status: 'allowed', + utilization: 0.6, + usedPercent: 60, + remainingPercent: 40, + resetAt: '2026-03-06T10:00:00Z', + }, + ]); + + expect(summary.weekly?.rateLimitType).toBe('seven_day_sonnet'); + }); + }); + + 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); + }); + + it('treats missing expiry as not expired', async () => { + createClaudeAccount('claude-no-expiry@example.com', { + access_token: 'no-expiry-token', + type: 'claude', + }); + + global.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ restrictions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) as typeof fetch; + + const result = await fetchClaudeQuota('claude-no-expiry@example.com'); + + expect(result.success).toBe(true); + expect(result.windows).toHaveLength(0); + }); + + it('retries once on transient 500 then succeeds', async () => { + createClaudeAccount('claude-retry@example.com', { + access_token: 'retry-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + let attempt = 0; + global.fetch = mock(() => { + attempt += 1; + if (attempt === 1) { + return Promise.resolve(new Response('', { status: 500 })); + } + + return Promise.resolve( + new Response( + JSON.stringify({ + restrictions: [ + { + rateLimitType: 'five_hour', + utilization: 0.4, + resetsAt: '2026-03-01T01:00:00Z', + status: 'allowed', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + }) as typeof fetch; + + const result = await fetchClaudeQuota('claude-retry@example.com'); + + expect(result.success).toBe(true); + expect(attempt).toBe(2); + expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(60); + }); + + it('retries once after AbortError and succeeds', async () => { + createClaudeAccount('claude-timeout@example.com', { + access_token: 'timeout-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + let attempt = 0; + global.fetch = mock(() => { + attempt += 1; + if (attempt === 1) { + const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' }); + return Promise.reject(abortError); + } + + return Promise.resolve( + new Response( + JSON.stringify({ + restrictions: [ + { + rateLimitType: 'seven_day', + utilization: 0.3, + resetsAt: '2026-03-07T01:00:00Z', + status: 'allowed', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + }) as typeof fetch; + + const result = await fetchClaudeQuota('claude-timeout@example.com'); + + expect(result.success).toBe(true); + expect(attempt).toBe(2); + expect(result.coreUsage?.weekly?.remainingPercent).toBe(70); + }); + + it('falls back to alternate auth file when preferred file is invalid JSON', async () => { + const accountId = 'claude-fallback@example.com'; + const cliproxyDir = path.join(tmpDir, '.ccs', 'cliproxy'); + const authDir = path.join(cliproxyDir, 'auth'); + const sanitized = sanitizeEmail(accountId); + + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync(path.join(authDir, `claude-${sanitized}.json`), '{invalid'); + fs.writeFileSync( + path.join(authDir, `anthropic-${sanitized}.json`), + JSON.stringify( + { + access_token: 'valid-anthropic-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'anthropic', + }, + null, + 2 + ) + ); + fs.writeFileSync( + path.join(cliproxyDir, 'accounts.json'), + JSON.stringify( + { + version: 1, + providers: { + claude: { + default: accountId, + accounts: { + [accountId]: { + email: accountId, + tokenFile: `anthropic-${sanitized}.json`, + createdAt: '2026-02-20T00:00:00.000Z', + lastUsedAt: '2026-02-20T00:00:00.000Z', + }, + }, + }, + }, + }, + null, + 2 + ) + ); + + global.fetch = mock((_url: string, options?: RequestInit) => { + expect(options?.headers).toMatchObject({ + Authorization: 'Bearer valid-anthropic-token', + }); + return Promise.resolve( + new Response(JSON.stringify({ restrictions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + }) as typeof fetch; + + const result = await fetchClaudeQuota(accountId); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/tests/unit/commands/cliproxy-provider-arg.test.ts b/tests/unit/commands/cliproxy-provider-arg.test.ts new file mode 100644 index 00000000..2be22e06 --- /dev/null +++ b/tests/unit/commands/cliproxy-provider-arg.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test'; +import { parseProviderArg } from '../../../src/commands/cliproxy'; + +const originalConsoleError = console.error; + +afterEach(() => { + console.error = originalConsoleError; +}); + +describe('parseProviderArg', () => { + it('defaults to all when --provider is not specified', () => { + const result = parseProviderArg(['--verbose']); + + expect(result.provider).toBe('all'); + expect(result.invalid).toBe(false); + expect(result.remainingArgs).toEqual(['--verbose']); + }); + + it('accepts canonical providers', () => { + const result = parseProviderArg(['--provider', 'claude']); + + expect(result.provider).toBe('claude'); + expect(result.invalid).toBe(false); + }); + + it('accepts external aliases', () => { + const result = parseProviderArg(['--provider', 'anthropic']); + + expect(result.provider).toBe('claude'); + expect(result.invalid).toBe(false); + }); + + it('marks invalid when provider value is unsupported', () => { + const errorSpy = mock(() => {}); + console.error = errorSpy as typeof console.error; + + const result = parseProviderArg(['--provider', 'nope']); + + expect(result.provider).toBe('all'); + expect(result.invalid).toBe(true); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('marks invalid when --provider value is missing', () => { + const errorSpy = mock(() => {}); + console.error = errorSpy as typeof console.error; + + const result = parseProviderArg(['--provider']); + + expect(result.provider).toBe('all'); + expect(result.invalid).toBe(true); + expect(errorSpy).toHaveBeenCalled(); + }); +}); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 4fdf42a8..f3410a91 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'; @@ -23,6 +24,13 @@ import { cleanEmail } from './utils'; import { AccountCardStats } from './account-card-stats'; type Zone = 'left' | 'right' | 'top' | 'bottom'; +const QUOTA_PROVIDER_ALIASES = [ + 'antigravity', + 'anthropic', + 'gemini-cli', + 'copilot', + 'github-copilot', +]; interface AccountCardProps { account: AccountData; @@ -90,12 +98,15 @@ export function AccountCard({ const borderColor = getBorderColorStyle(zone, account.color); const connectorPosition = CONNECTOR_POSITION_MAP[zone]; - // Quota for CLIProxy accounts (agy, codex, gemini) - const isCliproxyProvider = QUOTA_SUPPORTED_PROVIDERS.includes( - account.provider as QuotaSupportedProvider - ); + // Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp) + const normalizedProvider = account.provider.toLowerCase(); + const isCliproxyProvider = + QUOTA_SUPPORTED_PROVIDERS.includes(normalizedProvider as QuotaSupportedProvider) || + QUOTA_PROVIDER_ALIASES.includes(normalizedProvider); + const isCodexProvider = normalizedProvider === 'codex'; + const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic'; const { data: quota, isLoading: quotaLoading } = useAccountQuota( - account.provider, + normalizedProvider, account.id, isCliproxyProvider ); @@ -104,14 +115,48 @@ export function AccountCard({ const minQuota = getProviderMinQuota(account.provider, quota); const resetTime = getProviderResetTime(account.provider, quota); const codexBreakdown = - account.provider === 'codex' && quota && isCodexQuotaResult(quota) + isCodexProvider && quota && isCodexQuotaResult(quota) ? getCodexQuotaBreakdown(quota.windows) : null; const codexQuotaRows = [ { 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 = + isClaudeProvider && 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 = isCodexProvider + ? codexQuotaRows + : isClaudeProvider + ? claudeQuotaRows + : []; const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null; + const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null; // Tier badge (AGY only) - show P for Pro, U for Ultra const showTierBadge = @@ -215,7 +260,7 @@ export function AccountCard({ failure={account.failureCount} showDetails={showDetails} /> - {/* Quota bar for CLIProxy accounts (agy, codex, gemini) */} + {/* Quota bar for CLIProxy accounts */} {isCliproxyProvider && (
{quotaLoading ? ( @@ -223,7 +268,7 @@ export function AccountCard({ Quota...
- ) : minQuota !== null ? ( + ) : minQuotaValue !== null ? ( @@ -235,9 +280,9 @@ export function AccountCard({ 50 + minQuotaValue > 50 ? 'text-emerald-600 dark:text-emerald-400' - : minQuota > 20 + : minQuotaValue > 20 ? 'text-amber-500' : 'text-red-500' )} @@ -245,9 +290,9 @@ export function AccountCard({ {minQuotaLabel}% - {account.provider === 'codex' && codexQuotaRows.length > 0 && ( + {compactQuotaRows.length > 0 && (
- {codexQuotaRows.map((row) => ( + {compactQuotaRows.map((row) => ( {row.label} {row.value}% @@ -258,22 +303,24 @@ export function AccountCard({
50 + minQuotaValue > 50 ? 'bg-emerald-500' - : minQuota > 20 + : minQuotaValue > 20 ? 'bg-amber-500' : 'bg-red-500' )} - style={{ width: `${minQuota}%` }} + style={{ width: `${minQuotaValue}%` }} />
- {quota && } +
+ ) : quota?.success ? ( +
Quota limits unavailable
) : quota?.needsReauth ? ( diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 17dafc52..501fc4c3 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'; @@ -105,12 +106,16 @@ export function AccountItem({ selected, onSelectChange, }: AccountItemProps) { + const normalizedProvider = account.provider.toLowerCase(); + const isCodexProvider = normalizedProvider === 'codex'; + const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic'; + // Fetch runtime stats to get actual lastUsedAt (more accurate than file state) const { data: stats } = useCliproxyStats(showQuota); // Fetch quota for all provider accounts const { data: quota, isLoading: quotaLoading } = useAccountQuota( - account.provider, + normalizedProvider, account.id, showQuota ); @@ -123,14 +128,48 @@ export function AccountItem({ const minQuota = getProviderMinQuota(account.provider, quota); const nextReset = getProviderResetTime(account.provider, quota); const codexBreakdown = - account.provider === 'codex' && quota && isCodexQuotaResult(quota) + isCodexProvider && quota && isCodexQuotaResult(quota) ? getCodexQuotaBreakdown(quota.windows) : null; const codexQuotaRows = [ { 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 = + isClaudeProvider && 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 = isCodexProvider + ? codexQuotaRows + : isClaudeProvider + ? claudeQuotaRows + : []; const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null; + const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null; return (
Loading quota...
- ) : minQuota !== null ? ( + ) : minQuotaValue !== null ? (
{/* Status indicator based on runtime usage, not file state */}
@@ -353,9 +392,9 @@ export function AccountItem({ - {account.provider === 'codex' && codexQuotaRows.length > 0 ? ( + {dualWindowQuotaRows.length > 0 ? (
- {codexQuotaRows.map((row) => ( + {dualWindowQuotaRows.map((row) => (
{row.label} @@ -374,9 +413,9 @@ export function AccountItem({ ) : (
{minQuotaLabel}% @@ -385,11 +424,21 @@ export function AccountItem({ )} - {quota && } +
+ ) : quota?.success ? ( +
+ + + No limits + +
) : quota?.needsReauth ? ( 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/model-config-tab.tsx b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx index a42ed816..320a0495 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -94,6 +94,14 @@ export function ModelConfigTab({ privacyMode, isRemoteMode, }: ModelConfigTabProps) { + const normalizedProvider = provider.toLowerCase(); + const showQuota = + (QUOTA_SUPPORTED_PROVIDERS.includes(normalizedProvider as QuotaSupportedProvider) || + ['anthropic', 'antigravity', 'gemini-cli', 'copilot', 'github-copilot'].includes( + normalizedProvider + )) && + !isRemoteMode; + // Kiro-specific: no-incognito setting (defaults to true = normal browser) const isKiro = provider === 'kiro'; const [kiroNoIncognito, setKiroNoIncognito] = useState(true); @@ -177,9 +185,7 @@ export function ModelConfigTab({ isBulkPausing={isBulkPausing} isBulkResuming={isBulkResuming} privacyMode={privacyMode} - showQuota={ - QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) && !isRemoteMode - } + showQuota={showQuota} isKiro={isKiro} kiroNoIncognito={kiroNoIncognito} onKiroNoIncognitoChange={saveKiroNoIncognito} 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..d95dab71 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, @@ -21,7 +22,7 @@ import { } from '@/lib/utils'; interface QuotaTooltipContentProps { - quota: UnifiedQuotaResult; + quota: UnifiedQuotaResult | null | undefined; resetTime: string | null; } @@ -35,12 +36,39 @@ 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 */ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentProps) { - if (!quota?.success) return null; + if (!quota) { + return

Loading quota...

; + } + + if (!quota.success) { + return

{quota.error || 'Failed to load quota'}

; + } // Antigravity (agy) provider tooltip if (isAgyQuotaResult(quota)) { @@ -115,6 +143,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..105049b6 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,41 @@ 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]; +const QUOTA_PROVIDER_ALIAS_MAP: Readonly> = { + antigravity: 'agy', + anthropic: 'claude', + 'gemini-cli': 'gemini', + copilot: 'ghcp', + 'github-copilot': 'ghcp', +}; + +function normalizeQuotaProvider(provider: string): QuotaSupportedProvider | null { + const normalized = provider.trim().toLowerCase(); + if (!normalized) { + return null; + } + + if ((QUOTA_SUPPORTED_PROVIDERS as readonly string[]).includes(normalized)) { + return normalized as QuotaSupportedProvider; + } + + return QUOTA_PROVIDER_ALIAS_MAP[normalized] ?? null; +} /** - * 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 +273,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 */ @@ -291,9 +337,16 @@ async function fetchQuotaByProvider( provider: string, accountId: string ): Promise { - switch (provider) { + const canonicalProvider = normalizeQuotaProvider(provider); + if (!canonicalProvider) { + return fetchAccountQuota(provider, accountId); + } + + switch (canonicalProvider) { case 'codex': return fetchCodexQuotaApi(accountId); + case 'claude': + return fetchClaudeQuotaApi(accountId); case 'gemini': return fetchGeminiQuotaApi(accountId); case 'ghcp': @@ -305,16 +358,15 @@ 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) { + const canonicalProvider = normalizeQuotaProvider(provider); + return useQuery({ - queryKey: ['account-quota', provider, accountId], - queryFn: () => fetchQuotaByProvider(provider, accountId), - enabled: - enabled && - QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) && - !!accountId, + queryKey: ['account-quota', canonicalProvider ?? provider, accountId], + queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId), + enabled: enabled && !!canonicalProvider && !!accountId, staleTime: 60000, // Match refetchInterval to prevent early refetching refetchInterval: 60000, // Refresh every 1 minute refetchOnWindowFocus: false, // Don't refetch on tab switch 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..1fbbe124 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,37 +614,101 @@ export function getGhcpResetTime(quotaResetDate: string | null): string | null { export type UnifiedQuotaResult = | QuotaResult | CodexQuotaResult + | ClaudeQuotaResult | GeminiCliQuotaResult | GhcpQuotaResult; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + /** Type guard: Check if quota result is from Antigravity (agy) provider */ export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResult { - return 'models' in quota && Array.isArray((quota as QuotaResult).models); + if (!isRecord(quota)) return false; + const models = (quota as Partial).models; + return typeof quota.success === 'boolean' && Array.isArray(models); } /** 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); + if (!isRecord(quota)) return false; + + const candidate = quota as Partial; + if (typeof candidate.success !== 'boolean') return false; + if (!Array.isArray(candidate.windows)) return false; + if (!('planType' in candidate)) return false; + + return candidate.windows.every( + (window) => + isRecord(window) && + typeof window.label === 'string' && + isFiniteNumber(window.usedPercent) && + isFiniteNumber(window.remainingPercent) + ); +} + +/** Type guard: Check if quota result is from Claude provider */ +export function isClaudeQuotaResult(quota: UnifiedQuotaResult): quota is ClaudeQuotaResult { + if (!isRecord(quota)) return false; + + const candidate = quota as Partial; + if (typeof candidate.success !== 'boolean') return false; + if (!Array.isArray(candidate.windows)) return false; + if ('planType' in candidate) return false; + + return candidate.windows.every( + (window) => + isRecord(window) && + typeof window.rateLimitType === 'string' && + isFiniteNumber(window.remainingPercent) && + typeof window.status === 'string' + ); } /** Type guard: Check if quota result is from Gemini CLI provider */ export function isGeminiQuotaResult(quota: UnifiedQuotaResult): quota is GeminiCliQuotaResult { - return 'buckets' in quota && Array.isArray((quota as GeminiCliQuotaResult).buckets); + if (!isRecord(quota)) return false; + + const candidate = quota as Partial; + if (typeof candidate.success !== 'boolean') return false; + if (!Array.isArray(candidate.buckets)) return false; + + return candidate.buckets.every( + (bucket) => + isRecord(bucket) && + typeof bucket.id === 'string' && + isFiniteNumber(bucket.remainingFraction) && + isFiniteNumber(bucket.remainingPercent) && + Array.isArray(bucket.modelIds) + ); } /** Type guard: Check if quota result is from GitHub Copilot (ghcp) provider */ export function isGhcpQuotaResult(quota: UnifiedQuotaResult): quota is GhcpQuotaResult { - const candidate = quota as GhcpQuotaResult; - const snapshots = candidate.snapshots as Record | null | undefined; + if (!isRecord(quota)) return false; - return ( - 'snapshots' in quota && - typeof snapshots === 'object' && - snapshots !== null && - 'premiumInteractions' in snapshots && - 'chat' in snapshots && - 'completions' in snapshots - ); + const candidate = quota as Partial; + const snapshots = candidate.snapshots as Record | null | undefined; + if (typeof candidate.success !== 'boolean') return false; + if (!isRecord(snapshots)) return false; + + const snapshotKeys: Array = [ + 'premiumInteractions', + 'chat', + 'completions', + ]; + return snapshotKeys.every((key) => { + const snapshot = snapshots[key] as Record | undefined; + return ( + isRecord(snapshot) && + isFiniteNumber(snapshot.percentRemaining) && + isFiniteNumber(snapshot.percentUsed) + ); + }); } // ==================== Unified Quota Helpers ==================== @@ -614,8 +722,9 @@ export function getProviderMinQuota( quota: UnifiedQuotaResult | null | undefined ): number | null { if (!quota?.success) return null; + const normalizedProvider = provider.trim().toLowerCase(); - switch (provider) { + switch (normalizedProvider) { case 'agy': if (isAgyQuotaResult(quota)) { return getMinClaudeQuota(quota.models); @@ -626,6 +735,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); @@ -651,8 +766,9 @@ export function getProviderResetTime( quota: UnifiedQuotaResult | null | undefined ): string | null { if (!quota?.success) return null; + const normalizedProvider = provider.trim().toLowerCase(); - switch (provider) { + switch (normalizedProvider) { case 'agy': if (isAgyQuotaResult(quota)) { return getClaudeResetTime(quota.models); @@ -663,6 +779,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); diff --git a/ui/tests/unit/ui/lib/quota-utils.test.ts b/ui/tests/unit/ui/lib/quota-utils.test.ts index d6c9dcd2..62865580 100644 --- a/ui/tests/unit/ui/lib/quota-utils.test.ts +++ b/ui/tests/unit/ui/lib/quota-utils.test.ts @@ -15,6 +15,7 @@ import { getProviderMinQuota, getProviderResetTime, isAgyQuotaResult, + isClaudeQuotaResult, isCodexQuotaResult, isGeminiQuotaResult, isGhcpQuotaResult, @@ -22,6 +23,7 @@ import { import type { CodexQuotaWindow, CodexQuotaResult, + ClaudeQuotaResult, GeminiCliBucket, GeminiCliQuotaResult, GhcpQuotaResult, @@ -1002,6 +1004,56 @@ describe('isCodexQuotaResult', () => { }); }); +describe('isClaudeQuotaResult', () => { + it('returns true for valid Claude quota result', () => { + const quota: ClaudeQuotaResult = { + success: true, + windows: [ + { + rateLimitType: 'five_hour', + label: 'Session limit', + status: 'allowed', + utilization: 0.5, + usedPercent: 50, + remainingPercent: 50, + resetAt: '2026-01-30T12:00:00Z', + }, + ], + coreUsage: { + fiveHour: { + rateLimitType: 'five_hour', + label: 'Session limit', + remainingPercent: 50, + resetAt: '2026-01-30T12:00:00Z', + status: 'allowed', + }, + weekly: null, + }, + lastUpdated: Date.now(), + }; + expect(isClaudeQuotaResult(quota)).toBe(true); + }); + + it('returns false for Codex quota result', () => { + const quota: CodexQuotaResult = { + success: true, + windows: [], + planType: 'free', + lastUpdated: Date.now(), + }; + expect(isClaudeQuotaResult(quota as unknown as ClaudeQuotaResult)).toBe(false); + }); + + it('returns false when Claude windows are malformed', () => { + const malformed = { + success: true, + windows: [{ rateLimitType: 'five_hour', label: 'Session limit' }], + lastUpdated: Date.now(), + }; + expect(isClaudeQuotaResult(malformed as unknown as ClaudeQuotaResult)).toBe(false); + }); +}); + describe('isGeminiQuotaResult', () => { it('returns true for valid Gemini quota result', () => { const quota: GeminiCliQuotaResult = {