feat(ui): display Codex/Gemini quota in dashboard

- update account-card.tsx to show quota for all CLIProxy providers

- update account-item.tsx with provider-specific tooltip rendering

- enable showQuota for codex and gemini in model-config-tab.tsx
This commit is contained in:
kaitranntt committed 2026-01-29 21:51:39 -05:00
1 parent 19a57c395c
commit 387c010267
4 files changed
+278 -92

No files matched your search

-1
View File
@@ -1,6 +1,5 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "ui", "name": "ui",
@@ -9,11 +9,16 @@ import {
getMinClaudeQuota, getMinClaudeQuota,
getModelsWithTiers, getModelsWithTiers,
groupModelsByTier, groupModelsByTier,
getMinCodexQuota,
getMinGeminiQuota,
getCodexResetTime,
getGeminiResetTime,
type ModelTier, type ModelTier,
} from '@/lib/utils'; } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { GripVertical, Loader2, Clock, Pause, Play } from 'lucide-react'; import { GripVertical, Loader2, Clock, Pause, Play } from 'lucide-react';
import { useAccountQuota } from '@/hooks/use-cliproxy-stats'; import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
import type { CodexQuotaResult, GeminiCliQuotaResult, QuotaResult } from '@/lib/api-client';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -89,19 +94,36 @@ export function AccountCard({
const borderColor = getBorderColorStyle(zone, account.color); const borderColor = getBorderColorStyle(zone, account.color);
const connectorPosition = CONNECTOR_POSITION_MAP[zone]; const connectorPosition = CONNECTOR_POSITION_MAP[zone];
// Quota for AGY accounts // Quota for CLIProxy accounts (agy, codex, gemini)
const isAgy = account.provider === 'agy'; const isCliproxyProvider = ['agy', 'codex', 'gemini'].includes(account.provider);
const { data: quota, isLoading: quotaLoading } = useAccountQuota( const { data: quota, isLoading: quotaLoading } = useAccountQuota(
account.provider, account.provider,
account.id, account.id,
isAgy isCliproxyProvider
); );
// Show minimum quota of Claude models (primary), fallback to min of all models
const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null; // Get provider-specific minimum quota
const getProviderMinQuota = () => {
if (!quota?.success) return null;
switch (account.provider) {
case 'agy':
return getMinClaudeQuota((quota as QuotaResult).models);
case 'codex':
return getMinCodexQuota((quota as CodexQuotaResult).windows);
case 'gemini':
return getMinGeminiQuota((quota as GeminiCliQuotaResult).buckets);
default:
return null;
}
};
const minQuota = getProviderMinQuota();
// Tier badge (AGY only) - show P for Pro, U for Ultra // Tier badge (AGY only) - show P for Pro, U for Ultra
const showTierBadge = const showTierBadge =
isAgy && account.tier && account.tier !== 'unknown' && account.tier !== 'free'; account.provider === 'agy' &&
account.tier &&
account.tier !== 'unknown' &&
account.tier !== 'free';
return ( return (
<div <div
@@ -198,8 +220,8 @@ export function AccountCard({
failure={account.failureCount} failure={account.failureCount}
showDetails={showDetails} showDetails={showDetails}
/> />
{/* Quota bar for AGY accounts */} {/* Quota bar for CLIProxy accounts (agy, codex, gemini) */}
{isAgy && ( {isCliproxyProvider && (
<div className="mt-2 px-0.5"> <div className="mt-2 px-0.5">
{quotaLoading ? ( {quotaLoading ? (
<div className="flex items-center gap-1 text-[8px] text-muted-foreground"> <div className="flex items-center gap-1 text-[8px] text-muted-foreground">
@@ -244,47 +266,99 @@ export function AccountCard({
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="max-w-xs"> <TooltipContent side="top" className="max-w-xs">
<div className="text-xs space-y-1"> {account.provider === 'agy' ? (
<p className="font-medium">Model Quotas:</p> <div className="text-xs space-y-1">
{(() => { <p className="font-medium">Model Quotas:</p>
const tiered = getModelsWithTiers(quota?.models || []); {(() => {
const groups = groupModelsByTier(tiered); const tiered = getModelsWithTiers((quota as QuotaResult)?.models || []);
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; const groups = groupModelsByTier(tiered);
return tierOrder.map((tier, idx) => { const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
const models = groups.get(tier); return tierOrder.map((tier, idx) => {
if (!models || models.length === 0) return null; const models = groups.get(tier);
const isFirst = tierOrder if (!models || models.length === 0) return null;
.slice(0, idx) const isFirst = tierOrder
.every((t) => !groups.get(t)?.length); .slice(0, idx)
return ( .every((t) => !groups.get(t)?.length);
<div key={tier}> return (
{!isFirst && <div className="border-t border-border/40 my-1" />} <div key={tier}>
{models.map((m) => ( {!isFirst && <div className="border-t border-border/40 my-1" />}
<div key={m.name} className="flex justify-between gap-4"> {models.map((m) => (
<span className={cn('truncate', m.exhausted && 'text-red-500')}> <div key={m.name} className="flex justify-between gap-4">
{m.displayName} <span className={cn('truncate', m.exhausted && 'text-red-500')}>
</span> {m.displayName}
<span className={cn('font-mono', m.exhausted && 'text-red-500')}> </span>
{m.percentage}% <span className={cn('font-mono', m.exhausted && 'text-red-500')}>
</span> {m.percentage}%
</div> </span>
))} </div>
))}
</div>
);
});
})()}
{(() => {
const resetTime = getClaudeResetTime((quota as QuotaResult)?.models || []);
return resetTime ? (
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
Resets {formatResetTime(resetTime)}
</span>
</div> </div>
); ) : null;
}); })()}
})()} </div>
{(() => { ) : account.provider === 'codex' ? (
const resetTime = getClaudeResetTime(quota?.models || []); <div className="text-xs space-y-1">
return resetTime ? ( <p className="font-medium">Rate Limits:</p>
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50"> {(quota as CodexQuotaResult)?.windows?.map((w) => (
<Clock className="w-3 h-3 text-blue-400" /> <div key={w.label} className="flex justify-between gap-4">
<span className="text-blue-400 font-medium"> <span className={cn(w.remainingPercent < 20 && 'text-red-500')}>
Resets {formatResetTime(resetTime)} {w.label}
</span> </span>
<span className="font-mono">{w.remainingPercent}%</span>
</div> </div>
) : null; ))}
})()} {(() => {
</div> const resetTime = getCodexResetTime(
(quota as CodexQuotaResult)?.windows || []
);
return resetTime ? (
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
Resets {formatResetTime(resetTime)}
</span>
</div>
) : null;
})()}
</div>
) : account.provider === 'gemini' ? (
<div className="text-xs space-y-1">
<p className="font-medium">Buckets:</p>
{(quota as GeminiCliQuotaResult)?.buckets?.map((b) => (
<div key={b.id} className="flex justify-between gap-4">
<span className={cn(b.remainingPercent < 20 && 'text-red-500')}>
{b.label}
</span>
<span className="font-mono">{b.remainingPercent}%</span>
</div>
))}
{(() => {
const resetTime = getGeminiResetTime(
(quota as GeminiCliQuotaResult)?.buckets || []
);
return resetTime ? (
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
Resets {formatResetTime(resetTime)}
</span>
</div>
) : null;
})()}
</div>
) : null}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -36,10 +36,20 @@ import {
getMinClaudeQuota, getMinClaudeQuota,
getModelsWithTiers, getModelsWithTiers,
groupModelsByTier, groupModelsByTier,
getMinCodexQuota,
getMinGeminiQuota,
getCodexResetTime,
getGeminiResetTime,
type ModelTier, type ModelTier,
} from '@/lib/utils'; } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import {
useAccountQuota,
useCliproxyStats,
type QuotaResult,
type CodexQuotaResult,
type GeminiCliQuotaResult,
} from '@/hooks/use-cliproxy-stats';
import type { AccountItemProps } from './types'; import type { AccountItemProps } from './types';
/** /**
@@ -118,12 +128,152 @@ export function AccountItem({
const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt; const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt;
const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed); const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed);
// Show minimum quota of Claude models (primary), fallback to min of all models // Compute min quota based on provider
const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null; const getProviderMinQuota = (): number | null => {
if (!quota?.success) return null;
switch (account.provider) {
case 'agy':
return getMinClaudeQuota((quota as QuotaResult).models);
case 'codex':
return getMinCodexQuota((quota as CodexQuotaResult).windows);
case 'gemini':
return getMinGeminiQuota((quota as GeminiCliQuotaResult).buckets);
default:
return null;
}
};
const minQuota = getProviderMinQuota();
// Get earliest reset time // Compute reset time based on provider
const nextReset = const getProviderResetTime = (): string | null => {
quota?.success && quota.models.length > 0 ? getClaudeResetTime(quota.models) : null; if (!quota?.success) return null;
switch (account.provider) {
case 'agy':
return getClaudeResetTime((quota as QuotaResult).models);
case 'codex':
return getCodexResetTime((quota as CodexQuotaResult).windows);
case 'gemini':
return getGeminiResetTime((quota as GeminiCliQuotaResult).buckets);
default:
return null;
}
};
const nextReset = getProviderResetTime();
// Render Antigravity (agy) provider tooltip
const renderAgyTooltip = () => {
const tiered = getModelsWithTiers((quota as QuotaResult).models || []);
const groups = groupModelsByTier(tiered);
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
return tierOrder.map((tier, idx) => {
const models = groups.get(tier);
if (!models || models.length === 0) return null;
const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length);
return (
<div key={tier}>
{!isFirst && <div className="border-t border-border/40 my-1" />}
{models.map((m) => (
<div key={m.name} className="flex justify-between gap-4">
<span className={cn('truncate', m.exhausted && 'text-red-500')}>{m.displayName}</span>
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
{m.percentage}%
</span>
</div>
))}
</div>
);
});
};
// Render Codex provider tooltip
const renderCodexTooltip = () => {
const windows = (quota as CodexQuotaResult).windows;
const planType = (quota as CodexQuotaResult).planType;
return (
<>
{planType && <p className="text-muted-foreground">Plan: {planType}</p>}
{windows.map((w) => (
<div key={w.label} className="flex justify-between gap-4">
<span>{w.label}</span>
<span className="font-mono">{w.remainingPercent}%</span>
</div>
))}
</>
);
};
// Render Gemini provider tooltip
const renderGeminiTooltip = () => {
const buckets = (quota as GeminiCliQuotaResult).buckets;
return (
<>
{buckets.map((b) => (
<div key={b.id} className="flex justify-between gap-4">
<span>
{b.label}
{b.tokenType ? ` (${b.tokenType})` : ''}
</span>
<span className="font-mono">{b.remainingPercent}%</span>
</div>
))}
</>
);
};
// Provider-specific tooltip content renderer
const renderQuotaTooltip = () => {
if (!quota?.success) return null;
switch (account.provider) {
case 'agy':
return (
<div className="text-xs space-y-1">
<p className="font-medium">Model Quotas:</p>
{renderAgyTooltip()}
{nextReset && (
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
Resets {formatResetTime(nextReset)}
</span>
</div>
)}
</div>
);
case 'codex':
return (
<div className="text-xs space-y-1">
<p className="font-medium">Rate Limit Windows:</p>
{renderCodexTooltip()}
{nextReset && (
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
Resets {formatResetTime(nextReset)}
</span>
</div>
)}
</div>
);
case 'gemini':
return (
<div className="text-xs space-y-1">
<p className="font-medium">Model Buckets:</p>
{renderGeminiTooltip()}
{nextReset && (
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
Resets {formatResetTime(nextReset)}
</span>
</div>
)}
</div>
);
default:
return null;
}
};
return ( return (
<div <div
@@ -356,44 +506,7 @@ export function AccountItem({
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs"> <TooltipContent side="bottom" className="max-w-xs">
<div className="text-xs space-y-1"> {renderQuotaTooltip()}
<p className="font-medium">Model Quotas:</p>
{(() => {
const tiered = getModelsWithTiers(quota?.models || []);
const groups = groupModelsByTier(tiered);
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
return tierOrder.map((tier, idx) => {
const models = groups.get(tier);
if (!models || models.length === 0) return null;
const isFirst = tierOrder
.slice(0, idx)
.every((t) => !groups.get(t)?.length);
return (
<div key={tier}>
{!isFirst && <div className="border-t border-border/40 my-1" />}
{models.map((m) => (
<div key={m.name} className="flex justify-between gap-4">
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
{m.displayName}
</span>
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
{m.percentage}%
</span>
</div>
))}
</div>
);
});
})()}
{nextReset && (
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
Resets {formatResetTime(nextReset)}
</span>
</div>
)}
</div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -167,7 +167,7 @@ export function ModelConfigTab({
isBulkPausing={isBulkPausing} isBulkPausing={isBulkPausing}
isBulkResuming={isBulkResuming} isBulkResuming={isBulkResuming}
privacyMode={privacyMode} privacyMode={privacyMode}
showQuota={provider === 'agy' && !isRemoteMode} showQuota={['agy', 'codex', 'gemini'].includes(provider) && !isRemoteMode}
isKiro={isKiro} isKiro={isKiro}
kiroNoIncognito={kiroNoIncognito} kiroNoIncognito={kiroNoIncognito}
onKiroNoIncognitoChange={saveKiroNoIncognito} onKiroNoIncognitoChange={saveKiroNoIncognito}