mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 08:15:17 +00:00
Merge pull request #600 from kaitranntt/fix/548-claude-quota-balance
feat(cliproxy): support Claude quota windows and load balancing
This commit is contained in:
25 files changed
+2256
-112
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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 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<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,
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> | null {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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 isAuthExpired(expiry: string | null): boolean {
|
||||
return expiry ? isTokenExpired(expiry) : false;
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | 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<ClaudeAuthData | null> {
|
||||
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<ClaudeAuthData | null> {
|
||||
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<ClaudeQuotaResult> {
|
||||
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<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) {
|
||||
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;
|
||||
}
|
||||
+119
-32
@@ -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
|
||||
// ============================================================================
|
||||
@@ -174,10 +209,14 @@ export function clearCooldown(provider: CLIProxyProvider, accountId: string): vo
|
||||
async function batchedMap<T, R>(
|
||||
items: T[],
|
||||
fn: (item: T) => Promise<R>,
|
||||
concurrency = 10
|
||||
concurrency = 10,
|
||||
delayMs = 100
|
||||
): Promise<R[]> {
|
||||
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<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 +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<PreflightResult> {
|
||||
@@ -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<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 +425,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 +452,7 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
|
||||
return {
|
||||
proceed: true,
|
||||
accountId: defaultAccount.id,
|
||||
quotaPercent: calculateAverageQuota(quota),
|
||||
quotaPercent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -399,11 +475,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 +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;
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -55,7 +55,7 @@ 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', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'],
|
||||
['quota --provider <name>', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<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 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<QuotaSupportedProvider, QuotaProviderRuntim
|
||||
emptyMessage: 'No Codex accounts configured',
|
||||
authCommand: 'ccs codex --auth',
|
||||
},
|
||||
claude: {
|
||||
fetch: (verbose) => 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,
|
||||
|
||||
@@ -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<string, QuotaRateLimitEntry>();
|
||||
|
||||
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<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 +338,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;
|
||||
}
|
||||
@@ -576,6 +622,12 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise<voi
|
||||
*/
|
||||
router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promise<void> => {
|
||||
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<void> => {
|
||||
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<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
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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'])
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
<div className="mt-2 px-0.5">
|
||||
{quotaLoading ? (
|
||||
@@ -223,7 +268,7 @@ export function AccountCard({
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
<span>Quota...</span>
|
||||
</div>
|
||||
) : minQuota !== null ? (
|
||||
) : minQuotaValue !== null ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -235,9 +280,9 @@ export function AccountCard({
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-mono font-bold',
|
||||
minQuota > 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}%
|
||||
</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>
|
||||
@@ -258,22 +303,24 @@ export function AccountCard({
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
minQuota > 50
|
||||
minQuotaValue > 50
|
||||
? 'bg-emerald-500'
|
||||
: minQuota > 20
|
||||
: minQuotaValue > 20
|
||||
? 'bg-amber-500'
|
||||
: 'bg-red-500'
|
||||
)}
|
||||
style={{ width: `${minQuota}%` }}
|
||||
style={{ width: `${minQuotaValue}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{quota && <QuotaTooltipContent quota={quota} resetTime={resetTime} />}
|
||||
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : quota?.success ? (
|
||||
<div className="text-[8px] text-muted-foreground/60">Quota limits unavailable</div>
|
||||
) : quota?.needsReauth ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -324,7 +363,7 @@ export function AccountItem({
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>Loading quota...</span>
|
||||
</div>
|
||||
) : minQuota !== null ? (
|
||||
) : minQuotaValue !== null ? (
|
||||
<div className="space-y-1.5">
|
||||
{/* Status indicator based on runtime usage, not file state */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
@@ -353,9 +392,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}
|
||||
@@ -374,9 +413,9 @@ export function AccountItem({
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={Math.max(0, Math.min(100, minQuota))}
|
||||
value={Math.max(0, Math.min(100, minQuotaValue))}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(minQuota)}
|
||||
indicatorClassName={getQuotaColor(minQuotaValue)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">
|
||||
{minQuotaLabel}%
|
||||
@@ -385,11 +424,21 @@ export function AccountItem({
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
{quota && <QuotaTooltipContent quota={quota} resetTime={nextReset} />}
|
||||
<QuotaTooltipContent quota={quota} resetTime={nextReset} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
) : quota?.success ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
|
||||
>
|
||||
<HelpCircle className="w-3 h-3" />
|
||||
No limits
|
||||
</Badge>
|
||||
</div>
|
||||
) : quota?.needsReauth ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
@@ -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 <p className="text-xs text-muted-foreground">Loading quota...</p>;
|
||||
}
|
||||
|
||||
if (!quota.success) {
|
||||
return <p className="text-xs text-destructive">{quota.error || 'Failed to load quota'}</p>;
|
||||
}
|
||||
|
||||
// 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<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 (
|
||||
|
||||
@@ -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<Record<string, QuotaSupportedProvider>> = {
|
||||
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<QuotaResult> {
|
||||
const response = await fetch(`/api/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`);
|
||||
@@ -245,6 +273,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
|
||||
*/
|
||||
@@ -291,9 +337,16 @@ async function fetchQuotaByProvider(
|
||||
provider: string,
|
||||
accountId: string
|
||||
): Promise<UnifiedQuotaResult> {
|
||||
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
|
||||
|
||||
@@ -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)}`),
|
||||
|
||||
+137
-15
@@ -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,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<string, unknown> {
|
||||
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<QuotaResult>).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<CodexQuotaResult>;
|
||||
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<ClaudeQuotaResult>;
|
||||
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<GeminiCliQuotaResult>;
|
||||
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<string, unknown> | 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<GhcpQuotaResult>;
|
||||
const snapshots = candidate.snapshots as Record<string, unknown> | null | undefined;
|
||||
if (typeof candidate.success !== 'boolean') return false;
|
||||
if (!isRecord(snapshots)) return false;
|
||||
|
||||
const snapshotKeys: Array<keyof GhcpQuotaResult['snapshots']> = [
|
||||
'premiumInteractions',
|
||||
'chat',
|
||||
'completions',
|
||||
];
|
||||
return snapshotKeys.every((key) => {
|
||||
const snapshot = snapshots[key] as Record<string, unknown> | 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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user