From f8af5a8c3cb9e2c0de09f0a1ffde942c642cdca0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 14 Feb 2026 05:57:06 +0700 Subject: [PATCH 1/4] fix(codex): align quota display to 5h and weekly windows --- src/commands/cliproxy/quota-subcommand.ts | 105 ++++++++++++- .../account/flow-viz/account-card.tsx | 25 ++- .../cliproxy/provider-editor/account-item.tsx | 52 +++++-- .../shared/quota-tooltip-content.tsx | 24 ++- ui/src/lib/utils.ts | 142 +++++++++++++++++- ui/tests/unit/ui/lib/quota-utils.test.ts | 10 +- 6 files changed, 335 insertions(+), 23 deletions(-) diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index c1dfa9a3..38a09c43 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -78,6 +78,93 @@ 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'; +} + +function getCodexWindowDisplayLabel(label: string): string { + switch (getCodexWindowKind(label)) { + case 'usage-5h': + return '5h usage limit'; + case 'usage-weekly': + return 'Weekly usage limit'; + case 'code-review-5h': + return 'Code review (5h)'; + case 'code-review-weekly': + return 'Code review (weekly)'; + case 'code-review': + return 'Code review'; + case 'unknown': + return 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> ): void { @@ -143,22 +230,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 => !!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 => !!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.label).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` ); } console.log(''); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index ba990e63..6e71c344 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -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}% + {account.provider === 'codex' && codexQuotaRows.length > 0 && ( +
+ {codexQuotaRows.map((row) => ( + + {row.label} {row.value}% + + ))} +
+ )}
row.value !== null); return (
-
- - {minQuota}% -
+ {account.provider === 'codex' && codexQuotaRows.length > 0 ? ( +
+ {codexQuotaRows.map((row) => ( +
+ + {row.label} + + + + {row.value}% + +
+ ))} +
+ ) : ( +
+ + {minQuota}% +
+ )}
{quota && } diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 177394e8..01826405 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -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 => !!w) + .filter( + (w, index, arr) => + arr.findIndex( + (candidate) => candidate.label === w.label && candidate.resetAt === w.resetAt + ) === index + ); + return (

Rate Limits:

{quota.planType &&

Plan: {quota.planType}

} - {quota.windows.map((w) => ( -
- {w.label} + {orderedWindows.map((w, index) => ( +
+ + {getCodexWindowDisplayLabel(w.label)} + {w.remainingPercent}%
))} diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 6c2df585..9959eb71 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -306,12 +306,144 @@ export function groupModelsByTier(models: TieredModel[]): Map 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 +452,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]; } diff --git a/ui/tests/unit/ui/lib/quota-utils.test.ts b/ui/tests/unit/ui/lib/quota-utils.test.ts index a1f1c1e4..7735c853 100644 --- a/ui/tests/unit/ui/lib/quota-utils.test.ts +++ b/ui/tests/unit/ui/lib/quota-utils.test.ts @@ -386,7 +386,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 +440,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,9 +517,10 @@ 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'); }); }); From 1d2ee827fe38204d8d4ceb376c604abcff130836 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 14 Feb 2026 06:09:36 +0700 Subject: [PATCH 2/4] fix(codex): infer code review cadence from reset window --- src/commands/cliproxy/quota-subcommand.ts | 34 ++++++++++++++++--- .../shared/quota-tooltip-content.tsx | 2 +- ui/src/lib/utils.ts | 32 ++++++++++++++++- ui/tests/unit/ui/lib/quota-utils.test.ts | 29 ++++++++++++++++ 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index 38a09c43..683e863a 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -103,20 +103,46 @@ function getCodexWindowKind(label: string): CodexWindowKind { return 'unknown'; } -function getCodexWindowDisplayLabel(label: string): string { - switch (getCodexWindowKind(label)) { +function getCodexWindowCadence( + resetAfterSeconds: number | null | undefined +): '5h' | 'weekly' | null { + if ( + typeof resetAfterSeconds !== 'number' || + !isFinite(resetAfterSeconds) || + resetAfterSeconds <= 0 + ) { + return null; + } + + if (resetAfterSeconds <= 6 * 60 * 60) return '5h'; + if (resetAfterSeconds >= 24 * 60 * 60) return 'weekly'; + return null; +} + +function getCodexWindowDisplayLabel( + window: Pick +): string { + const cadence = getCodexWindowCadence(window.resetAfterSeconds); + + switch (getCodexWindowKind(window.label)) { case 'usage-5h': + if (cadence === 'weekly') return 'Weekly usage limit'; return '5h usage limit'; case 'usage-weekly': + if (cadence === '5h') return '5h usage limit'; return 'Weekly usage limit'; case 'code-review-5h': + if (cadence === 'weekly') return 'Code review (weekly)'; return 'Code review (5h)'; case 'code-review-weekly': + if (cadence === '5h') return 'Code review (5h)'; return 'Code review (weekly)'; case 'code-review': + if (cadence === '5h') return 'Code review (5h)'; + if (cadence === 'weekly') return 'Code review (weekly)'; return 'Code review'; case 'unknown': - return label; + return window.label; } } @@ -255,7 +281,7 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR ? dim(` Resets ${formatResetTime(window.resetAfterSeconds)}`) : ''; console.log( - ` ${getCodexWindowDisplayLabel(window.label).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` + ` ${getCodexWindowDisplayLabel(window).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` ); } console.log(''); diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 01826405..27ff1c3a 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -87,7 +87,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro className="flex justify-between gap-4" > - {getCodexWindowDisplayLabel(w.label)} + {getCodexWindowDisplayLabel(w)} {w.remainingPercent}%
diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 9959eb71..81387cea 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -337,17 +337,47 @@ export function getCodexWindowKind(label: string): CodexWindowKind { /** * Convert raw Codex window labels into user-facing labels. */ -export function getCodexWindowDisplayLabel(label: string): string { +function getCodexWindowCadence( + resetAfterSeconds: number | null | undefined +): '5h' | 'weekly' | null { + if ( + typeof resetAfterSeconds !== 'number' || + !isFinite(resetAfterSeconds) || + resetAfterSeconds <= 0 + ) { + return null; + } + + if (resetAfterSeconds <= 6 * 60 * 60) return '5h'; + if (resetAfterSeconds >= 24 * 60 * 60) return 'weekly'; + return null; +} + +export function getCodexWindowDisplayLabel( + labelOrWindow: string | Pick, + resetAfterSecondsOverride?: number | null +): string { + const label = typeof labelOrWindow === 'string' ? labelOrWindow : labelOrWindow.label; + const cadence = getCodexWindowCadence( + typeof labelOrWindow === 'string' ? resetAfterSecondsOverride : labelOrWindow.resetAfterSeconds + ); + switch (getCodexWindowKind(label)) { case 'usage-5h': + if (cadence === 'weekly') return 'Weekly usage limit'; return '5h usage limit'; case 'usage-weekly': + if (cadence === '5h') return '5h usage limit'; return 'Weekly usage limit'; case 'code-review-5h': + if (cadence === 'weekly') return 'Code review (weekly)'; return 'Code review (5h)'; case 'code-review-weekly': + if (cadence === '5h') return 'Code review (5h)'; return 'Code review (weekly)'; case 'code-review': + if (cadence === '5h') return 'Code review (5h)'; + if (cadence === 'weekly') return 'Code review (weekly)'; return 'Code review'; case 'unknown': return label; diff --git a/ui/tests/unit/ui/lib/quota-utils.test.ts b/ui/tests/unit/ui/lib/quota-utils.test.ts index 7735c853..338d5c17 100644 --- a/ui/tests/unit/ui/lib/quota-utils.test.ts +++ b/ui/tests/unit/ui/lib/quota-utils.test.ts @@ -9,6 +9,7 @@ import { getEarliestResetTime, getMinCodexQuota, getCodexResetTime, + getCodexWindowDisplayLabel, getMinGeminiQuota, getGeminiResetTime, getProviderMinQuota, @@ -526,6 +527,34 @@ describe('getCodexResetTime', () => { }); }); +describe('getCodexWindowDisplayLabel', () => { + it('labels code review primary window as weekly when reset cadence is weekly', () => { + expect( + getCodexWindowDisplayLabel({ + label: 'Code Review (Primary)', + resetAfterSeconds: 604800, + }) + ).toBe('Code review (weekly)'); + }); + + it('labels code review primary window as 5h when reset cadence is short', () => { + expect( + getCodexWindowDisplayLabel({ + label: 'Code Review (Primary)', + resetAfterSeconds: 18000, + }) + ).toBe('Code review (5h)'); + }); + + it('keeps secondary usage label as weekly by default', () => { + expect(getCodexWindowDisplayLabel('Secondary')).toBe('Weekly usage limit'); + }); + + it('uses cadence override when provided with string label', () => { + expect(getCodexWindowDisplayLabel('Primary', 604800)).toBe('Weekly usage limit'); + }); +}); + // ==================== Gemini Quota Functions ==================== describe('getMinGeminiQuota', () => { From b3d9dce6e1e7b631d24bb98111a14b239074db2b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 14 Feb 2026 06:23:31 +0700 Subject: [PATCH 3/4] fix(codex): reduce quota timeout flakes in dashboard --- src/cliproxy/quota-fetcher-codex.ts | 208 ++++++++++-------- .../routes/cliproxy-stats-routes.ts | 39 +++- 2 files changed, 150 insertions(+), 97 deletions(-) diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 878dbe2d..88ac119a 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -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, + }; } /** diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index bae7f14c..4fd41249 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -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); } From 40512fe3388e0e517838926438c8ae4559cc09d3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 14 Feb 2026 06:28:48 +0700 Subject: [PATCH 4/4] fix(codex): stabilize code review window labeling --- src/commands/cliproxy/quota-subcommand.ts | 65 ++++++++++------ .../shared/quota-tooltip-content.tsx | 2 +- ui/src/lib/utils.ts | 77 +++++++++++-------- ui/tests/unit/ui/lib/quota-utils.test.ts | 45 ++++++++--- 4 files changed, 121 insertions(+), 68 deletions(-) diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index 683e863a..f426e490 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -103,44 +103,59 @@ function getCodexWindowKind(label: string): CodexWindowKind { return 'unknown'; } -function getCodexWindowCadence( - resetAfterSeconds: number | null | undefined -): '5h' | 'weekly' | null { - if ( - typeof resetAfterSeconds !== 'number' || - !isFinite(resetAfterSeconds) || - resetAfterSeconds <= 0 - ) { - return null; - } +type CodexWindowSummary = Pick; - if (resetAfterSeconds <= 6 * 60 * 60) return '5h'; - if (resetAfterSeconds >= 24 * 60 * 60) return 'weekly'; - return null; +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: Pick + window: CodexWindowSummary, + allWindows: CodexWindowSummary[] = [] ): string { - const cadence = getCodexWindowCadence(window.resetAfterSeconds); + const context = allWindows.length > 0 ? allWindows : [window]; switch (getCodexWindowKind(window.label)) { case 'usage-5h': - if (cadence === 'weekly') return 'Weekly usage limit'; return '5h usage limit'; case 'usage-weekly': - if (cadence === '5h') return '5h usage limit'; return 'Weekly usage limit'; case 'code-review-5h': - if (cadence === 'weekly') return 'Code review (weekly)'; - return 'Code review (5h)'; case 'code-review-weekly': - if (cadence === '5h') return 'Code review (5h)'; - return 'Code review (weekly)'; - case 'code-review': - if (cadence === '5h') return 'Code review (5h)'; - if (cadence === 'weekly') return '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; } @@ -281,7 +296,7 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR ? dim(` Resets ${formatResetTime(window.resetAfterSeconds)}`) : ''; console.log( - ` ${getCodexWindowDisplayLabel(window).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` + ` ${getCodexWindowDisplayLabel(window, orderedWindows).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` ); } console.log(''); diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 27ff1c3a..492924c3 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -87,7 +87,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro className="flex justify-between gap-4" > - {getCodexWindowDisplayLabel(w)} + {getCodexWindowDisplayLabel(w, orderedWindows)} {w.remainingPercent}%
diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 81387cea..2c7edc70 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -334,51 +334,68 @@ export function getCodexWindowKind(label: string): CodexWindowKind { return 'unknown'; } -/** - * Convert raw Codex window labels into user-facing labels. - */ -function getCodexWindowCadence( - resetAfterSeconds: number | null | undefined -): '5h' | 'weekly' | null { - if ( - typeof resetAfterSeconds !== 'number' || - !isFinite(resetAfterSeconds) || - resetAfterSeconds <= 0 - ) { - return null; - } +type CodexWindowSummary = Pick; - if (resetAfterSeconds <= 6 * 60 * 60) return '5h'; - if (resetAfterSeconds >= 24 * 60 * 60) return 'weekly'; - return null; +/** + * 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 | Pick, - resetAfterSecondsOverride?: number | null + labelOrWindow: string | CodexWindowSummary, + allWindows: CodexWindowSummary[] = [] ): string { const label = typeof labelOrWindow === 'string' ? labelOrWindow : labelOrWindow.label; - const cadence = getCodexWindowCadence( - typeof labelOrWindow === 'string' ? resetAfterSecondsOverride : labelOrWindow.resetAfterSeconds - ); + 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': - if (cadence === 'weekly') return 'Weekly usage limit'; return '5h usage limit'; case 'usage-weekly': - if (cadence === '5h') return '5h usage limit'; return 'Weekly usage limit'; case 'code-review-5h': - if (cadence === 'weekly') return 'Code review (weekly)'; - return 'Code review (5h)'; case 'code-review-weekly': - if (cadence === '5h') return 'Code review (5h)'; - return 'Code review (weekly)'; - case 'code-review': - if (cadence === '5h') return 'Code review (5h)'; - if (cadence === 'weekly') return '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; } diff --git a/ui/tests/unit/ui/lib/quota-utils.test.ts b/ui/tests/unit/ui/lib/quota-utils.test.ts index 338d5c17..8efdf579 100644 --- a/ui/tests/unit/ui/lib/quota-utils.test.ts +++ b/ui/tests/unit/ui/lib/quota-utils.test.ts @@ -528,21 +528,37 @@ describe('getCodexResetTime', () => { }); describe('getCodexWindowDisplayLabel', () => { - it('labels code review primary window as weekly when reset cadence is weekly', () => { + 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: 604800, - }) + getCodexWindowDisplayLabel( + { + label: 'Code Review (Primary)', + resetAfterSeconds: 600000, + }, + windows + ) ).toBe('Code review (weekly)'); }); - it('labels code review primary window as 5h when reset cadence is short', () => { + 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: 18000, - }) + getCodexWindowDisplayLabel( + { + label: 'Code Review (Primary)', + resetAfterSeconds: 17000, + }, + windows + ) ).toBe('Code review (5h)'); }); @@ -550,8 +566,13 @@ describe('getCodexWindowDisplayLabel', () => { expect(getCodexWindowDisplayLabel('Secondary')).toBe('Weekly usage limit'); }); - it('uses cadence override when provided with string label', () => { - expect(getCodexWindowDisplayLabel('Primary', 604800)).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'); }); });