mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 04:17:54 +00:00
Merge pull request #554 from kaitranntt/fix/553-codex-quota-display
fix(codex): align quota display to 5h and weekly windows
This commit is contained in:
@@ -14,6 +14,8 @@ import type { CodexQuotaResult, CodexQuotaWindow } from './quota-types';
|
||||
|
||||
/** ChatGPT backend API base URL */
|
||||
const CODEX_API_BASE = 'https://chatgpt.com/backend-api';
|
||||
const CODEX_QUOTA_TIMEOUT_MS = 12000;
|
||||
const CODEX_QUOTA_MAX_ATTEMPTS = 2;
|
||||
|
||||
/**
|
||||
* User agent matching Codex CLI for API compatibility.
|
||||
@@ -222,114 +224,134 @@ export async function fetchCodexQuota(
|
||||
}
|
||||
|
||||
const url = `${CODEX_API_BASE}/wham/usage`;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
let lastErrorMsg = 'Unknown error';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${authData.accessToken}`,
|
||||
'ChatGPT-Account-Id': authData.accountId,
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
for (let attempt = 1; attempt <= CODEX_QUOTA_MAX_ATTEMPTS; attempt++) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), CODEX_QUOTA_TIMEOUT_MS);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${authData.accessToken}`,
|
||||
'ChatGPT-Account-Id': authData.accountId,
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
|
||||
if (verbose) console.error(`[i] Codex API status: ${response.status}`);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (verbose) console.error(`[i] Codex API status: ${response.status} (attempt ${attempt})`);
|
||||
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
error: 'Token expired or invalid',
|
||||
accountId,
|
||||
needsReauth: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
// 403 = account lacks API access (not same as quota exhausted)
|
||||
// Keep success=false with isForbidden flag for UI to show distinct "403" badge
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
error: '403 Forbidden - No quota API access',
|
||||
accountId,
|
||||
isForbidden: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
error: 'Rate limited - try again later',
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
error: `API error: ${response.status}`,
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await response.json()) as CodexUsageResponse;
|
||||
const windows = buildCodexQuotaWindows(data);
|
||||
|
||||
// Extract plan type
|
||||
const planTypeRaw = data.plan_type || data.planType;
|
||||
let planType: 'free' | 'plus' | 'team' | null = null;
|
||||
if (planTypeRaw) {
|
||||
const normalized = planTypeRaw.toLowerCase();
|
||||
if (normalized === 'free') planType = 'free';
|
||||
else if (normalized === 'plus') planType = 'plus';
|
||||
else if (normalized === 'team') planType = 'team';
|
||||
}
|
||||
|
||||
if (verbose) console.error(`[i] Codex windows found: ${windows.length}`);
|
||||
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
planType: null,
|
||||
success: true,
|
||||
windows,
|
||||
planType,
|
||||
lastUpdated: Date.now(),
|
||||
error: 'Token expired or invalid',
|
||||
accountId,
|
||||
needsReauth: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
// 403 = account lacks API access (not same as quota exhausted)
|
||||
// Keep success=false with isForbidden flag for UI to show distinct "403" badge
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
error: '403 Forbidden - No quota API access',
|
||||
accountId,
|
||||
isForbidden: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
error: 'Rate limited - try again later',
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
error: `API error: ${response.status}`,
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await response.json()) as CodexUsageResponse;
|
||||
const windows = buildCodexQuotaWindows(data);
|
||||
|
||||
// Extract plan type
|
||||
const planTypeRaw = data.plan_type || data.planType;
|
||||
let planType: 'free' | 'plus' | 'team' | null = null;
|
||||
if (planTypeRaw) {
|
||||
const normalized = planTypeRaw.toLowerCase();
|
||||
if (normalized === 'free') planType = 'free';
|
||||
else if (normalized === 'plus') planType = 'plus';
|
||||
else if (normalized === 'team') planType = 'team';
|
||||
}
|
||||
|
||||
if (verbose) console.error(`[i] Codex windows found: ${windows.length}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
windows,
|
||||
planType,
|
||||
lastUpdated: Date.now(),
|
||||
accountId,
|
||||
};
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
const errorMsg =
|
||||
err instanceof Error && err.name === 'AbortError'
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
const isAbortError = err instanceof Error && err.name === 'AbortError';
|
||||
lastErrorMsg = isAbortError
|
||||
? 'Request timeout'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Unknown error';
|
||||
|
||||
if (verbose) console.error(`[!] Codex quota error: ${errorMsg}`);
|
||||
if (verbose) {
|
||||
console.error(`[!] Codex quota error (attempt ${attempt}): ${lastErrorMsg}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
error: errorMsg,
|
||||
accountId,
|
||||
};
|
||||
// Retry timeout once; other failures return immediately.
|
||||
if (isAbortError && attempt < CODEX_QUOTA_MAX_ATTEMPTS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
error: lastErrorMsg,
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
error: lastErrorMsg,
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,6 +78,134 @@ function formatResetTimeISO(isoTime: string): string {
|
||||
return formatResetTime(seconds);
|
||||
}
|
||||
|
||||
type CodexWindowKind =
|
||||
| 'usage-5h'
|
||||
| 'usage-weekly'
|
||||
| 'code-review-5h'
|
||||
| 'code-review-weekly'
|
||||
| 'code-review'
|
||||
| 'unknown';
|
||||
|
||||
function getCodexWindowKind(label: string): CodexWindowKind {
|
||||
const lower = (label || '').toLowerCase();
|
||||
const isCodeReview = lower.includes('code review') || lower.includes('code_review');
|
||||
const isPrimary = lower.includes('primary');
|
||||
const isSecondary = lower.includes('secondary');
|
||||
|
||||
if (isCodeReview) {
|
||||
if (isPrimary) return 'code-review-5h';
|
||||
if (isSecondary) return 'code-review-weekly';
|
||||
return 'code-review';
|
||||
}
|
||||
|
||||
if (isPrimary) return 'usage-5h';
|
||||
if (isSecondary) return 'usage-weekly';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
type CodexWindowSummary = Pick<CodexQuotaResult['windows'][number], 'label' | 'resetAfterSeconds'>;
|
||||
|
||||
function inferCodeReviewCadence(
|
||||
window: CodexWindowSummary,
|
||||
allWindows: CodexWindowSummary[]
|
||||
): '5h' | 'weekly' | null {
|
||||
const kind = getCodexWindowKind(window.label);
|
||||
if (kind === 'code-review-weekly') return 'weekly';
|
||||
|
||||
const reset = window.resetAfterSeconds;
|
||||
if (typeof reset !== 'number' || !isFinite(reset) || reset <= 0) return null;
|
||||
|
||||
const usage5h = allWindows.find(
|
||||
(w) =>
|
||||
getCodexWindowKind(w.label) === 'usage-5h' &&
|
||||
typeof w.resetAfterSeconds === 'number' &&
|
||||
isFinite(w.resetAfterSeconds) &&
|
||||
w.resetAfterSeconds > 0
|
||||
);
|
||||
const usageWeekly = allWindows.find(
|
||||
(w) =>
|
||||
getCodexWindowKind(w.label) === 'usage-weekly' &&
|
||||
typeof w.resetAfterSeconds === 'number' &&
|
||||
isFinite(w.resetAfterSeconds) &&
|
||||
w.resetAfterSeconds > 0
|
||||
);
|
||||
|
||||
if (!usage5h || !usageWeekly) return null;
|
||||
|
||||
const diffTo5h = Math.abs(reset - (usage5h.resetAfterSeconds as number));
|
||||
const diffToWeekly = Math.abs(reset - (usageWeekly.resetAfterSeconds as number));
|
||||
return diffToWeekly <= diffTo5h ? 'weekly' : '5h';
|
||||
}
|
||||
|
||||
function getCodexWindowDisplayLabel(
|
||||
window: CodexWindowSummary,
|
||||
allWindows: CodexWindowSummary[] = []
|
||||
): string {
|
||||
const context = allWindows.length > 0 ? allWindows : [window];
|
||||
|
||||
switch (getCodexWindowKind(window.label)) {
|
||||
case 'usage-5h':
|
||||
return '5h usage limit';
|
||||
case 'usage-weekly':
|
||||
return 'Weekly usage limit';
|
||||
case 'code-review-5h':
|
||||
case 'code-review-weekly':
|
||||
case 'code-review': {
|
||||
const inferred = inferCodeReviewCadence(window, context);
|
||||
if (inferred === '5h') return 'Code review (5h)';
|
||||
if (inferred === 'weekly') return 'Code review (weekly)';
|
||||
return 'Code review';
|
||||
}
|
||||
case 'unknown':
|
||||
return window.label;
|
||||
}
|
||||
}
|
||||
|
||||
function getCodexCoreUsageWindows(windows: CodexQuotaResult['windows']): {
|
||||
fiveHourWindow: CodexQuotaResult['windows'][number] | null;
|
||||
weeklyWindow: CodexQuotaResult['windows'][number] | null;
|
||||
} {
|
||||
let fiveHourWindow: CodexQuotaResult['windows'][number] | null = null;
|
||||
let weeklyWindow: CodexQuotaResult['windows'][number] | null = null;
|
||||
const nonCodeReviewWindows: CodexQuotaResult['windows'] = [];
|
||||
|
||||
for (const window of windows) {
|
||||
const kind = getCodexWindowKind(window.label);
|
||||
if (kind === 'usage-5h') {
|
||||
if (!fiveHourWindow) fiveHourWindow = window;
|
||||
nonCodeReviewWindows.push(window);
|
||||
continue;
|
||||
}
|
||||
if (kind === 'usage-weekly') {
|
||||
if (!weeklyWindow) weeklyWindow = window;
|
||||
nonCodeReviewWindows.push(window);
|
||||
continue;
|
||||
}
|
||||
if (kind === 'unknown') {
|
||||
nonCodeReviewWindows.push(window);
|
||||
}
|
||||
}
|
||||
|
||||
if ((!fiveHourWindow || !weeklyWindow) && nonCodeReviewWindows.length > 0) {
|
||||
const withReset = nonCodeReviewWindows
|
||||
.filter((w) => typeof w.resetAfterSeconds === 'number' && w.resetAfterSeconds >= 0)
|
||||
.sort((a, b) => (a.resetAfterSeconds || 0) - (b.resetAfterSeconds || 0));
|
||||
|
||||
if (!fiveHourWindow) {
|
||||
fiveHourWindow = withReset[0] || nonCodeReviewWindows[0] || null;
|
||||
}
|
||||
|
||||
if (!weeklyWindow) {
|
||||
weeklyWindow =
|
||||
withReset.length > 1
|
||||
? withReset[withReset.length - 1]
|
||||
: nonCodeReviewWindows.find((w) => w !== fiveHourWindow) || null;
|
||||
}
|
||||
}
|
||||
|
||||
return { fiveHourWindow, weeklyWindow };
|
||||
}
|
||||
|
||||
function displayAntigravityQuotaSection(
|
||||
quotaResult: Awaited<ReturnType<typeof fetchAllProviderQuotas>>
|
||||
): void {
|
||||
@@ -143,22 +271,32 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR
|
||||
continue;
|
||||
}
|
||||
|
||||
const { fiveHourWindow, weeklyWindow } = getCodexCoreUsageWindows(quota.windows);
|
||||
const coreUsageWindows = [fiveHourWindow, weeklyWindow].filter(
|
||||
(w, index, arr): w is NonNullable<typeof w> => !!w && arr.indexOf(w) === index
|
||||
);
|
||||
const statusWindows = coreUsageWindows.length > 0 ? coreUsageWindows : quota.windows;
|
||||
|
||||
const avgQuota =
|
||||
quota.windows.length > 0
|
||||
? quota.windows.reduce((sum, w) => sum + w.remainingPercent, 0) / quota.windows.length
|
||||
statusWindows.length > 0
|
||||
? statusWindows.reduce((sum, w) => sum + w.remainingPercent, 0) / statusWindows.length
|
||||
: 0;
|
||||
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
|
||||
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
|
||||
|
||||
console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`);
|
||||
|
||||
for (const window of quota.windows) {
|
||||
const orderedWindows = [fiveHourWindow, weeklyWindow, ...quota.windows].filter(
|
||||
(w, index, arr): w is NonNullable<typeof w> => !!w && arr.indexOf(w) === index
|
||||
);
|
||||
|
||||
for (const window of orderedWindows) {
|
||||
const bar = formatQuotaBar(window.remainingPercent);
|
||||
const resetLabel = window.resetAfterSeconds
|
||||
? dim(` Resets ${formatResetTime(window.resetAfterSeconds)}`)
|
||||
: '';
|
||||
console.log(
|
||||
` ${window.label.padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}`
|
||||
` ${getCodexWindowDisplayLabel(window, orderedWindows).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}`
|
||||
);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
@@ -46,6 +46,37 @@ import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Cache only stable failures; avoid pinning transient network failures (timeouts, 429s).
|
||||
*/
|
||||
function shouldCacheCodexQuotaResult(result: CodexQuotaResult): boolean {
|
||||
if (result.success) return true;
|
||||
if (result.needsReauth || result.isForbidden) 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 shouldCacheGeminiQuotaResult(result: GeminiCliQuotaResult): 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;
|
||||
}
|
||||
|
||||
/** Get configured backend from config */
|
||||
function getConfiguredBackend() {
|
||||
try {
|
||||
@@ -548,8 +579,8 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi
|
||||
// Fetch from external API
|
||||
const result = await fetchCodexQuota(accountId);
|
||||
|
||||
// Cache successful results (don't cache errors that need reauth)
|
||||
if (result.success || !result.needsReauth) {
|
||||
// Cache successful and stable failure states; skip transient network failures.
|
||||
if (shouldCacheCodexQuotaResult(result)) {
|
||||
setCachedQuota('codex', accountId, result);
|
||||
}
|
||||
|
||||
@@ -589,8 +620,8 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom
|
||||
// Fetch from external API
|
||||
const result = await fetchGeminiCliQuota(accountId);
|
||||
|
||||
// Cache successful results (don't cache errors that need reauth)
|
||||
if (result.success || !result.needsReauth) {
|
||||
// Cache successful and stable failure states; skip transient network failures.
|
||||
if (shouldCacheGeminiQuotaResult(result)) {
|
||||
setCachedQuota('gemini', accountId, result);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
* Account Card Component for Flow Visualization
|
||||
*/
|
||||
|
||||
import { cn, getProviderMinQuota, getProviderResetTime } from '@/lib/utils';
|
||||
import {
|
||||
cn,
|
||||
getCodexQuotaBreakdown,
|
||||
getProviderMinQuota,
|
||||
getProviderResetTime,
|
||||
isCodexQuotaResult,
|
||||
} from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { GripVertical, Loader2, Pause, Play, KeyRound } from 'lucide-react';
|
||||
import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats';
|
||||
@@ -96,6 +102,14 @@ export function AccountCard({
|
||||
// Use shared helper for provider-specific minimum quota
|
||||
const minQuota = getProviderMinQuota(account.provider, quota);
|
||||
const resetTime = getProviderResetTime(account.provider, quota);
|
||||
const codexBreakdown =
|
||||
account.provider === 'codex' && 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);
|
||||
|
||||
// Tier badge (AGY only) - show P for Pro, U for Ultra
|
||||
const showTierBadge =
|
||||
@@ -229,6 +243,15 @@ export function AccountCard({
|
||||
{minQuota}%
|
||||
</span>
|
||||
</div>
|
||||
{account.provider === 'codex' && codexQuotaRows.length > 0 && (
|
||||
<div className="flex items-center justify-between text-[7px] text-muted-foreground/70">
|
||||
{codexQuotaRows.map((row) => (
|
||||
<span key={row.label}>
|
||||
{row.label} {row.value}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -30,7 +30,13 @@ import {
|
||||
Check,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
import { cn, getProviderMinQuota, getProviderResetTime } from '@/lib/utils';
|
||||
import {
|
||||
cn,
|
||||
getCodexQuotaBreakdown,
|
||||
getProviderMinQuota,
|
||||
getProviderResetTime,
|
||||
isCodexQuotaResult,
|
||||
} from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
||||
@@ -115,6 +121,14 @@ export function AccountItem({
|
||||
// Use shared utility functions for provider-specific quota handling
|
||||
const minQuota = getProviderMinQuota(account.provider, quota);
|
||||
const nextReset = getProviderResetTime(account.provider, quota);
|
||||
const codexBreakdown =
|
||||
account.provider === 'codex' && 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);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -337,14 +351,34 @@ export function AccountItem({
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={Math.max(0, Math.min(100, minQuota))}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(minQuota)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">{minQuota}%</span>
|
||||
</div>
|
||||
{account.provider === 'codex' && codexQuotaRows.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{codexQuotaRows.map((row) => (
|
||||
<div key={row.label} className="flex items-center gap-2">
|
||||
<span className="w-10 text-[10px] text-muted-foreground">
|
||||
{row.label}
|
||||
</span>
|
||||
<Progress
|
||||
value={Math.max(0, Math.min(100, row.value))}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(row.value)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">
|
||||
{row.value}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={Math.max(0, Math.min(100, minQuota))}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(minQuota)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">{minQuota}%</span>
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
{quota && <QuotaTooltipContent quota={quota} resetTime={nextReset} />}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Clock } from 'lucide-react';
|
||||
import {
|
||||
cn,
|
||||
formatResetTime,
|
||||
getCodexQuotaBreakdown,
|
||||
getCodexWindowDisplayLabel,
|
||||
getModelsWithTiers,
|
||||
groupModelsByTier,
|
||||
isAgyQuotaResult,
|
||||
@@ -64,13 +66,29 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
|
||||
// Codex provider tooltip
|
||||
if (isCodexQuotaResult(quota)) {
|
||||
const { fiveHourWindow, weeklyWindow, codeReviewWindows, unknownWindows } =
|
||||
getCodexQuotaBreakdown(quota.windows);
|
||||
const orderedWindows = [fiveHourWindow, weeklyWindow, ...codeReviewWindows, ...unknownWindows]
|
||||
.filter((w): w is NonNullable<typeof w> => !!w)
|
||||
.filter(
|
||||
(w, index, arr) =>
|
||||
arr.findIndex(
|
||||
(candidate) => candidate.label === w.label && candidate.resetAt === w.resetAt
|
||||
) === index
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Rate Limits:</p>
|
||||
{quota.planType && <p className="text-muted-foreground">Plan: {quota.planType}</p>}
|
||||
{quota.windows.map((w) => (
|
||||
<div key={w.label} className="flex justify-between gap-4">
|
||||
<span className={cn(w.remainingPercent < 20 && 'text-red-500')}>{w.label}</span>
|
||||
{orderedWindows.map((w, index) => (
|
||||
<div
|
||||
key={`${w.label}-${w.resetAt ?? 'no-reset'}-${index}`}
|
||||
className="flex justify-between gap-4"
|
||||
>
|
||||
<span className={cn(w.remainingPercent < 20 && 'text-red-500')}>
|
||||
{getCodexWindowDisplayLabel(w, orderedWindows)}
|
||||
</span>
|
||||
<span className="font-mono">{w.remainingPercent}%</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
+187
-2
@@ -306,12 +306,191 @@ export function groupModelsByTier(models: TieredModel[]): Map<ModelTier, TieredM
|
||||
return groups;
|
||||
}
|
||||
|
||||
export type CodexWindowKind =
|
||||
| 'usage-5h'
|
||||
| 'usage-weekly'
|
||||
| 'code-review-5h'
|
||||
| 'code-review-weekly'
|
||||
| 'code-review'
|
||||
| 'unknown';
|
||||
|
||||
/**
|
||||
* Map raw Codex API window labels into semantic buckets.
|
||||
*/
|
||||
export function getCodexWindowKind(label: string): CodexWindowKind {
|
||||
const lower = (label || '').toLowerCase();
|
||||
const isCodeReview = lower.includes('code review') || lower.includes('code_review');
|
||||
const isPrimary = lower.includes('primary');
|
||||
const isSecondary = lower.includes('secondary');
|
||||
|
||||
if (isCodeReview) {
|
||||
if (isPrimary) return 'code-review-5h';
|
||||
if (isSecondary) return 'code-review-weekly';
|
||||
return 'code-review';
|
||||
}
|
||||
|
||||
if (isPrimary) return 'usage-5h';
|
||||
if (isSecondary) return 'usage-weekly';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
type CodexWindowSummary = Pick<CodexQuotaWindow, 'label' | 'resetAfterSeconds'>;
|
||||
|
||||
/**
|
||||
* Infer code-review window cadence by comparing against usage windows.
|
||||
* This keeps labels stable as countdown values decrease over time.
|
||||
*/
|
||||
function inferCodeReviewCadence(
|
||||
window: CodexWindowSummary,
|
||||
allWindows: CodexWindowSummary[]
|
||||
): '5h' | 'weekly' | null {
|
||||
const kind = getCodexWindowKind(window.label);
|
||||
if (kind === 'code-review-weekly') return 'weekly';
|
||||
|
||||
const reset = window.resetAfterSeconds;
|
||||
if (typeof reset !== 'number' || !isFinite(reset) || reset <= 0) return null;
|
||||
|
||||
const usage5h = allWindows.find(
|
||||
(w) =>
|
||||
getCodexWindowKind(w.label) === 'usage-5h' &&
|
||||
typeof w.resetAfterSeconds === 'number' &&
|
||||
isFinite(w.resetAfterSeconds) &&
|
||||
w.resetAfterSeconds > 0
|
||||
);
|
||||
const usageWeekly = allWindows.find(
|
||||
(w) =>
|
||||
getCodexWindowKind(w.label) === 'usage-weekly' &&
|
||||
typeof w.resetAfterSeconds === 'number' &&
|
||||
isFinite(w.resetAfterSeconds) &&
|
||||
w.resetAfterSeconds > 0
|
||||
);
|
||||
|
||||
if (!usage5h || !usageWeekly) return null;
|
||||
|
||||
const diffTo5h = Math.abs(reset - (usage5h.resetAfterSeconds as number));
|
||||
const diffToWeekly = Math.abs(reset - (usageWeekly.resetAfterSeconds as number));
|
||||
return diffToWeekly <= diffTo5h ? 'weekly' : '5h';
|
||||
}
|
||||
|
||||
export function getCodexWindowDisplayLabel(
|
||||
labelOrWindow: string | CodexWindowSummary,
|
||||
allWindows: CodexWindowSummary[] = []
|
||||
): string {
|
||||
const label = typeof labelOrWindow === 'string' ? labelOrWindow : labelOrWindow.label;
|
||||
const currentWindow: CodexWindowSummary =
|
||||
typeof labelOrWindow === 'string'
|
||||
? { label, resetAfterSeconds: null }
|
||||
: { label, resetAfterSeconds: labelOrWindow.resetAfterSeconds };
|
||||
const context = allWindows.length > 0 ? allWindows : [currentWindow];
|
||||
|
||||
switch (getCodexWindowKind(label)) {
|
||||
case 'usage-5h':
|
||||
return '5h usage limit';
|
||||
case 'usage-weekly':
|
||||
return 'Weekly usage limit';
|
||||
case 'code-review-5h':
|
||||
case 'code-review-weekly':
|
||||
case 'code-review': {
|
||||
const inferred = inferCodeReviewCadence(currentWindow, context);
|
||||
if (inferred === '5h') return 'Code review (5h)';
|
||||
if (inferred === 'weekly') return 'Code review (weekly)';
|
||||
return 'Code review';
|
||||
}
|
||||
case 'unknown':
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CodexQuotaBreakdown {
|
||||
fiveHourWindow: CodexQuotaWindow | null;
|
||||
weeklyWindow: CodexQuotaWindow | null;
|
||||
codeReviewWindows: CodexQuotaWindow[];
|
||||
unknownWindows: CodexQuotaWindow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Break down Codex windows into core usage windows (5h + weekly) and auxiliary windows.
|
||||
*/
|
||||
export function getCodexQuotaBreakdown(windows: CodexQuotaWindow[]): CodexQuotaBreakdown {
|
||||
if (!windows || windows.length === 0) {
|
||||
return {
|
||||
fiveHourWindow: null,
|
||||
weeklyWindow: null,
|
||||
codeReviewWindows: [],
|
||||
unknownWindows: [],
|
||||
};
|
||||
}
|
||||
|
||||
let fiveHourWindow: CodexQuotaWindow | null = null;
|
||||
let weeklyWindow: CodexQuotaWindow | null = null;
|
||||
const codeReviewWindows: CodexQuotaWindow[] = [];
|
||||
const unknownWindows: CodexQuotaWindow[] = [];
|
||||
const nonCodeReviewWindows: CodexQuotaWindow[] = [];
|
||||
|
||||
for (const window of windows) {
|
||||
const kind = getCodexWindowKind(window.label);
|
||||
|
||||
switch (kind) {
|
||||
case 'usage-5h':
|
||||
if (!fiveHourWindow) fiveHourWindow = window;
|
||||
nonCodeReviewWindows.push(window);
|
||||
break;
|
||||
case 'usage-weekly':
|
||||
if (!weeklyWindow) weeklyWindow = window;
|
||||
nonCodeReviewWindows.push(window);
|
||||
break;
|
||||
case 'code-review-5h':
|
||||
case 'code-review-weekly':
|
||||
case 'code-review':
|
||||
codeReviewWindows.push(window);
|
||||
break;
|
||||
case 'unknown':
|
||||
unknownWindows.push(window);
|
||||
nonCodeReviewWindows.push(window);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for API label changes: infer 5h/weekly from reset horizon when explicit labels are absent.
|
||||
if ((!fiveHourWindow || !weeklyWindow) && nonCodeReviewWindows.length > 0) {
|
||||
const withReset = nonCodeReviewWindows
|
||||
.filter((w) => typeof w.resetAfterSeconds === 'number' && w.resetAfterSeconds >= 0)
|
||||
.sort((a, b) => (a.resetAfterSeconds || 0) - (b.resetAfterSeconds || 0));
|
||||
|
||||
if (!fiveHourWindow) {
|
||||
fiveHourWindow = withReset[0] || nonCodeReviewWindows[0] || null;
|
||||
}
|
||||
|
||||
if (!weeklyWindow) {
|
||||
weeklyWindow =
|
||||
withReset.length > 1
|
||||
? withReset[withReset.length - 1]
|
||||
: nonCodeReviewWindows.find((w) => w !== fiveHourWindow) || null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fiveHourWindow,
|
||||
weeklyWindow,
|
||||
codeReviewWindows,
|
||||
unknownWindows,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum remaining percentage across Codex rate limit windows
|
||||
*/
|
||||
export function getMinCodexQuota(windows: CodexQuotaWindow[]): number | null {
|
||||
if (!windows || windows.length === 0) return null;
|
||||
const percentages = windows.map((w) => w.remainingPercent);
|
||||
|
||||
const { fiveHourWindow, weeklyWindow } = getCodexQuotaBreakdown(windows);
|
||||
const usageWindows = [fiveHourWindow, weeklyWindow].filter(
|
||||
(w, index, arr): w is CodexQuotaWindow => !!w && arr.indexOf(w) === index
|
||||
);
|
||||
|
||||
// Primary account quota should be driven by core usage windows, not code-review windows.
|
||||
const sourceWindows = usageWindows.length > 0 ? usageWindows : windows;
|
||||
const percentages = sourceWindows.map((w) => w.remainingPercent);
|
||||
return Math.min(...percentages);
|
||||
}
|
||||
|
||||
@@ -320,7 +499,13 @@ export function getMinCodexQuota(windows: CodexQuotaWindow[]): number | null {
|
||||
*/
|
||||
export function getCodexResetTime(windows: CodexQuotaWindow[]): string | null {
|
||||
if (!windows || windows.length === 0) return null;
|
||||
const resets = windows.map((w) => w.resetAt).filter((t): t is string => t !== null);
|
||||
|
||||
const { fiveHourWindow, weeklyWindow } = getCodexQuotaBreakdown(windows);
|
||||
const usageWindows = [fiveHourWindow, weeklyWindow].filter(
|
||||
(w, index, arr): w is CodexQuotaWindow => !!w && arr.indexOf(w) === index
|
||||
);
|
||||
const sourceWindows = usageWindows.length > 0 ? usageWindows : windows;
|
||||
const resets = sourceWindows.map((w) => w.resetAt).filter((t): t is string => t !== null);
|
||||
if (resets.length === 0) return null;
|
||||
return resets.sort()[0];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getEarliestResetTime,
|
||||
getMinCodexQuota,
|
||||
getCodexResetTime,
|
||||
getCodexWindowDisplayLabel,
|
||||
getMinGeminiQuota,
|
||||
getGeminiResetTime,
|
||||
getProviderMinQuota,
|
||||
@@ -386,7 +387,8 @@ describe('getMinCodexQuota', () => {
|
||||
resetAt: '2026-01-30T14:30:32Z',
|
||||
},
|
||||
];
|
||||
expect(getMinCodexQuota(windows)).toBe(8.7);
|
||||
// Core account quota should ignore code-review windows.
|
||||
expect(getMinCodexQuota(windows)).toBe(21.1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -439,10 +441,10 @@ describe('getCodexResetTime', () => {
|
||||
usedPercent: 50,
|
||||
remainingPercent: 50,
|
||||
resetAfterSeconds: 1800,
|
||||
resetAt: '2026-01-30T16:00:00Z',
|
||||
resetAt: '2026-01-30T09:00:00Z',
|
||||
},
|
||||
];
|
||||
// Should return earliest (alphabetically sorted)
|
||||
// Should ignore code-review reset when choosing main account reset.
|
||||
expect(getCodexResetTime(windows)).toBe('2026-01-30T10:00:00Z');
|
||||
});
|
||||
});
|
||||
@@ -516,14 +518,64 @@ describe('getCodexResetTime', () => {
|
||||
usedPercent: 75,
|
||||
remainingPercent: 25,
|
||||
resetAfterSeconds: 5400,
|
||||
resetAt: '2026-01-30T18:45:00Z',
|
||||
resetAt: '2026-01-30T08:15:00Z',
|
||||
},
|
||||
];
|
||||
// Code-review windows should not drive the main account reset.
|
||||
expect(getCodexResetTime(windows)).toBe('2026-01-30T09:30:00Z');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCodexWindowDisplayLabel', () => {
|
||||
it('labels code review primary as weekly when it matches usage weekly window', () => {
|
||||
const windows: Array<{ label: string; resetAfterSeconds: number | null }> = [
|
||||
{ label: 'Primary', resetAfterSeconds: 18000 },
|
||||
{ label: 'Secondary', resetAfterSeconds: 604800 },
|
||||
{ label: 'Code Review (Primary)', resetAfterSeconds: 600000 },
|
||||
];
|
||||
expect(
|
||||
getCodexWindowDisplayLabel(
|
||||
{
|
||||
label: 'Code Review (Primary)',
|
||||
resetAfterSeconds: 600000,
|
||||
},
|
||||
windows
|
||||
)
|
||||
).toBe('Code review (weekly)');
|
||||
});
|
||||
|
||||
it('labels code review primary as 5h when it matches usage 5h window', () => {
|
||||
const windows: Array<{ label: string; resetAfterSeconds: number | null }> = [
|
||||
{ label: 'Primary', resetAfterSeconds: 18000 },
|
||||
{ label: 'Secondary', resetAfterSeconds: 604800 },
|
||||
{ label: 'Code Review (Primary)', resetAfterSeconds: 17000 },
|
||||
];
|
||||
expect(
|
||||
getCodexWindowDisplayLabel(
|
||||
{
|
||||
label: 'Code Review (Primary)',
|
||||
resetAfterSeconds: 17000,
|
||||
},
|
||||
windows
|
||||
)
|
||||
).toBe('Code review (5h)');
|
||||
});
|
||||
|
||||
it('keeps secondary usage label as weekly by default', () => {
|
||||
expect(getCodexWindowDisplayLabel('Secondary')).toBe('Weekly usage limit');
|
||||
});
|
||||
|
||||
it('falls back to generic code review label when cadence cannot be inferred', () => {
|
||||
expect(
|
||||
getCodexWindowDisplayLabel({
|
||||
label: 'Code Review (Primary)',
|
||||
resetAfterSeconds: 604800,
|
||||
})
|
||||
).toBe('Code review');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Gemini Quota Functions ====================
|
||||
|
||||
describe('getMinGeminiQuota', () => {
|
||||
|
||||
Reference in New Issue
Block a user