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

This commit is contained in:
Tam Nhu Tran committed 2026-02-22 01:19:02 +07:00
1 parent 6a9abd2a48
commit 2385d9028a
19 files changed
+1599 -74

No files matched your search

@@ -8,6 +8,7 @@ import {
getCodexQuotaBreakdown,
getProviderMinQuota,
getProviderResetTime,
isClaudeQuotaResult,
isCodexQuotaResult,
} from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
@@ -90,7 +91,7 @@ export function AccountCard({
const borderColor = getBorderColorStyle(zone, account.color);
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
// Quota for CLIProxy accounts (agy, codex, gemini)
// Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp)
const isCliproxyProvider = QUOTA_SUPPORTED_PROVIDERS.includes(
account.provider as QuotaSupportedProvider
);
@@ -111,6 +112,40 @@ export function AccountCard({
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
{ label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
].filter((row): row is { label: string; value: number } => row.value !== null);
const claudeQuotaRows =
account.provider === 'claude' && quota && isClaudeQuotaResult(quota)
? [
{
label: '5h',
value:
quota.coreUsage?.fiveHour?.remainingPercent ??
quota.windows.find((window) => window.rateLimitType === 'five_hour')
?.remainingPercent ??
null,
},
{
label: 'Wk',
value:
quota.coreUsage?.weekly?.remainingPercent ??
quota.windows.find((window) =>
[
'seven_day',
'seven_day_opus',
'seven_day_sonnet',
'seven_day_oauth_apps',
'seven_day_cowork',
].includes(window.rateLimitType)
)?.remainingPercent ??
null,
},
].filter((row): row is { label: string; value: number } => row.value !== null)
: [];
const compactQuotaRows =
account.provider === 'codex'
? codexQuotaRows
: account.provider === 'claude'
? claudeQuotaRows
: [];
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
// Tier badge (AGY only) - show P for Pro, U for Ultra
@@ -215,7 +250,7 @@ export function AccountCard({
failure={account.failureCount}
showDetails={showDetails}
/>
{/* Quota bar for CLIProxy accounts (agy, codex, gemini) */}
{/* Quota bar for CLIProxy accounts */}
{isCliproxyProvider && (
<div className="mt-2 px-0.5">
{quotaLoading ? (
@@ -245,9 +280,9 @@ export function AccountCard({
{minQuotaLabel}%
</span>
</div>
{account.provider === 'codex' && codexQuotaRows.length > 0 && (
{compactQuotaRows.length > 0 && (
<div className="flex items-center justify-between text-[7px] text-muted-foreground/70">
{codexQuotaRows.map((row) => (
{compactQuotaRows.map((row) => (
<span key={row.label}>
{row.label} {row.value}%
</span>
@@ -36,6 +36,7 @@ import {
getCodexQuotaBreakdown,
getProviderMinQuota,
getProviderResetTime,
isClaudeQuotaResult,
isCodexQuotaResult,
} from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
@@ -130,6 +131,40 @@ export function AccountItem({
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
{ label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
].filter((row): row is { label: string; value: number } => row.value !== null);
const claudeQuotaRows =
account.provider === 'claude' && quota && isClaudeQuotaResult(quota)
? [
{
label: '5h',
value:
quota.coreUsage?.fiveHour?.remainingPercent ??
quota.windows.find((window) => window.rateLimitType === 'five_hour')
?.remainingPercent ??
null,
},
{
label: 'Weekly',
value:
quota.coreUsage?.weekly?.remainingPercent ??
quota.windows.find((window) =>
[
'seven_day',
'seven_day_opus',
'seven_day_sonnet',
'seven_day_oauth_apps',
'seven_day_cowork',
].includes(window.rateLimitType)
)?.remainingPercent ??
null,
},
].filter((row): row is { label: string; value: number } => row.value !== null)
: [];
const dualWindowQuotaRows =
account.provider === 'codex'
? codexQuotaRows
: account.provider === 'claude'
? claudeQuotaRows
: [];
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
return (
@@ -353,9 +388,9 @@ export function AccountItem({
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{account.provider === 'codex' && codexQuotaRows.length > 0 ? (
{dualWindowQuotaRows.length > 0 ? (
<div className="space-y-1.5">
{codexQuotaRows.map((row) => (
{dualWindowQuotaRows.map((row) => (
<div key={row.label} className="flex items-center gap-2">
<span className="w-10 text-[10px] text-muted-foreground">
{row.label}
@@ -35,7 +35,7 @@ interface AccountsSectionProps {
/** Bulk resume mutation in progress */
isBulkResuming?: boolean;
privacyMode?: boolean;
/** Show quota bars for accounts (only applicable for 'agy' provider) */
/** Show quota bars for accounts when provider supports quota API */
showQuota?: boolean;
/** Kiro-specific: show "use normal browser" toggle */
isKiro?: boolean;
@@ -61,7 +61,7 @@ export interface AccountItemProps {
/** Solo mode mutation in progress */
isSoloingAccount?: boolean;
privacyMode?: boolean;
/** Show quota bar (only for 'agy' provider) */
/** Show quota bar for providers with quota API support */
showQuota?: boolean;
/** Enable checkbox for multi-select */
selectable?: boolean;
@@ -13,6 +13,7 @@ import {
getModelsWithTiers,
groupModelsByTier,
isAgyQuotaResult,
isClaudeQuotaResult,
isCodexQuotaResult,
isGeminiQuotaResult,
isGhcpQuotaResult,
@@ -35,6 +36,27 @@ function formatPlanLabel(planType: string | null | undefined): string | null {
return normalized.length > 0 ? normalized.join(' ') : planType;
}
function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): string {
switch (rateLimitType) {
case 'five_hour':
return '5h usage limit';
case 'seven_day':
return 'Weekly usage limit';
case 'seven_day_opus':
return 'Weekly usage (Opus)';
case 'seven_day_sonnet':
return 'Weekly usage (Sonnet)';
case 'seven_day_oauth_apps':
return 'Weekly usage (OAuth apps)';
case 'seven_day_cowork':
return 'Weekly usage (Cowork)';
case 'overage':
return 'Extra usage';
default:
return fallback;
}
}
/**
* Renders provider-specific quota tooltip content
* Uses type guards for proper TypeScript narrowing
@@ -115,6 +137,74 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
);
}
// Claude provider tooltip
if (isClaudeQuotaResult(quota)) {
const coreWindows = [quota.coreUsage?.fiveHour, quota.coreUsage?.weekly]
.filter((window): window is NonNullable<typeof window> => !!window)
.map((window) => ({
rateLimitType: window.rateLimitType,
label: window.label,
remainingPercent: window.remainingPercent,
resetAt: window.resetAt,
status: window.status,
}));
const policyWindows = quota.windows.map((window) => ({
rateLimitType: window.rateLimitType,
label: window.label,
remainingPercent: window.remainingPercent,
resetAt: window.resetAt,
status: window.status,
}));
const orderedWindows = [...coreWindows, ...policyWindows].filter(
(window, index, arr) =>
arr.findIndex(
(candidate) =>
candidate.rateLimitType === window.rateLimitType &&
candidate.resetAt === window.resetAt &&
candidate.status === window.status
) === index
);
const fiveHourResetAt =
quota.coreUsage?.fiveHour?.resetAt ??
quota.windows.find((window) => window.rateLimitType === 'five_hour')?.resetAt ??
null;
const weeklyResetAt =
quota.coreUsage?.weekly?.resetAt ??
quota.windows.find((window) =>
[
'seven_day',
'seven_day_opus',
'seven_day_sonnet',
'seven_day_oauth_apps',
'seven_day_cowork',
].includes(window.rateLimitType)
)?.resetAt ??
null;
return (
<div className="text-xs space-y-1">
<p className="font-medium">Rate Limits:</p>
{orderedWindows.map((window, index) => (
<div
key={`${window.rateLimitType}-${window.resetAt ?? 'no-reset'}-${window.status}-${index}`}
className="flex justify-between gap-4"
>
<span className={cn(window.remainingPercent < 20 && 'text-red-500')}>
{getClaudeWindowDisplayLabel(window.rateLimitType, window.label)}
</span>
<span className="font-mono">{window.remainingPercent}%</span>
</div>
))}
<CodexResetIndicators
fiveHourResetTime={fiveHourResetAt}
weeklyResetTime={weeklyResetAt}
fallbackResetTime={resetTime}
/>
</div>
);
}
// Gemini provider tooltip
if (isGeminiQuotaResult(quota)) {
return (
+32 -4
View File
@@ -7,6 +7,7 @@ import type {
ModelQuota,
QuotaResult,
CodexQuotaResult,
ClaudeQuotaResult,
GeminiCliQuotaResult,
GhcpQuotaResult,
} from '@/lib/api-client';
@@ -203,14 +204,21 @@ export function useCliproxyErrorLogContent(name: string | null) {
}
// Re-export for consumers
export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult };
export type {
ModelQuota,
QuotaResult,
CodexQuotaResult,
ClaudeQuotaResult,
GeminiCliQuotaResult,
GhcpQuotaResult,
};
/** Providers with quota API support */
export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini', 'ghcp'] as const;
export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'claude', 'gemini', 'ghcp'] as const;
export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number];
/**
* Fetch account quota from API (Antigravity only)
* Fetch account quota from generic API route
*/
async function fetchAccountQuota(provider: string, accountId: string): Promise<QuotaResult> {
const response = await fetch(`/api/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`);
@@ -245,6 +253,24 @@ async function fetchCodexQuotaApi(accountId: string): Promise<CodexQuotaResult>
return response.json();
}
/**
* Fetch Claude quota from API
*/
async function fetchClaudeQuotaApi(accountId: string): Promise<ClaudeQuotaResult> {
const response = await fetch(`/api/cliproxy/quota/claude/${encodeURIComponent(accountId)}`);
if (!response.ok) {
let message = 'Failed to fetch Claude quota';
try {
const error = await response.json();
message = error.message || message;
} catch {
// Use default message if response isn't JSON
}
throw new Error(message);
}
return response.json();
}
/**
* Fetch Gemini quota from API
*/
@@ -294,6 +320,8 @@ async function fetchQuotaByProvider(
switch (provider) {
case 'codex':
return fetchCodexQuotaApi(accountId);
case 'claude':
return fetchClaudeQuotaApi(accountId);
case 'gemini':
return fetchGeminiQuotaApi(accountId);
case 'ghcp':
@@ -305,7 +333,7 @@ async function fetchQuotaByProvider(
/**
* Hook to get account quota
* Supports agy, codex, gemini, and ghcp providers
* Supports agy, codex, claude, gemini, and ghcp providers
*/
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
return useQuery({
+56
View File
@@ -299,6 +299,59 @@ export interface CodexQuotaResult {
isForbidden?: boolean;
}
/** Claude policy limit window */
export interface ClaudeQuotaWindow {
/** Source identifier: five_hour, seven_day, seven_day_opus, seven_day_sonnet, overage, ... */
rateLimitType: string;
/** Human-friendly label for UI display */
label: string;
/** Upstream status: allowed, allowed_warning, rejected */
status: string;
/** Utilization ratio (0-1) when available */
utilization: number | null;
/** Utilization as percentage (0-100) */
usedPercent: number;
/** Remaining percentage (100 - usedPercent) */
remainingPercent: number;
/** Reset timestamp for this window, null if unknown */
resetAt: string | null;
surpassedThreshold?: boolean;
severity?: string;
overageStatus?: string;
overageResetsAt?: string | null;
overageDisabledReason?: string | null;
isUsingOverage?: boolean;
hasExtraUsageEnabled?: boolean;
}
/** Core Claude usage window (5h/weekly) */
export interface ClaudeCoreUsageWindow {
rateLimitType: string;
label: string;
remainingPercent: number;
resetAt: string | null;
status: string;
}
/** Core Claude usage summary (5h + weekly) */
export interface ClaudeCoreUsageSummary {
fiveHour: ClaudeCoreUsageWindow | null;
weekly: ClaudeCoreUsageWindow | null;
}
/** Claude quota result */
export interface ClaudeQuotaResult {
success: boolean;
windows: ClaudeQuotaWindow[];
coreUsage?: ClaudeCoreUsageSummary;
lastUpdated: number;
error?: string;
accountId?: string;
needsReauth?: boolean;
/** True if result was served from cache */
cached?: boolean;
}
/** Gemini CLI bucket (grouped by model series) */
export interface GeminiCliBucket {
/** Unique bucket identifier (e.g., "gemini-flash-series::input") */
@@ -793,6 +846,9 @@ export const api = {
/** Fetch Codex quota for a specific account */
getCodex: (accountId: string) =>
request<CodexQuotaResult>(`/cliproxy/quota/codex/${encodeURIComponent(accountId)}`),
/** Fetch Claude quota for a specific account */
getClaude: (accountId: string) =>
request<ClaudeQuotaResult>(`/cliproxy/quota/claude/${encodeURIComponent(accountId)}`),
/** Fetch Gemini CLI quota for a specific account */
getGemini: (accountId: string) =>
request<GeminiCliQuotaResult>(`/cliproxy/quota/gemini/${encodeURIComponent(accountId)}`),
+69 -1
View File
@@ -3,6 +3,7 @@ import { twMerge } from 'tailwind-merge';
import type {
CodexQuotaWindow,
CodexQuotaResult,
ClaudeQuotaResult,
GeminiCliBucket,
GeminiCliQuotaResult,
GhcpQuotaResult,
@@ -42,6 +43,7 @@ const PROVIDER_COLORS: Record<string, string> = {
agy: '#f3722c', // Pumpkin
gemini: '#277da1', // Cerulean
codex: '#f8961e', // Carrot
claude: '#4d908e', // Dark Cyan
vertex: '#577590', // Blue Slate
iflow: '#f94144', // Strawberry
qwen: '#f9c74f', // Tuscan
@@ -522,6 +524,48 @@ export function getCodexResetTime(windows: CodexQuotaWindow[]): string | null {
return resets.sort()[0];
}
/**
* Get minimum remaining percentage across Claude policy windows.
*/
export function getMinClaudePolicyQuota(quota: ClaudeQuotaResult): number | null {
if (!quota.success) return null;
const coreWindows = [quota.coreUsage?.fiveHour, quota.coreUsage?.weekly].filter(
(window): window is NonNullable<typeof window> => !!window
);
if (coreWindows.length > 0) {
return Math.min(...coreWindows.map((window) => window.remainingPercent));
}
const usageWindows = quota.windows.filter((window) => window.rateLimitType !== 'overage');
if (usageWindows.length > 0) {
return Math.min(...usageWindows.map((window) => window.remainingPercent));
}
return null;
}
/**
* Get earliest reset time from Claude policy windows.
*/
export function getClaudePolicyResetTime(quota: ClaudeQuotaResult): string | null {
if (!quota.success) return null;
const coreResets = [quota.coreUsage?.fiveHour?.resetAt, quota.coreUsage?.weekly?.resetAt].filter(
(value): value is string => !!value
);
if (coreResets.length > 0) {
return coreResets.sort()[0];
}
const resets = quota.windows
.filter((window) => window.rateLimitType !== 'overage')
.map((window) => window.resetAt)
.filter((value): value is string => value !== null);
if (resets.length === 0) return null;
return resets.sort()[0];
}
/**
* Get minimum remaining percentage across Gemini CLI buckets
*/
@@ -570,6 +614,7 @@ export function getGhcpResetTime(quotaResetDate: string | null): string | null {
export type UnifiedQuotaResult =
| QuotaResult
| CodexQuotaResult
| ClaudeQuotaResult
| GeminiCliQuotaResult
| GhcpQuotaResult;
@@ -580,7 +625,18 @@ export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResul
/** Type guard: Check if quota result is from Codex provider */
export function isCodexQuotaResult(quota: UnifiedQuotaResult): quota is CodexQuotaResult {
return 'windows' in quota && Array.isArray((quota as CodexQuotaResult).windows);
return (
'windows' in quota && 'planType' in quota && Array.isArray((quota as CodexQuotaResult).windows)
);
}
/** Type guard: Check if quota result is from Claude provider */
export function isClaudeQuotaResult(quota: UnifiedQuotaResult): quota is ClaudeQuotaResult {
return (
'windows' in quota &&
!('planType' in quota) &&
Array.isArray((quota as ClaudeQuotaResult).windows)
);
}
/** Type guard: Check if quota result is from Gemini CLI provider */
@@ -626,6 +682,12 @@ export function getProviderMinQuota(
return getMinCodexQuota(quota.windows);
}
return null;
case 'claude':
case 'anthropic':
if (isClaudeQuotaResult(quota)) {
return getMinClaudePolicyQuota(quota);
}
return null;
case 'gemini':
if (isGeminiQuotaResult(quota)) {
return getMinGeminiQuota(quota.buckets);
@@ -663,6 +725,12 @@ export function getProviderResetTime(
return getCodexResetTime(quota.windows);
}
return null;
case 'claude':
case 'anthropic':
if (isClaudeQuotaResult(quota)) {
return getClaudePolicyResetTime(quota);
}
return null;
case 'gemini':
if (isGeminiQuotaResult(quota)) {
return getGeminiResetTime(quota.buckets);