feat(cliproxy): add Claude quota windows and account failover

This commit is contained in:
Tam Nhu Tran
2026-02-22 01:19:02 +07:00
parent 6a9abd2a48
commit 2385d9028a
19 changed files with 1599 additions and 74 deletions
+3 -3
View File
@@ -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
+7 -4
View File
@@ -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);
+2 -2
View File
@@ -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<void> {
if (provider !== 'agy') return;
if (provider !== 'agy' && provider !== 'claude') return;
const { preflightCheck } = await import('../quota-manager');
const preflight = await preflightCheck(provider);
+546
View File
@@ -0,0 +1,546 @@
/**
* Quota Fetcher for Claude (Anthropic) Accounts
*
* Fetches policy limits from Claude API and normalizes 5h + weekly windows.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getAuthDir } from './config-generator';
import { getPausedDir, getProviderAccounts } from './account-manager';
import { sanitizeEmail, isTokenExpired } from './auth-utils';
import type { ClaudeQuotaResult, ClaudeQuotaWindow, ClaudeCoreUsageSummary } from './quota-types';
import { clampPercent } from '../utils/percentage';
const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits';
const CLAUDE_QUOTA_TIMEOUT_MS = 10000;
const CLAUDE_QUOTA_MAX_ATTEMPTS = 2;
const CLAUDE_USER_AGENT = 'ccs-cli/claude-quota';
interface ClaudeAuthData {
accessToken: string;
isExpired: boolean;
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function asBoolean(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
if (value === 'true') return true;
if (value === 'false') return false;
}
return undefined;
}
function asNumber(value: unknown): number | null {
if (typeof value === 'number' && isFinite(value)) return value;
if (typeof value === 'string') {
const parsed = Number(value);
return isFinite(parsed) ? parsed : null;
}
return null;
}
function normalizeTimestamp(value: unknown): string | null {
const asNum = asNumber(value);
if (asNum !== null) {
const millis = asNum > 1e12 ? asNum : asNum * 1000;
const date = new Date(millis);
return isNaN(date.getTime()) ? null : date.toISOString();
}
const str = asString(value);
if (!str) return null;
// Numeric strings can be either epoch seconds or epoch milliseconds.
if (/^\d+$/.test(str)) {
const numeric = Number(str);
if (isFinite(numeric)) {
const millis = numeric > 1e12 ? numeric : numeric * 1000;
const date = new Date(millis);
return isNaN(date.getTime()) ? null : date.toISOString();
}
}
const date = new Date(str);
return isNaN(date.getTime()) ? null : date.toISOString();
}
function getClaudeWindowLabel(rateLimitType: string): string {
switch (rateLimitType) {
case 'five_hour':
return 'Session limit';
case 'seven_day':
return 'Weekly limit';
case 'seven_day_opus':
return 'Opus limit';
case 'seven_day_sonnet':
return 'Sonnet limit';
case 'overage':
return 'Extra usage';
default:
return rateLimitType || 'Unknown limit';
}
}
function normalizeUtilization(raw: Record<string, unknown>): {
utilization: number | null;
usedPercent: number;
remainingPercent: number;
} {
const utilizationRaw = asNumber(raw['utilization']);
const usedPercentRaw = asNumber(raw['usedPercent'] ?? raw['used_percent']);
const remainingPercentRaw = asNumber(raw['remainingPercent'] ?? raw['remaining_percent']);
if (utilizationRaw !== null) {
const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100;
const usedPercent = clampPercent(ratio * 100);
return {
utilization: ratio,
usedPercent,
remainingPercent: clampPercent(100 - usedPercent),
};
}
if (usedPercentRaw !== null) {
const usedPercent = clampPercent(usedPercentRaw);
return {
utilization: usedPercent / 100,
usedPercent,
remainingPercent: clampPercent(100 - usedPercent),
};
}
if (remainingPercentRaw !== null) {
const remainingPercent = clampPercent(remainingPercentRaw);
const usedPercent = clampPercent(100 - remainingPercent);
return {
utilization: usedPercent / 100,
usedPercent,
remainingPercent,
};
}
return {
utilization: null,
usedPercent: 0,
remainingPercent: 100,
};
}
function normalizeRateLimitType(value: unknown, fallbackKey?: string): string {
const direct = asString(value);
if (direct) return direct;
if (fallbackKey) return fallbackKey;
return 'unknown';
}
function toObject(value: unknown): Record<string, unknown> | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function normalizeRestriction(
raw: Record<string, unknown>,
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<string, unknown>): 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<string, unknown>)) {
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<string>();
const unique: ClaudeQuotaWindow[] = [];
for (const window of windows) {
const key = `${window.rateLimitType}:${window.resetAt ?? ''}:${window.status}`;
if (seen.has(key)) continue;
seen.add(key);
unique.push(window);
}
return unique.sort((a, b) => a.rateLimitType.localeCompare(b.rateLimitType));
}
function toEpochMs(iso: string | null): number | null {
if (!iso) return null;
const value = new Date(iso).getTime();
return isNaN(value) ? null : value;
}
function pickMostRestrictiveWeekly(windows: ClaudeQuotaWindow[]): ClaudeQuotaWindow | null {
if (windows.length === 0) return null;
return [...windows].sort((a, b) => {
if (a.remainingPercent !== b.remainingPercent) {
return a.remainingPercent - b.remainingPercent;
}
const aReset = toEpochMs(a.resetAt);
const bReset = toEpochMs(b.resetAt);
if (aReset === null && bReset === null) return 0;
if (aReset === null) return 1;
if (bReset === null) return -1;
return aReset - bReset;
})[0];
}
function mapCoreWindow(window: ClaudeQuotaWindow | null): ClaudeCoreUsageSummary['fiveHour'] {
if (!window) return null;
return {
rateLimitType: window.rateLimitType,
label: window.label,
remainingPercent: window.remainingPercent,
resetAt: window.resetAt,
status: window.status,
};
}
/**
* Build explicit 5h + weekly usage summary from Claude policy windows.
*/
export function buildClaudeCoreUsageSummary(windows: ClaudeQuotaWindow[]): ClaudeCoreUsageSummary {
if (!windows || windows.length === 0) {
return { fiveHour: null, weekly: null };
}
const fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null;
const weeklyCandidates = windows.filter((window) =>
['seven_day', 'seven_day_opus', 'seven_day_sonnet'].includes(window.rateLimitType)
);
const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates);
// Fallback: infer shortest/longest reset windows from non-overage limits.
if (!fiveHourWindow || !weeklyWindow) {
const nonOverage = windows.filter((window) => window.rateLimitType !== 'overage');
const withReset = nonOverage
.map((window) => ({
window,
resetMs: toEpochMs(window.resetAt),
}))
.filter((entry) => entry.resetMs !== null)
.sort((a, b) => (a.resetMs as number) - (b.resetMs as number));
const inferredFiveHour =
fiveHourWindow ||
(withReset.length > 0
? withReset[0].window
: nonOverage.length > 0
? pickMostRestrictiveWeekly(nonOverage)
: null);
const inferredWeekly =
weeklyWindow ||
(withReset.length > 1
? withReset[withReset.length - 1].window
: nonOverage.find((window) => window !== inferredFiveHour) || null);
return {
fiveHour: mapCoreWindow(inferredFiveHour),
weekly: mapCoreWindow(inferredWeekly),
};
}
return {
fiveHour: mapCoreWindow(fiveHourWindow),
weekly: mapCoreWindow(weeklyWindow),
};
}
function extractAccessToken(data: Record<string, unknown>): 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, unknown>): string | null {
const direct = asString(data['expired']);
if (direct) return direct;
const nested = toObject(data['token']);
if (nested) {
return asString(nested['expiry']);
}
return null;
}
function readClaudeAuthData(accountId: string): ClaudeAuthData | null {
const authDirs = [getAuthDir(), getPausedDir()];
const sanitizedId = sanitizeEmail(accountId);
const expectedFiles = [`claude-${sanitizedId}.json`, `anthropic-${sanitizedId}.json`];
for (const authDir of authDirs) {
if (!fs.existsSync(authDir)) continue;
for (const expectedFile of expectedFiles) {
const filePath = path.join(authDir, expectedFile);
if (!fs.existsSync(filePath)) continue;
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, unknown>;
const accessToken = extractAccessToken(data);
if (!accessToken) continue;
const expiry = extractExpiry(data);
return {
accessToken,
isExpired: isTokenExpired(expiry ?? undefined),
};
} catch {
continue;
}
}
const files = fs.readdirSync(authDir);
for (const file of files) {
if (
!file.endsWith('.json') ||
(!file.startsWith('claude-') && !file.startsWith('anthropic-'))
) {
continue;
}
const filePath = path.join(authDir, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, unknown>;
const accessToken = extractAccessToken(data);
if (!accessToken) continue;
const fileEmail = asString(data['email']);
const typeValue = asString(data['type']);
const isClaudeType =
typeValue === null || typeValue === 'claude' || typeValue === 'anthropic';
const matchesEmail = fileEmail === accountId;
const matchesFile = file.includes(sanitizedId);
if ((matchesEmail || matchesFile) && isClaudeType) {
const expiry = extractExpiry(data);
return {
accessToken,
isExpired: isTokenExpired(expiry ?? undefined),
};
}
} catch {
continue;
}
}
}
return null;
}
function buildEmptyResult(
error: string,
accountId: string,
needsReauth = false
): ClaudeQuotaResult {
return {
success: false,
windows: [],
coreUsage: { fiveHour: null, weekly: null },
lastUpdated: Date.now(),
error,
accountId,
needsReauth,
};
}
/**
* Fetch quota for a single Claude account.
*/
export async function fetchClaudeQuota(
accountId: string,
verbose = false
): Promise<ClaudeQuotaResult> {
const authData = readClaudeAuthData(accountId);
if (!authData) {
return buildEmptyResult('Auth file not found for Claude account', accountId);
}
if (authData.isExpired) {
return buildEmptyResult(
'Token expired - re-authenticate with ccs cliproxy auth claude',
accountId,
true
);
}
let lastError = 'Unknown error';
for (let attempt = 1; attempt <= CLAUDE_QUOTA_MAX_ATTEMPTS; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CLAUDE_QUOTA_TIMEOUT_MS);
try {
const response = await fetch(CLAUDE_POLICY_LIMITS_URL, {
method: 'GET',
signal: controller.signal,
headers: {
Authorization: `Bearer ${authData.accessToken}`,
Accept: 'application/json',
'User-Agent': CLAUDE_USER_AGENT,
},
});
clearTimeout(timeoutId);
if (verbose) {
console.error(`[i] Claude policy limits status: ${response.status} (attempt ${attempt})`);
}
if (response.status === 401) {
return buildEmptyResult('Authentication required for policy limits', accountId, true);
}
if (response.status === 404) {
// Some accounts may not expose policy limits; treat as empty but successful.
return {
success: true,
windows: [],
coreUsage: { fiveHour: null, weekly: null },
lastUpdated: Date.now(),
accountId,
};
}
if (response.status === 403) {
return buildEmptyResult('Not authorized for policy limits', accountId);
}
if (!response.ok) {
lastError = `Policy limits API error: ${response.status}`;
if (
attempt < CLAUDE_QUOTA_MAX_ATTEMPTS &&
(response.status === 429 || response.status >= 500)
) {
continue;
}
return buildEmptyResult(lastError, accountId);
}
let payload: unknown;
try {
payload = await response.json();
} catch {
return buildEmptyResult('Invalid policy limits format', accountId);
}
if (!toObject(payload)) {
return buildEmptyResult('Invalid policy limits format', accountId);
}
const windows = buildClaudeQuotaWindows(payload as Record<string, unknown>);
const coreUsage = buildClaudeCoreUsageSummary(windows);
return {
success: true,
windows,
coreUsage,
lastUpdated: Date.now(),
accountId,
};
} catch (error) {
clearTimeout(timeoutId);
lastError =
error instanceof Error && error.name === 'AbortError'
? 'Policy limits request timeout'
: error instanceof Error
? error.message
: 'Unknown error';
if (verbose) {
console.error(`[!] Claude policy limits failed (attempt ${attempt}): ${lastError}`);
}
if (attempt >= CLAUDE_QUOTA_MAX_ATTEMPTS) {
return buildEmptyResult(lastError, accountId);
}
}
}
return buildEmptyResult(lastError, accountId);
}
/**
* Fetch quota for all Claude accounts.
*/
export async function fetchAllClaudeQuotas(
verbose = false
): Promise<{ account: string; quota: ClaudeQuotaResult }[]> {
const accounts = getProviderAccounts('claude');
const results = await Promise.all(
accounts.map(async (account) => ({
account: account.id,
quota: await fetchClaudeQuota(account.id, verbose),
}))
);
return results;
}
+103 -31
View File
@@ -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<string, CacheEntry>();
// Request deduplication: track in-flight fetch promises to avoid parallel duplicate requests
const pendingFetches = new Map<string, Promise<QuotaResult>>();
const pendingFetches = new Map<string, Promise<ManagedQuotaResult>>();
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<QuotaResult> {
): Promise<ManagedQuotaResult> {
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<ManagedQuotaResult> {
if (provider === 'claude') {
return fetchClaudeQuota(accountId, verbose);
}
return fetchAccountQuota(provider, accountId, verbose);
}
// ============================================================================
// COOLDOWN TRACKING
// ============================================================================
@@ -201,17 +236,40 @@ export interface PreflightResult {
quotaPercent?: number | null;
}
/**
* Calculate average quota percentage from models
*/
function calculateAverageQuota(quota: QuotaResult): number | null {
if (!quota.success || quota.models.length === 0) {
return null; // No data available
}
const total = quota.models.reduce((sum, m) => sum + m.percentage, 0);
function calculateAgyQuotaPercent(quota: QuotaResult): number | null {
if (!quota.success || quota.models.length === 0) return null;
const total = quota.models.reduce((sum, model) => sum + model.percentage, 0);
return total / quota.models.length;
}
function calculateClaudeQuotaPercent(quota: ClaudeQuotaResult): number | null {
if (!quota.success) return null;
const coreWindows = [quota.coreUsage?.fiveHour, quota.coreUsage?.weekly].filter(
(window): window is NonNullable<typeof window> => !!window
);
if (coreWindows.length > 0) {
return Math.min(...coreWindows.map((window) => window.remainingPercent));
}
const usageWindows = quota.windows.filter((window) => window.rateLimitType !== 'overage');
if (usageWindows.length > 0) {
return Math.min(...usageWindows.map((window) => window.remainingPercent));
}
return null;
}
/**
* Calculate normalized quota percentage for managed providers.
*/
function calculateQuotaPercent(quota: ManagedQuotaResult): number | null {
if ('models' in quota) {
return calculateAgyQuotaPercent(quota);
}
return calculateClaudeQuotaPercent(quota);
}
/**
* Find healthy account with remaining quota
* Respects tier priority and skips paused/cooldown accounts
@@ -220,6 +278,10 @@ export async function findHealthyAccount(
provider: CLIProxyProvider,
exclude: string[]
): Promise<{ id: string; tier: string; lastQuota: number } | null> {
if (!isManagedQuotaProvider(provider)) {
return null;
}
const config = loadOrCreateUnifiedConfig();
const tierPriority = config.quota_management?.auto?.tier_priority ?? ['ultra', 'pro', 'free'];
const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5;
@@ -243,7 +305,7 @@ export async function findHealthyAccount(
quota = await fetchQuotaWithDedup(provider, account.id);
}
const avgQuota = calculateAverageQuota(quota) ?? 0;
const avgQuota = calculateQuotaPercent(quota) ?? 0;
return {
id: account.id,
@@ -276,7 +338,7 @@ export async function findHealthyAccount(
* Find and switch to a healthy account
*/
async function findAndSwitch(
provider: CLIProxyProvider,
provider: ManagedQuotaProvider,
excludeAccountId: string,
reason: string
): Promise<PreflightResult> {
@@ -310,12 +372,12 @@ async function findAndSwitch(
* Checks if default account has sufficient quota, auto-switches if needed.
* Respects paused accounts, tier priority, and cooldown settings.
*
* @param provider - CLIProxy provider (only 'agy' supports quota)
* @param provider - CLIProxy provider
* @returns PreflightResult with account to use and any switch info
*/
export async function preflightCheck(provider: CLIProxyProvider): Promise<PreflightResult> {
// Only Antigravity supports quota checking
if (provider !== 'agy') {
// Only providers with quota-based account rotation need preflight checks.
if (!isManagedQuotaProvider(provider)) {
const defaultAccount = getDefaultAccount(provider);
return { proceed: true, accountId: defaultAccount?.id || '' };
}
@@ -359,8 +421,18 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
quota = await fetchQuotaWithDedup(provider, defaultAccount.id);
}
// Calculate average quota
const avgQuota = calculateAverageQuota(quota) ?? 0;
// Calculate normalized quota percentage.
// Null means quota data is unavailable (e.g. provider does not expose limits for this account).
const quotaPercent = calculateQuotaPercent(quota);
if (quotaPercent === null) {
return {
proceed: true,
accountId: defaultAccount.id,
reason: 'Quota unavailable, using default account',
};
}
const avgQuota = quotaPercent;
const threshold = quotaConfig.auto?.exhaustion_threshold ?? 5;
if (avgQuota < threshold) {
@@ -376,7 +448,7 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
return {
proceed: true,
accountId: defaultAccount.id,
quotaPercent: calculateAverageQuota(quota),
quotaPercent,
};
}
@@ -399,11 +471,11 @@ export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
const results = await Promise.all(
accounts.map(async (account) => {
let quota = getCachedQuota(provider, account.id);
if (!quota && provider === 'agy') {
if (!quota && isManagedQuotaProvider(provider)) {
quota = await fetchQuotaWithDedup(provider, account.id);
}
const avgQuota = quota ? calculateAverageQuota(quota) : null;
const avgQuota = quota ? calculateQuotaPercent(quota) : null;
return {
account,
@@ -436,7 +508,7 @@ let monitorStopped = false;
* Uses setTimeout chain (not setInterval) for dynamic interval switching.
*/
function scheduleNextPoll(
provider: CLIProxyProvider,
provider: ManagedQuotaProvider,
accountId: string,
monitorConfig: RuntimeMonitorConfig,
intervalMs: number
@@ -448,7 +520,7 @@ function scheduleNextPoll(
try {
const quota = await fetchQuotaWithDedup(provider, accountId);
if (monitorStopped) return; // Re-check after async fetch
const avgQuota = calculateAverageQuota(quota) ?? 100;
const avgQuota = calculateQuotaPercent(quota) ?? 100;
if (avgQuota <= monitorConfig.exhaustion_threshold) {
// EXHAUSTED: cooldown + switch default + stop monitoring.
@@ -502,12 +574,12 @@ function scheduleNextPoll(
* critical_interval (60s) when quota hits warn_threshold (20%).
* Auto-stops on exhaustion or when stopQuotaMonitor() is called.
*
* Only monitors 'agy' provider (only one with quota API).
* Only monitors providers with quota-based account rotation.
* No-op for other providers, manual mode, or if disabled in config.
*/
export function startQuotaMonitor(provider: CLIProxyProvider, accountId: string): void {
// Only Antigravity supports quota
if (provider !== 'agy') return;
// Only managed providers support runtime quota monitoring.
if (!isManagedQuotaProvider(provider)) return;
// Prevent duplicate monitors
if (monitorTimer) return;
+78 -2
View File
@@ -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)
*/
+2 -2
View File
@@ -54,8 +54,8 @@ export async function showHelp(): Promise<void> {
['default <account>', 'Set default account for rotation'],
['pause <account>', 'Pause account (skip in rotation)'],
['resume <account>', 'Resume paused account'],
['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'],
['quota --provider <name>', 'Filter by provider (agy|codex|gemini|ghcp)'],
['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'],
['quota --provider <name>', 'Filter by provider (agy|codex|claude|gemini|ghcp)'],
],
],
[
+26 -12
View File
@@ -80,10 +80,10 @@ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend {
/**
* Parse --provider flag from args for quota command
* Returns the provider filter value and remaining args
* Accepts: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all
* Accepts: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all
*/
function parseProviderArg(args: string[]): {
provider: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all';
provider: 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp' | 'all';
remainingArgs: string[];
} {
const providerIdx = args.indexOf('--provider');
@@ -97,27 +97,34 @@ function parseProviderArg(args: string[]): {
// Handle empty value
if (!value) {
console.error(
'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all'
'Warning: --provider requires a value. Valid options: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all'
);
return { provider: 'all', remainingArgs };
}
// Normalize gemini-cli to gemini
// Normalize aliases
const normalized =
value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value;
value === 'gemini-cli'
? 'gemini'
: value === 'github-copilot'
? 'ghcp'
: value === 'anthropic'
? 'claude'
: value;
if (
normalized !== 'agy' &&
normalized !== 'codex' &&
normalized !== 'claude' &&
normalized !== 'gemini' &&
normalized !== 'ghcp' &&
normalized !== 'all'
) {
console.error(
`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all`
`Invalid provider '${value}'. Valid options: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all`
);
return { provider: 'all', remainingArgs };
}
return {
provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all',
provider: normalized as 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp' | 'all',
remainingArgs,
};
}
@@ -127,29 +134,36 @@ function parseProviderArg(args: string[]): {
// Warn if no value or value looks like another flag
if (!rawValue || rawValue.startsWith('-')) {
console.error(
'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all'
'Warning: --provider requires a value. Valid options: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all'
);
}
const value = rawValue?.toLowerCase() || 'all';
const remainingArgs = [...args];
remainingArgs.splice(providerIdx, 2);
// Normalize gemini-cli to gemini
// Normalize aliases
const normalized =
value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value;
value === 'gemini-cli'
? 'gemini'
: value === 'github-copilot'
? 'ghcp'
: value === 'anthropic'
? 'claude'
: value;
if (
normalized !== 'agy' &&
normalized !== 'codex' &&
normalized !== 'claude' &&
normalized !== 'gemini' &&
normalized !== 'ghcp' &&
normalized !== 'all'
) {
console.error(
`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all`
`Invalid provider '${value}'. Valid options: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all`
);
return { provider: 'all', remainingArgs };
}
return {
provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all',
provider: normalized as 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp' | 'all',
remainingArgs,
};
}
+190 -2
View File
@@ -18,10 +18,12 @@ import {
} from '../../cliproxy/account-manager';
import { fetchAllProviderQuotas } from '../../cliproxy/quota-fetcher';
import { fetchAllCodexQuotas } from '../../cliproxy/quota-fetcher-codex';
import { fetchAllClaudeQuotas } from '../../cliproxy/quota-fetcher-claude';
import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota-fetcher-gemini-cli';
import { fetchAllGhcpQuotas } from '../../cliproxy/quota-fetcher-ghcp';
import type {
CodexQuotaResult,
ClaudeQuotaResult,
GeminiCliQuotaResult,
GhcpQuotaResult,
} from '../../cliproxy/quota-types';
@@ -378,6 +380,181 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR
}
}
interface ClaudeDisplayWindow {
rateLimitType: string;
label: string;
remainingPercent: number;
resetAt: string | null;
status: string;
}
function getClaudeWindowDisplayLabel(
window: Pick<ClaudeDisplayWindow, 'rateLimitType' | 'label'>
): 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<ClaudeQuotaResult['coreUsage']>['fiveHour']
): ClaudeDisplayWindow | null {
if (!window) return null;
return {
rateLimitType: window.rateLimitType,
label: window.label,
remainingPercent: window.remainingPercent,
resetAt: window.resetAt,
status: window.status,
};
}
function pickClaudeWeeklyWindow(
windows: ClaudeQuotaResult['windows']
): ClaudeQuotaResult['windows'][number] | null {
const weeklyCandidates = windows.filter((window) =>
[
'seven_day',
'seven_day_opus',
'seven_day_sonnet',
'seven_day_oauth_apps',
'seven_day_cowork',
].includes(window.rateLimitType)
);
if (weeklyCandidates.length === 0) return null;
return [...weeklyCandidates].sort((a, b) => {
if (a.remainingPercent !== b.remainingPercent) {
return a.remainingPercent - b.remainingPercent;
}
const aReset = a.resetAt ? new Date(a.resetAt).getTime() : Number.POSITIVE_INFINITY;
const bReset = b.resetAt ? new Date(b.resetAt).getTime() : Number.POSITIVE_INFINITY;
return aReset - bReset;
})[0];
}
function getClaudeCoreUsageWindows(quota: ClaudeQuotaResult): {
fiveHourWindow: ClaudeDisplayWindow | null;
weeklyWindow: ClaudeDisplayWindow | null;
} {
const coreUsage = quota.coreUsage;
const fiveHourFromCore = toClaudeCoreDisplayWindow(coreUsage?.fiveHour ?? null);
const weeklyFromCore = toClaudeCoreDisplayWindow(coreUsage?.weekly ?? null);
if (fiveHourFromCore || weeklyFromCore) {
return {
fiveHourWindow: fiveHourFromCore,
weeklyWindow: weeklyFromCore,
};
}
const fiveHourPolicy =
quota.windows.find((window) => window.rateLimitType === 'five_hour') ?? null;
const weeklyPolicy = pickClaudeWeeklyWindow(quota.windows);
return {
fiveHourWindow: fiveHourPolicy ? toClaudeDisplayWindow(fiveHourPolicy) : null,
weeklyWindow: weeklyPolicy ? toClaudeDisplayWindow(weeklyPolicy) : null,
};
}
function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuotaResult }[]): void {
console.log(subheader(`Claude (${results.length} account${results.length !== 1 ? 's' : ''})`));
console.log('');
for (const { account, quota } of results) {
const accountInfo = findAccountByQuery('claude', account);
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
console.log(` ${fail(account)}${defaultMark}`);
console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`);
console.log('');
continue;
}
const { fiveHourWindow, weeklyWindow } = getClaudeCoreUsageWindows(quota);
const coreWindows = [fiveHourWindow, weeklyWindow].filter(
(window, index, arr): window is ClaudeDisplayWindow =>
!!window && arr.indexOf(window) === index
);
const statusWindows =
coreWindows.length > 0 ? coreWindows : quota.windows.map(toClaudeDisplayWindow);
const minQuota =
statusWindows.length > 0
? Math.min(...statusWindows.map((window) => window.remainingPercent))
: null;
const statusIcon =
minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
console.log(` ${statusIcon}${account}${defaultMark}`);
const resetParts: string[] = [];
if (fiveHourWindow?.resetAt)
resetParts.push(`5h ${formatResetTimeISO(fiveHourWindow.resetAt)}`);
if (weeklyWindow?.resetAt)
resetParts.push(`weekly ${formatResetTimeISO(weeklyWindow.resetAt)}`);
if (resetParts.length > 0) {
console.log(` ${dim(`Reset schedule: ${resetParts.join(' | ')}`)}`);
}
const orderedWindows = [...coreWindows, ...quota.windows.map(toClaudeDisplayWindow)].filter(
(window, index, arr) =>
arr.findIndex(
(candidate) =>
candidate.rateLimitType === window.rateLimitType &&
candidate.resetAt === window.resetAt &&
candidate.status === window.status
) === index
);
if (orderedWindows.length === 0) {
console.log(` ${dim('Policy limits unavailable for this account')}`);
console.log('');
continue;
}
for (const window of orderedWindows) {
const bar = formatQuotaBar(window.remainingPercent);
const resetLabel = window.resetAt ? dim(` Resets ${formatResetTimeISO(window.resetAt)}`) : '';
const statusLabel =
window.status === 'rejected'
? dim(' [blocked]')
: window.status === 'allowed_warning'
? dim(' [warning]')
: '';
console.log(
` ${getClaudeWindowDisplayLabel(window).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${statusLabel}${resetLabel}`
);
}
console.log('');
}
}
function displayGeminiCliQuotaSection(
results: { account: string; quota: GeminiCliQuotaResult }[]
): void {
@@ -483,7 +660,7 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes
export async function handleQuotaStatus(
verbose = false,
providerFilter: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all' = 'all'
providerFilter: 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp' | 'all' = 'all'
): Promise<void> {
await initUI();
console.log(header('Quota Status'));
@@ -492,15 +669,17 @@ export async function handleQuotaStatus(
const shouldFetch = {
agy: providerFilter === 'all' || providerFilter === 'agy',
codex: providerFilter === 'all' || providerFilter === 'codex',
claude: providerFilter === 'all' || providerFilter === 'claude',
gemini: providerFilter === 'all' || providerFilter === 'gemini',
ghcp: providerFilter === 'all' || providerFilter === 'ghcp',
};
console.log(dim('Fetching quotas...'));
const [agyResults, codexResults, geminiResults, ghcpResults] = await Promise.all([
const [agyResults, codexResults, claudeResults, geminiResults, ghcpResults] = await Promise.all([
shouldFetch.agy ? fetchAllProviderQuotas('agy', verbose) : null,
shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null,
shouldFetch.claude ? fetchAllClaudeQuotas(verbose) : null,
shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null,
shouldFetch.ghcp ? fetchAllGhcpQuotas(verbose) : null,
]);
@@ -525,6 +704,15 @@ export async function handleQuotaStatus(
console.log('');
}
if (claudeResults && claudeResults.length > 0) {
displayClaudeQuotaSection(claudeResults);
} else if (shouldFetch.claude) {
console.log(subheader('Claude (0 accounts)'));
console.log(info('No Claude accounts configured'));
console.log(` Run: ${color('ccs claude --auth', 'command')} to authenticate`);
console.log('');
}
if (geminiResults && geminiResults.length > 0) {
displayGeminiCliQuotaSection(geminiResults);
} else if (shouldFetch.gemini) {
+60 -3
View File
@@ -14,11 +14,13 @@ import {
} from '../../cliproxy/stats-fetcher';
import { fetchAccountQuota } from '../../cliproxy/quota-fetcher';
import { fetchCodexQuota } from '../../cliproxy/quota-fetcher-codex';
import { fetchClaudeQuota } from '../../cliproxy/quota-fetcher-claude';
import { fetchGeminiCliQuota } from '../../cliproxy/quota-fetcher-gemini-cli';
import { fetchGhcpQuota } from '../../cliproxy/quota-fetcher-ghcp';
import { getCachedQuota, setCachedQuota } from '../../cliproxy/quota-response-cache';
import type {
CodexQuotaResult,
ClaudeQuotaResult,
GeminiCliQuotaResult,
GhcpQuotaResult,
} from '../../cliproxy/quota-types';
@@ -83,6 +85,20 @@ function shouldCacheGeminiQuotaResult(result: GeminiCliQuotaResult): boolean {
return false;
}
function shouldCacheClaudeQuotaResult(result: ClaudeQuotaResult): boolean {
if (result.success) return true;
if (result.needsReauth) return true;
const msg = (result.error || '').toLowerCase();
if (!msg) return false;
if (msg.includes('timeout')) return false;
if (msg.includes('rate limited')) return false;
if (msg.includes('api error: 5')) return false;
if (msg.includes('fetch failed')) return false;
return false;
}
function shouldCacheGhcpQuotaResult(result: GhcpQuotaResult): boolean {
if (result.success) return true;
if (result.needsReauth) return true;
@@ -159,7 +175,7 @@ const handleStatsRequest = async (_req: Request, res: Response): Promise<void> =
if (!running) {
res.status(503).json({
error: 'CLIProxy Plus not running',
message: 'Start a CLIProxy session (gemini, codex, agy, ghcp) to collect stats',
message: 'Start a CLIProxy session (gemini, codex, claude, agy, ghcp) to collect stats',
});
return;
}
@@ -292,7 +308,7 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
if (!running) {
res.status(503).json({
error: 'CLIProxy Plus not running',
message: 'Start a CLIProxy session (gemini, codex, agy) to fetch available models',
message: 'Start a CLIProxy session (gemini, codex, claude, agy) to fetch available models',
});
return;
}
@@ -610,6 +626,47 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi
}
});
/**
* GET /api/cliproxy/quota/claude/:accountId - Get Claude quota for a specific account
* Returns: ClaudeQuotaResult with policy windows (5h + weekly)
* Caching: 2 minute TTL to reduce Anthropic API calls
*/
router.get('/quota/claude/:accountId', async (req: Request, res: Response): Promise<void> => {
const { accountId } = req.params;
// Validate accountId - prevent path traversal
if (
!accountId ||
accountId.includes('..') ||
accountId.includes('/') ||
accountId.includes('\\')
) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
try {
// Check cache first
const cached = getCachedQuota<ClaudeQuotaResult>('claude', accountId);
if (cached) {
res.json({ ...cached, cached: true });
return;
}
// Fetch from external API
const result = await fetchClaudeQuota(accountId);
// Cache successful and stable failure states; skip transient network failures.
if (shouldCacheClaudeQuotaResult(result)) {
setCachedQuota('claude', accountId, result);
}
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/cliproxy/quota/gemini/:accountId - Get Gemini quota for a specific account
* Returns: GeminiCliQuotaResult with quota buckets
@@ -695,7 +752,7 @@ router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promis
/**
* GET /api/cliproxy/quota/:provider/:accountId - Get quota for a specific account (generic)
* Returns: QuotaResult with model quotas and reset times
* NOTE: This generic route MUST come after specific routes (codex, gemini, ghcp)
* NOTE: This generic route MUST come after specific routes (codex, claude, gemini, ghcp)
* Caching: 2 minute TTL to reduce external API calls
*/
router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise<void> => {
@@ -0,0 +1,257 @@
/**
* Claude Quota Fetcher Unit Tests
*
* Covers policy limits parsing and auth/token edge cases.
*/
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
buildClaudeQuotaWindows,
buildClaudeCoreUsageSummary,
fetchClaudeQuota,
fetchAllClaudeQuotas,
} from '../../../src/cliproxy/quota-fetcher-claude';
import { sanitizeEmail } from '../../../src/cliproxy/auth-utils';
let tmpDir: string;
let originalCcsHome: string | undefined;
let originalFetch: typeof fetch;
function createClaudeAccount(
accountId: string,
tokenPayload: Record<string, unknown>,
tokenPrefix: 'claude' | 'anthropic' = 'claude'
): void {
const cliproxyDir = path.join(tmpDir, '.ccs', 'cliproxy');
const authDir = path.join(cliproxyDir, 'auth');
const sanitized = sanitizeEmail(accountId);
const tokenFile = `${tokenPrefix}-${sanitized}.json`;
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload, null, 2));
fs.writeFileSync(
path.join(cliproxyDir, 'accounts.json'),
JSON.stringify(
{
version: 1,
providers: {
claude: {
default: accountId,
accounts: {
[accountId]: {
email: accountId,
tokenFile,
createdAt: '2026-02-20T00:00:00.000Z',
lastUsedAt: '2026-02-20T00:00:00.000Z',
},
},
},
},
},
null,
2
)
);
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-quota-test-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpDir;
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('Claude Quota Fetcher', () => {
describe('buildClaudeQuotaWindows', () => {
it('parses restrictions array payload', () => {
const windows = buildClaudeQuotaWindows({
restrictions: [
{
rateLimitType: 'five_hour',
utilization: 0.4,
resetsAt: '2026-02-28T10:00:00Z',
status: 'allowed',
},
{
rateLimitType: 'seven_day',
utilization: 0.8,
resetsAt: '2026-03-06T10:00:00Z',
status: 'allowed_warning',
},
],
});
expect(windows).toHaveLength(2);
expect(windows[0].rateLimitType).toBe('five_hour');
expect(windows[0].remainingPercent).toBe(60);
expect(windows[1].rateLimitType).toBe('seven_day');
expect(windows[1].remainingPercent).toBe(20);
});
it('parses object-map restrictions payload', () => {
const windows = buildClaudeQuotaWindows({
restrictions: {
five_hour: {
utilization: 0.25,
resetsAt: '2026-02-28T10:00:00Z',
status: 'allowed',
},
seven_day_opus: {
utilization: 0.9,
resetsAt: '2026-03-06T10:00:00Z',
status: 'allowed_warning',
},
},
});
expect(windows.map((window) => window.rateLimitType)).toEqual(
expect.arrayContaining(['five_hour', 'seven_day_opus'])
);
});
});
describe('buildClaudeCoreUsageSummary', () => {
it('selects most restrictive weekly window', () => {
const summary = buildClaudeCoreUsageSummary([
{
rateLimitType: 'five_hour',
label: 'Session limit',
status: 'allowed',
utilization: 0.35,
usedPercent: 35,
remainingPercent: 65,
resetAt: '2026-02-28T10:00:00Z',
},
{
rateLimitType: 'seven_day',
label: 'Weekly limit',
status: 'allowed',
utilization: 0.45,
usedPercent: 45,
remainingPercent: 55,
resetAt: '2026-03-06T10:00:00Z',
},
{
rateLimitType: 'seven_day_opus',
label: 'Opus limit',
status: 'allowed_warning',
utilization: 0.85,
usedPercent: 85,
remainingPercent: 15,
resetAt: '2026-03-06T12:00:00Z',
},
]);
expect(summary.fiveHour?.rateLimitType).toBe('five_hour');
expect(summary.weekly?.rateLimitType).toBe('seven_day_opus');
expect(summary.weekly?.remainingPercent).toBe(15);
});
});
describe('fetchClaudeQuota', () => {
it('fetches and normalizes policy limits response', async () => {
createClaudeAccount('claude-main@example.com', {
access_token: 'claude-token',
expired: '2099-01-01T00:00:00.000Z',
type: 'claude',
});
global.fetch = mock((url: string, options?: RequestInit) => {
expect(url).toBe('https://api.anthropic.com/api/claude_code/policy_limits');
expect(options?.method).toBe('GET');
expect(options?.headers).toMatchObject({
Authorization: 'Bearer claude-token',
Accept: 'application/json',
});
return Promise.resolve(
new Response(
JSON.stringify({
restrictions: [
{
rateLimitType: 'five_hour',
utilization: 0.5,
resetsAt: '2026-03-01T01:00:00Z',
status: 'allowed',
},
{
rateLimitType: 'seven_day',
utilization: 0.75,
resetsAt: '2026-03-07T01:00:00Z',
status: 'allowed_warning',
},
],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
);
}) as typeof fetch;
const result = await fetchClaudeQuota('claude-main@example.com');
expect(result.success).toBe(true);
expect(result.accountId).toBe('claude-main@example.com');
expect(result.windows).toHaveLength(2);
expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(50);
expect(result.coreUsage?.weekly?.remainingPercent).toBe(25);
const all = await fetchAllClaudeQuotas();
expect(all).toHaveLength(1);
expect(all[0].account).toBe('claude-main@example.com');
expect(all[0].quota.success).toBe(true);
});
it('returns needsReauth on 401 responses', async () => {
createClaudeAccount(
'claude-auth@example.com',
{
access_token: 'expired-token',
expired: '2099-01-01T00:00:00.000Z',
type: 'anthropic',
},
'anthropic'
);
global.fetch = mock(() => Promise.resolve(new Response('', { status: 401 }))) as typeof fetch;
const result = await fetchClaudeQuota('claude-auth@example.com');
expect(result.success).toBe(false);
expect(result.needsReauth).toBe(true);
expect(result.error).toContain('Authentication');
});
it('fails fast when auth file has no token', async () => {
createClaudeAccount('claude-missing@example.com', {
access_token: ' ',
expired: '2099-01-01T00:00:00.000Z',
type: 'claude',
});
const fetchMock = mock(() => Promise.resolve(new Response('', { status: 200 })));
global.fetch = fetchMock as typeof fetch;
const result = await fetchClaudeQuota('claude-missing@example.com');
expect(result.success).toBe(false);
expect(result.error).toContain('Auth file not found');
expect(fetchMock).toHaveBeenCalledTimes(0);
});
});
});
@@ -8,6 +8,7 @@ import {
getCodexQuotaBreakdown,
getProviderMinQuota,
getProviderResetTime,
isClaudeQuotaResult,
isCodexQuotaResult,
} from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
@@ -90,7 +91,7 @@ export function AccountCard({
const borderColor = getBorderColorStyle(zone, account.color);
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
// Quota for CLIProxy accounts (agy, codex, gemini)
// Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp)
const isCliproxyProvider = QUOTA_SUPPORTED_PROVIDERS.includes(
account.provider as QuotaSupportedProvider
);
@@ -111,6 +112,40 @@ export function AccountCard({
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
{ label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
].filter((row): row is { label: string; value: number } => row.value !== null);
const claudeQuotaRows =
account.provider === 'claude' && quota && isClaudeQuotaResult(quota)
? [
{
label: '5h',
value:
quota.coreUsage?.fiveHour?.remainingPercent ??
quota.windows.find((window) => window.rateLimitType === 'five_hour')
?.remainingPercent ??
null,
},
{
label: 'Wk',
value:
quota.coreUsage?.weekly?.remainingPercent ??
quota.windows.find((window) =>
[
'seven_day',
'seven_day_opus',
'seven_day_sonnet',
'seven_day_oauth_apps',
'seven_day_cowork',
].includes(window.rateLimitType)
)?.remainingPercent ??
null,
},
].filter((row): row is { label: string; value: number } => row.value !== null)
: [];
const compactQuotaRows =
account.provider === 'codex'
? codexQuotaRows
: account.provider === 'claude'
? claudeQuotaRows
: [];
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
// Tier badge (AGY only) - show P for Pro, U for Ultra
@@ -215,7 +250,7 @@ export function AccountCard({
failure={account.failureCount}
showDetails={showDetails}
/>
{/* Quota bar for CLIProxy accounts (agy, codex, gemini) */}
{/* Quota bar for CLIProxy accounts */}
{isCliproxyProvider && (
<div className="mt-2 px-0.5">
{quotaLoading ? (
@@ -245,9 +280,9 @@ export function AccountCard({
{minQuotaLabel}%
</span>
</div>
{account.provider === 'codex' && codexQuotaRows.length > 0 && (
{compactQuotaRows.length > 0 && (
<div className="flex items-center justify-between text-[7px] text-muted-foreground/70">
{codexQuotaRows.map((row) => (
{compactQuotaRows.map((row) => (
<span key={row.label}>
{row.label} {row.value}%
</span>
@@ -36,6 +36,7 @@ import {
getCodexQuotaBreakdown,
getProviderMinQuota,
getProviderResetTime,
isClaudeQuotaResult,
isCodexQuotaResult,
} from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
@@ -130,6 +131,40 @@ export function AccountItem({
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
{ label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
].filter((row): row is { label: string; value: number } => row.value !== null);
const claudeQuotaRows =
account.provider === 'claude' && quota && isClaudeQuotaResult(quota)
? [
{
label: '5h',
value:
quota.coreUsage?.fiveHour?.remainingPercent ??
quota.windows.find((window) => window.rateLimitType === 'five_hour')
?.remainingPercent ??
null,
},
{
label: 'Weekly',
value:
quota.coreUsage?.weekly?.remainingPercent ??
quota.windows.find((window) =>
[
'seven_day',
'seven_day_opus',
'seven_day_sonnet',
'seven_day_oauth_apps',
'seven_day_cowork',
].includes(window.rateLimitType)
)?.remainingPercent ??
null,
},
].filter((row): row is { label: string; value: number } => row.value !== null)
: [];
const dualWindowQuotaRows =
account.provider === 'codex'
? codexQuotaRows
: account.provider === 'claude'
? claudeQuotaRows
: [];
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
return (
@@ -353,9 +388,9 @@ export function AccountItem({
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{account.provider === 'codex' && codexQuotaRows.length > 0 ? (
{dualWindowQuotaRows.length > 0 ? (
<div className="space-y-1.5">
{codexQuotaRows.map((row) => (
{dualWindowQuotaRows.map((row) => (
<div key={row.label} className="flex items-center gap-2">
<span className="w-10 text-[10px] text-muted-foreground">
{row.label}
@@ -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;
@@ -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;
@@ -13,6 +13,7 @@ import {
getModelsWithTiers,
groupModelsByTier,
isAgyQuotaResult,
isClaudeQuotaResult,
isCodexQuotaResult,
isGeminiQuotaResult,
isGhcpQuotaResult,
@@ -35,6 +36,27 @@ function formatPlanLabel(planType: string | null | undefined): string | null {
return normalized.length > 0 ? normalized.join(' ') : planType;
}
function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): string {
switch (rateLimitType) {
case 'five_hour':
return '5h usage limit';
case 'seven_day':
return 'Weekly usage limit';
case 'seven_day_opus':
return 'Weekly usage (Opus)';
case 'seven_day_sonnet':
return 'Weekly usage (Sonnet)';
case 'seven_day_oauth_apps':
return 'Weekly usage (OAuth apps)';
case 'seven_day_cowork':
return 'Weekly usage (Cowork)';
case 'overage':
return 'Extra usage';
default:
return fallback;
}
}
/**
* Renders provider-specific quota tooltip content
* Uses type guards for proper TypeScript narrowing
@@ -115,6 +137,74 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
);
}
// Claude provider tooltip
if (isClaudeQuotaResult(quota)) {
const coreWindows = [quota.coreUsage?.fiveHour, quota.coreUsage?.weekly]
.filter((window): window is NonNullable<typeof window> => !!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 (
<div className="text-xs space-y-1">
<p className="font-medium">Rate Limits:</p>
{orderedWindows.map((window, index) => (
<div
key={`${window.rateLimitType}-${window.resetAt ?? 'no-reset'}-${window.status}-${index}`}
className="flex justify-between gap-4"
>
<span className={cn(window.remainingPercent < 20 && 'text-red-500')}>
{getClaudeWindowDisplayLabel(window.rateLimitType, window.label)}
</span>
<span className="font-mono">{window.remainingPercent}%</span>
</div>
))}
<CodexResetIndicators
fiveHourResetTime={fiveHourResetAt}
weeklyResetTime={weeklyResetAt}
fallbackResetTime={resetTime}
/>
</div>
);
}
// Gemini provider tooltip
if (isGeminiQuotaResult(quota)) {
return (
+32 -4
View File
@@ -7,6 +7,7 @@ import type {
ModelQuota,
QuotaResult,
CodexQuotaResult,
ClaudeQuotaResult,
GeminiCliQuotaResult,
GhcpQuotaResult,
} from '@/lib/api-client';
@@ -203,14 +204,21 @@ export function useCliproxyErrorLogContent(name: string | null) {
}
// Re-export for consumers
export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult };
export type {
ModelQuota,
QuotaResult,
CodexQuotaResult,
ClaudeQuotaResult,
GeminiCliQuotaResult,
GhcpQuotaResult,
};
/** Providers with quota API support */
export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini', 'ghcp'] as const;
export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'claude', 'gemini', 'ghcp'] as const;
export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number];
/**
* Fetch account quota from API (Antigravity only)
* Fetch account quota from generic API route
*/
async function fetchAccountQuota(provider: string, accountId: string): Promise<QuotaResult> {
const response = await fetch(`/api/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`);
@@ -245,6 +253,24 @@ async function fetchCodexQuotaApi(accountId: string): Promise<CodexQuotaResult>
return response.json();
}
/**
* Fetch Claude quota from API
*/
async function fetchClaudeQuotaApi(accountId: string): Promise<ClaudeQuotaResult> {
const response = await fetch(`/api/cliproxy/quota/claude/${encodeURIComponent(accountId)}`);
if (!response.ok) {
let message = 'Failed to fetch Claude quota';
try {
const error = await response.json();
message = error.message || message;
} catch {
// Use default message if response isn't JSON
}
throw new Error(message);
}
return response.json();
}
/**
* Fetch Gemini quota from API
*/
@@ -294,6 +320,8 @@ async function fetchQuotaByProvider(
switch (provider) {
case 'codex':
return fetchCodexQuotaApi(accountId);
case 'claude':
return fetchClaudeQuotaApi(accountId);
case 'gemini':
return fetchGeminiQuotaApi(accountId);
case 'ghcp':
@@ -305,7 +333,7 @@ async function fetchQuotaByProvider(
/**
* Hook to get account quota
* Supports agy, codex, gemini, and ghcp providers
* Supports agy, codex, claude, gemini, and ghcp providers
*/
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
return useQuery({
+56
View File
@@ -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<CodexQuotaResult>(`/cliproxy/quota/codex/${encodeURIComponent(accountId)}`),
/** Fetch Claude quota for a specific account */
getClaude: (accountId: string) =>
request<ClaudeQuotaResult>(`/cliproxy/quota/claude/${encodeURIComponent(accountId)}`),
/** Fetch Gemini CLI quota for a specific account */
getGemini: (accountId: string) =>
request<GeminiCliQuotaResult>(`/cliproxy/quota/gemini/${encodeURIComponent(accountId)}`),
+69 -1
View File
@@ -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<string, string> = {
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<typeof window> => !!window
);
if (coreWindows.length > 0) {
return Math.min(...coreWindows.map((window) => window.remainingPercent));
}
const usageWindows = quota.windows.filter((window) => window.rateLimitType !== 'overage');
if (usageWindows.length > 0) {
return Math.min(...usageWindows.map((window) => window.remainingPercent));
}
return null;
}
/**
* Get earliest reset time from Claude policy windows.
*/
export function getClaudePolicyResetTime(quota: ClaudeQuotaResult): string | null {
if (!quota.success) return null;
const coreResets = [quota.coreUsage?.fiveHour?.resetAt, quota.coreUsage?.weekly?.resetAt].filter(
(value): value is string => !!value
);
if (coreResets.length > 0) {
return coreResets.sort()[0];
}
const resets = quota.windows
.filter((window) => window.rateLimitType !== 'overage')
.map((window) => window.resetAt)
.filter((value): value is string => value !== null);
if (resets.length === 0) return null;
return resets.sort()[0];
}
/**
* Get minimum remaining percentage across Gemini CLI buckets
*/
@@ -570,6 +614,7 @@ export function getGhcpResetTime(quotaResetDate: string | null): string | null {
export type UnifiedQuotaResult =
| QuotaResult
| CodexQuotaResult
| ClaudeQuotaResult
| GeminiCliQuotaResult
| GhcpQuotaResult;
@@ -580,7 +625,18 @@ export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResul
/** Type guard: Check if quota result is from Codex provider */
export function isCodexQuotaResult(quota: UnifiedQuotaResult): quota is CodexQuotaResult {
return 'windows' in quota && Array.isArray((quota as CodexQuotaResult).windows);
return (
'windows' in quota && 'planType' in quota && Array.isArray((quota as CodexQuotaResult).windows)
);
}
/** Type guard: Check if quota result is from Claude provider */
export function isClaudeQuotaResult(quota: UnifiedQuotaResult): quota is ClaudeQuotaResult {
return (
'windows' in quota &&
!('planType' in quota) &&
Array.isArray((quota as ClaudeQuotaResult).windows)
);
}
/** Type guard: Check if quota result is from Gemini CLI provider */
@@ -626,6 +682,12 @@ export function getProviderMinQuota(
return getMinCodexQuota(quota.windows);
}
return null;
case 'claude':
case 'anthropic':
if (isClaudeQuotaResult(quota)) {
return getMinClaudePolicyQuota(quota);
}
return null;
case 'gemini':
if (isGeminiQuotaResult(quota)) {
return getMinGeminiQuota(quota.buckets);
@@ -663,6 +725,12 @@ export function getProviderResetTime(
return getCodexResetTime(quota.windows);
}
return null;
case 'claude':
case 'anthropic':
if (isClaudeQuotaResult(quota)) {
return getClaudePolicyResetTime(quota);
}
return null;
case 'gemini':
if (isGeminiQuotaResult(quota)) {
return getGeminiResetTime(quota.buckets);