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/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index c1dfa9a3..f426e490 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -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; + +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> ): 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 => !!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, orderedWindows).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` ); } console.log(''); 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); } 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..492924c3 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, orderedWindows)} + {w.remainingPercent}%
))} diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 6c2df585..2c7edc70 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -306,12 +306,191 @@ export function groupModelsByTier(models: TieredModel[]): Map; + +/** + * 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]; } diff --git a/ui/tests/unit/ui/lib/quota-utils.test.ts b/ui/tests/unit/ui/lib/quota-utils.test.ts index a1f1c1e4..8efdf579 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, @@ -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', () => {