diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 811be609..c0c4940b 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -39,8 +39,10 @@ interface CodexUsageResponse { planType?: string; rate_limit?: CodexRateLimitWindow; rateLimit?: CodexRateLimitWindow; - code_review_rate_limit?: CodexRateLimitWindow; - codeReviewRateLimit?: CodexRateLimitWindow; + code_review_rate_limit?: CodexRateLimitWindow | null; + codeReviewRateLimit?: CodexRateLimitWindow | null; + additional_rate_limits?: CodexAdditionalRateLimit[] | null; + additionalRateLimits?: CodexAdditionalRateLimit[] | null; } /** Rate limit window from API */ @@ -51,6 +53,19 @@ interface CodexRateLimitWindow { secondaryWindow?: CodexWindowData; } +/** + * Additional rate limit entry from API (introduced for features like GPT-5.3 Codex Spark). + * Each entry surfaces its own primary/secondary windows under a feature-specific limit name. + */ +interface CodexAdditionalRateLimit { + limit_name?: string; + limitName?: string; + metered_feature?: string; + meteredFeature?: string; + rate_limit?: CodexRateLimitWindow; + rateLimit?: CodexRateLimitWindow; +} + /** Individual window data */ interface CodexWindowData { used_percent?: number; @@ -92,7 +107,11 @@ function getCodexWindowKind(label: string): CodexWindowKind { function getUnknownCodexWindowLabels(windows: CodexQuotaWindow[]): string[] { const unknownLabels = windows - .filter((window) => getCodexWindowKind(window.label) === 'unknown') + .filter((window) => { + // Windows with explicit category metadata are always classified. + if (window.category) return false; + return getCodexWindowKind(window.label) === 'unknown'; + }) .map((window) => window.label) .filter((label): label is string => typeof label === 'string' && label.trim().length > 0); return Array.from(new Set(unknownLabels)); @@ -106,7 +125,8 @@ function shouldLogCodexWindowWarnings(verbose: boolean): boolean { /** * Build explicit 5h + weekly usage summary from raw Codex windows. - * Falls back to shortest/longest reset windows if API labels change. + * Prefers explicit `category`/`cadence` metadata when present. + * Falls back to label sniffing for legacy cached windows. */ export function buildCodexCoreUsageSummary(windows: CodexQuotaWindow[]): CodexCoreUsageSummary { if (!windows || windows.length === 0) { @@ -117,20 +137,36 @@ export function buildCodexCoreUsageSummary(windows: CodexQuotaWindow[]): CodexCo let weeklyWindow: CodexQuotaWindow | null = null; const nonCodeReviewWindows: CodexQuotaWindow[] = []; - for (const window of windows) { - const kind = getCodexWindowKind(window.label); - if (kind === 'usage-5h') { - if (!fiveHourWindow) fiveHourWindow = window; - nonCodeReviewWindows.push(window); - continue; + // Determine if any window carries category metadata. If so, prefer category-based + // selection so 'additional' windows (e.g. Spark) do not pollute the main usage summary. + const hasCategoryMetadata = windows.some((window) => Boolean(window.category)); + + if (hasCategoryMetadata) { + for (const window of windows) { + if (window.category === 'usage') { + if (window.cadence === '5h' && !fiveHourWindow) fiveHourWindow = window; + else if (window.cadence === 'weekly' && !weeklyWindow) weeklyWindow = window; + nonCodeReviewWindows.push(window); + } + // 'code-review' and 'additional' windows are intentionally excluded from the main + // usage summary — they represent feature-specific quotas, not core usage. } - if (kind === 'usage-weekly') { - if (!weeklyWindow) weeklyWindow = window; - nonCodeReviewWindows.push(window); - continue; - } - if (kind === 'unknown') { - nonCodeReviewWindows.push(window); + } else { + 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); + } } } @@ -270,9 +306,18 @@ function buildCodexQuotaWindows(payload: CodexUsageResponse): CodexQuotaWindow[] // Get rate limit object (handles both cases) const rateLimit = payload.rate_limit || payload.rateLimit; const codeReviewRateLimit = payload.code_review_rate_limit || payload.codeReviewRateLimit; + const additionalRateLimits = payload.additional_rate_limits || payload.additionalRateLimits; // Helper to extract window data - const addWindow = (label: string, windowData: CodexWindowData | undefined): void => { + const addWindow = ( + label: string, + windowData: CodexWindowData | undefined, + meta: { + category: NonNullable; + cadence: NonNullable; + featureLabel?: string; + } + ): void => { if (!windowData) return; // Clamp usedPercent to [0, 100] range @@ -287,33 +332,68 @@ function buildCodexQuotaWindows(payload: CodexUsageResponse): CodexQuotaWindow[] resetAt = new Date(Date.now() + resetAfterSeconds * 1000).toISOString(); } - windows.push({ + const window: CodexQuotaWindow = { label, usedPercent, remainingPercent: Math.max(0, 100 - usedPercent), resetAfterSeconds, resetAt, - }); + category: meta.category, + cadence: meta.cadence, + }; + if (meta.featureLabel) { + window.featureLabel = meta.featureLabel; + } + windows.push(window); }; // Add main rate limit windows if (rateLimit) { - addWindow('Primary', rateLimit.primary_window || rateLimit.primaryWindow); - addWindow('Secondary', rateLimit.secondary_window || rateLimit.secondaryWindow); + addWindow('Primary', rateLimit.primary_window || rateLimit.primaryWindow, { + category: 'usage', + cadence: '5h', + }); + addWindow('Secondary', rateLimit.secondary_window || rateLimit.secondaryWindow, { + category: 'usage', + cadence: 'weekly', + }); } // Add code review rate limit windows if (codeReviewRateLimit) { addWindow( 'Code Review (Primary)', - codeReviewRateLimit.primary_window || codeReviewRateLimit.primaryWindow + codeReviewRateLimit.primary_window || codeReviewRateLimit.primaryWindow, + { category: 'code-review', cadence: '5h', featureLabel: 'Code Review' } ); addWindow( 'Code Review (Secondary)', - codeReviewRateLimit.secondary_window || codeReviewRateLimit.secondaryWindow + codeReviewRateLimit.secondary_window || codeReviewRateLimit.secondaryWindow, + { category: 'code-review', cadence: 'weekly', featureLabel: 'Code Review' } ); } + // Add additional rate limit windows (e.g. GPT-5.3 Codex Spark) + if (Array.isArray(additionalRateLimits)) { + for (const entry of additionalRateLimits) { + if (!entry) continue; + const entryRateLimit = entry.rate_limit || entry.rateLimit; + if (!entryRateLimit) continue; + + const featureLabel = entry.limit_name || entry.limitName || 'Additional'; + addWindow( + `${featureLabel} (Primary)`, + entryRateLimit.primary_window || entryRateLimit.primaryWindow, + { category: 'additional', cadence: '5h', featureLabel } + ); + addWindow( + `${featureLabel} (Secondary)`, + entryRateLimit.secondary_window || entryRateLimit.secondaryWindow, + { category: 'additional', cadence: 'weekly', featureLabel } + ); + } + } + return windows; } diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index e768ec9b..f8a9ba66 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -29,10 +29,10 @@ export interface QuotaErrorMetadata { } /** - * Codex quota window (primary, secondary, code review) + * Codex quota window (primary, secondary, code review, additional) */ export interface CodexQuotaWindow { - /** Window label: "Primary", "Secondary", "Code Review (Primary)", "Code Review (Secondary)" */ + /** Window label: "Primary", "Secondary", "Code Review (Primary)", "Code Review (Secondary)", or " (Primary|Secondary)" */ label: string; /** Percentage used (0-100) */ usedPercent: number; @@ -42,6 +42,18 @@ export interface CodexQuotaWindow { resetAfterSeconds: number | null; /** ISO timestamp when quota resets, null if unknown */ resetAt: string | null; + /** + * Window category indicating the bucket this window belongs to. + * Optional for back-compat with cached data emitted before this field existed. + * - 'usage' -> standard rate_limit usage windows + * - 'code-review' -> code_review_rate_limit windows + * - 'additional' -> additional_rate_limits[] windows (e.g. GPT-5.3 Codex Spark) + */ + category?: 'usage' | 'code-review' | 'additional'; + /** Cadence of the window: '5h' = primary, 'weekly' = secondary. Optional for legacy data. */ + cadence?: '5h' | 'weekly'; + /** Raw upstream label (e.g. 'GPT-5.3-Codex-Spark', 'Code Review'); absent for plain usage windows. */ + featureLabel?: string; } /** Core Codex usage window (5h/weekly) extracted from raw windows */ diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index 5df3faaf..567a3ccb 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -238,7 +238,10 @@ function getCodexWindowKind(label: string): CodexWindowKind { return 'unknown'; } -type CodexWindowSummary = Pick; +type CodexWindowSummary = Pick< + CodexQuotaResult['windows'][number], + 'label' | 'resetAfterSeconds' | 'category' | 'cadence' | 'featureLabel' +>; function inferCodeReviewCadence( window: CodexWindowSummary, @@ -272,12 +275,46 @@ function inferCodeReviewCadence( return diffToWeekly <= diffTo5h ? 'weekly' : '5h'; } +/** + * Strip a leading "GPT-X.Y-Codex-" prefix from a feature label and turn the + * remainder into a Codex-prefixed display name. Other labels pass through unchanged. + */ +function prettifyCodexFeatureLabel(featureLabel: string): string { + const trimmed = featureLabel.trim(); + if (!trimmed) return 'Additional'; + const stripped = trimmed.replace(/^GPT-[\d.]+-Codex-/i, ''); + if (stripped !== trimmed && stripped.length > 0) { + return `Codex ${stripped}`; + } + return trimmed; +} + function getCodexWindowDisplayLabel( window: CodexWindowSummary, allWindows: CodexWindowSummary[] = [] ): string { const context = allWindows.length > 0 ? allWindows : [window]; + // Prefer explicit category metadata when present (post-2026-04 windows). + if (window.category === 'usage') { + if (window.cadence === '5h') return '5h usage limit'; + if (window.cadence === 'weekly') return 'Weekly usage limit'; + } + + if (window.category === 'additional') { + const pretty = prettifyCodexFeatureLabel(window.featureLabel || window.label || 'Additional'); + if (window.cadence === '5h') return `${pretty} (5h)`; + if (window.cadence === 'weekly') return `${pretty} (weekly)`; + return pretty; + } + + if (window.category === 'code-review') { + if (window.cadence === '5h') return 'Code review (5h)'; + if (window.cadence === 'weekly') return 'Code review (weekly)'; + return 'Code review'; + } + + // Legacy fallback: classify via label sniffing for cached windows without metadata. switch (getCodexWindowKind(window.label)) { case 'usage-5h': return '5h usage limit'; @@ -304,20 +341,35 @@ function getCodexCoreUsageWindows(windows: CodexQuotaResult['windows']): { 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; + // Prefer explicit category metadata when present so 'additional' windows + // (e.g. GPT-5.3 Codex Spark) do not displace core usage windows in the summary. + const hasCategoryMetadata = windows.some((window) => Boolean(window.category)); + + if (hasCategoryMetadata) { + for (const window of windows) { + if (window.category === 'usage') { + if (window.cadence === '5h' && !fiveHourWindow) fiveHourWindow = window; + else if (window.cadence === 'weekly' && !weeklyWindow) weeklyWindow = window; + nonCodeReviewWindows.push(window); + } + // 'code-review' and 'additional' are excluded from the core usage summary. } - if (kind === 'usage-weekly') { - if (!weeklyWindow) weeklyWindow = window; - nonCodeReviewWindows.push(window); - continue; - } - if (kind === 'unknown') { - nonCodeReviewWindows.push(window); + } else { + 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); + } } } diff --git a/tests/unit/cliproxy/quota-fetcher-codex.test.ts b/tests/unit/cliproxy/quota-fetcher-codex.test.ts index af6bc479..4bf3e689 100644 --- a/tests/unit/cliproxy/quota-fetcher-codex.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-codex.test.ts @@ -246,6 +246,122 @@ describe('Codex Quota Fetcher', () => { expect(windows[0].usedPercent).toBe(0); expect(windows[0].remainingPercent).toBe(100); }); + + it('should attach category and cadence metadata to standard usage windows', () => { + const response = { + rate_limit: { + primary_window: { used_percent: 5, reset_after_seconds: 18000 }, + secondary_window: { used_percent: 25, reset_after_seconds: 604800 }, + }, + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(2); + expect(windows[0].category).toBe('usage'); + expect(windows[0].cadence).toBe('5h'); + expect(windows[0].featureLabel).toBeUndefined(); + expect(windows[1].category).toBe('usage'); + expect(windows[1].cadence).toBe('weekly'); + }); + + it('should mark code review windows with code-review category and Code Review feature label', () => { + const response = { + code_review_rate_limit: { + primary_window: { used_percent: 12, reset_after_seconds: 1800 }, + secondary_window: { used_percent: 60, reset_after_seconds: 604800 }, + }, + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(2); + expect(windows[0].category).toBe('code-review'); + expect(windows[0].cadence).toBe('5h'); + expect(windows[0].featureLabel).toBe('Code Review'); + expect(windows[1].category).toBe('code-review'); + expect(windows[1].cadence).toBe('weekly'); + expect(windows[1].featureLabel).toBe('Code Review'); + }); + + it('should parse additional_rate_limits entries (e.g. GPT-5.3 Codex Spark)', () => { + const response = { + rate_limit: { + primary_window: { used_percent: 0, reset_after_seconds: 18000 }, + secondary_window: { used_percent: 1, reset_after_seconds: 254493 }, + }, + code_review_rate_limit: null, + additional_rate_limits: [ + { + limit_name: 'GPT-5.3-Codex-Spark', + metered_feature: 'codex_bengalfox', + rate_limit: { + primary_window: { used_percent: 0, reset_after_seconds: 18000 }, + secondary_window: { used_percent: 1, reset_after_seconds: 254493 }, + }, + }, + ], + }; + + const windows = buildCodexQuotaWindows(response); + + // 2 standard usage windows + 2 additional windows. + expect(windows).toHaveLength(4); + + const additionalWindows = windows.filter((w) => w.category === 'additional'); + expect(additionalWindows).toHaveLength(2); + + const sparkPrimary = additionalWindows.find((w) => w.cadence === '5h'); + const sparkSecondary = additionalWindows.find((w) => w.cadence === 'weekly'); + + expect(sparkPrimary).toBeDefined(); + expect(sparkPrimary?.featureLabel).toBe('GPT-5.3-Codex-Spark'); + expect(sparkPrimary?.label).toBe('GPT-5.3-Codex-Spark (Primary)'); + expect(sparkPrimary?.usedPercent).toBe(0); + expect(sparkPrimary?.remainingPercent).toBe(100); + + expect(sparkSecondary).toBeDefined(); + expect(sparkSecondary?.featureLabel).toBe('GPT-5.3-Codex-Spark'); + expect(sparkSecondary?.label).toBe('GPT-5.3-Codex-Spark (Secondary)'); + expect(sparkSecondary?.usedPercent).toBe(1); + expect(sparkSecondary?.remainingPercent).toBe(99); + }); + + it('should handle additional_rate_limits set to null without breaking', () => { + const response = { + rate_limit: { + primary_window: { used_percent: 10, reset_after_seconds: 3600 }, + }, + additional_rate_limits: null, + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(1); + expect(windows[0].category).toBe('usage'); + expect(windows.find((w) => w.category === 'additional')).toBeUndefined(); + }); + + it('should accept camelCase additionalRateLimits and rateLimit fields', () => { + const response = { + additionalRateLimits: [ + { + limitName: 'Custom-Feature', + rateLimit: { + primaryWindow: { usedPercent: 50, resetAfterSeconds: 3600 }, + }, + }, + ], + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(1); + expect(windows[0].category).toBe('additional'); + expect(windows[0].cadence).toBe('5h'); + expect(windows[0].featureLabel).toBe('Custom-Feature'); + expect(windows[0].usedPercent).toBe(50); + }); }); describe('buildCodexCoreUsageSummary', () => { @@ -311,6 +427,36 @@ describe('Codex Quota Fetcher', () => { expect(summary.fiveHour).toBeNull(); expect(summary.weekly).toBeNull(); }); + + it('excludes additional and code-review windows from the core usage summary', () => { + const windows = buildCodexQuotaWindows({ + rate_limit: { + primary_window: { used_percent: 35, reset_after_seconds: 18000 }, + secondary_window: { used_percent: 60, reset_after_seconds: 604800 }, + }, + code_review_rate_limit: { + primary_window: { used_percent: 70, reset_after_seconds: 1800 }, + }, + additional_rate_limits: [ + { + limit_name: 'GPT-5.3-Codex-Spark', + rate_limit: { + primary_window: { used_percent: 90, reset_after_seconds: 100 }, + secondary_window: { used_percent: 95, reset_after_seconds: 7200 }, + }, + }, + ], + }); + + const summary = buildCodexCoreUsageSummary(windows); + + // Should pick the 'usage' windows, NOT the Spark windows even though they + // have shorter reset cadences. + expect(summary.fiveHour?.label).toBe('Primary'); + expect(summary.fiveHour?.resetAfterSeconds).toBe(18000); + expect(summary.weekly?.label).toBe('Secondary'); + expect(summary.weekly?.resetAfterSeconds).toBe(604800); + }); }); describe('getUnknownCodexWindowLabels', () => { diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 0dba6fb7..23285688 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -259,11 +259,17 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro // Codex provider tooltip if (isCodexQuotaResult(quota)) { - const { fiveHourWindow, weeklyWindow, codeReviewWindows, unknownWindows } = + const { fiveHourWindow, weeklyWindow, codeReviewWindows, additionalWindows, unknownWindows } = getCodexQuotaBreakdown(quota.windows); const fiveHourResetAt = quota.coreUsage?.fiveHour?.resetAt ?? fiveHourWindow?.resetAt ?? null; const weeklyResetAt = quota.coreUsage?.weekly?.resetAt ?? weeklyWindow?.resetAt ?? null; - const orderedWindows = [fiveHourWindow, weeklyWindow, ...codeReviewWindows, ...unknownWindows] + const orderedWindows = [ + fiveHourWindow, + weeklyWindow, + ...codeReviewWindows, + ...additionalWindows, + ...unknownWindows, + ] .filter((w): w is NonNullable => !!w) .filter( (w, index, arr) => diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index e48e4dc4..1213553d 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -645,7 +645,11 @@ export interface QuotaResult { /** Codex rate limit window */ export interface CodexQuotaWindow { - /** Window label: "Primary", "Secondary", "Code Review (Primary)", "Code Review (Secondary)" */ + /** + * Window label, e.g.: "Primary", "Secondary", + * "Code Review (Primary)", "Code Review (Secondary)", + * or "GPT-5.3-Codex-Spark (Primary)" for additional rate limits. + */ label: string; /** Percentage used (0-100) */ usedPercent: number; @@ -655,6 +659,18 @@ export interface CodexQuotaWindow { resetAfterSeconds: number | null; /** ISO timestamp when quota resets, null if unknown */ resetAt: string | null; + /** + * Window category indicating the bucket this window belongs to. + * Optional for back-compat with cached data emitted before this field existed. + * - 'usage' -> standard rate_limit usage windows + * - 'code-review' -> code_review_rate_limit windows + * - 'additional' -> additional_rate_limits[] windows (e.g. GPT-5.3 Codex Spark) + */ + category?: 'usage' | 'code-review' | 'additional'; + /** Cadence of the window: '5h' = primary, 'weekly' = secondary. Optional for legacy data. */ + cadence?: '5h' | 'weekly'; + /** Raw upstream feature label (e.g. 'GPT-5.3-Codex-Spark', 'Code Review'); absent for plain usage windows. */ + featureLabel?: string; } /** Core Codex usage window (5h/weekly) extracted from raw windows */ diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 6c42ace6..85ee043a 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -1626,6 +1626,9 @@ const resources = { codeReview5h: 'Code review (5h)', codeReviewWeekly: 'Code review (weekly)', codeReview: 'Code review', + codexAdditional5h: '{{name}} (5h)', + codexAdditionalWeekly: '{{name}} (weekly)', + codexAdditional: '{{name}}', }, sponsorButton: { title: 'Sponsor this project on GitHub', @@ -4098,6 +4101,9 @@ const resources = { codeReview5h: '代码审查(5 小时)', codeReviewWeekly: '代码审查(每周)', codeReview: '代码审查', + codexAdditional5h: '{{name}}(5 小时)', + codexAdditionalWeekly: '{{name}}(每周)', + codexAdditional: '{{name}}', }, sponsorButton: { title: '在 GitHub 上赞助此项目', @@ -6630,6 +6636,9 @@ const resources = { codeReview5h: 'Đánh giá mã (5h)', codeReviewWeekly: 'Đánh giá mã (hàng tuần)', codeReview: 'Đánh giá mã', + codexAdditional5h: '{{name}} (5h)', + codexAdditionalWeekly: '{{name}} (hàng tuần)', + codexAdditional: '{{name}}', }, sponsorButton: { title: 'Tài trợ dự án này trên GitHub', @@ -9766,6 +9775,9 @@ const resources = { codeReview5h: 'コードレビュー(5時間)', codeReviewWeekly: 'コードレビュー(週次)', codeReview: 'コードレビュー', + codexAdditional5h: '{{name}}(5時間)', + codexAdditionalWeekly: '{{name}}(週次)', + codexAdditional: '{{name}}', }, rawEditorSection: { rawConfig: 'Raw 設定', diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index e50d0c5b..66cb1ee7 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -332,12 +332,33 @@ export type CodexWindowKind = | 'code-review-5h' | 'code-review-weekly' | 'code-review' + | 'additional-5h' + | 'additional-weekly' | 'unknown'; /** - * Map raw Codex API window labels into semantic buckets. + * Map a Codex window into a semantic bucket. Prefers explicit category metadata + * (post-2026-04 windows) and falls back to label sniffing for legacy cached data. */ -export function getCodexWindowKind(label: string): CodexWindowKind { +export function getCodexWindowKind(labelOrWindow: string | CodexWindowSummary): CodexWindowKind { + if (typeof labelOrWindow === 'object' && labelOrWindow !== null) { + const w = labelOrWindow; + if (w.category === 'additional' && w.cadence) { + return w.cadence === 'weekly' ? 'additional-weekly' : 'additional-5h'; + } + if (w.category === 'code-review' && w.cadence) { + return w.cadence === 'weekly' ? 'code-review-weekly' : 'code-review-5h'; + } + if (w.category === 'usage' && w.cadence) { + return w.cadence === 'weekly' ? 'usage-weekly' : 'usage-5h'; + } + // Missing or incomplete category metadata -> fall through to label sniffing. + return getCodexWindowKindFromLabel(w.label); + } + return getCodexWindowKindFromLabel(labelOrWindow); +} + +function getCodexWindowKindFromLabel(label: string): CodexWindowKind { const lower = (label || '').toLowerCase(); const isCodeReview = lower.includes('code review') || lower.includes('code_review'); const isPrimary = lower.includes('primary'); @@ -354,7 +375,29 @@ export function getCodexWindowKind(label: string): CodexWindowKind { return 'unknown'; } -type CodexWindowSummary = Pick; +/** + * Strip a leading "GPT-X.Y-Codex-" prefix from a feature label and turn the + * remainder into a "Codex " display name. Other labels pass through unchanged. + * + * Examples: + * "GPT-5.3-Codex-Spark" -> "Codex Spark" + * "OmniReview" -> "OmniReview" + * "" -> "" + */ +export function prettifyCodexFeatureLabel(featureLabel: string): string { + const trimmed = (featureLabel || '').trim(); + if (!trimmed) return ''; + const stripped = trimmed.replace(/^GPT-[\d.]+-Codex-/i, ''); + if (stripped !== trimmed && stripped.length > 0) { + return `Codex ${stripped}`; + } + return trimmed; +} + +type CodexWindowSummary = Pick< + CodexQuotaWindow, + 'label' | 'resetAfterSeconds' | 'category' | 'cadence' | 'featureLabel' +>; /** * Infer code-review window cadence by comparing against usage windows. @@ -364,7 +407,7 @@ function inferCodeReviewCadence( window: CodexWindowSummary, allWindows: CodexWindowSummary[] ): '5h' | 'weekly' | null { - const kind = getCodexWindowKind(window.label); + const kind = getCodexWindowKind(window); if (kind === 'code-review-weekly') return 'weekly'; const reset = window.resetAfterSeconds; @@ -372,14 +415,14 @@ function inferCodeReviewCadence( const usage5h = allWindows.find( (w) => - getCodexWindowKind(w.label) === 'usage-5h' && + getCodexWindowKind(w) === 'usage-5h' && typeof w.resetAfterSeconds === 'number' && isFinite(w.resetAfterSeconds) && w.resetAfterSeconds > 0 ); const usageWeekly = allWindows.find( (w) => - getCodexWindowKind(w.label) === 'usage-weekly' && + getCodexWindowKind(w) === 'usage-weekly' && typeof w.resetAfterSeconds === 'number' && isFinite(w.resetAfterSeconds) && w.resetAfterSeconds > 0 @@ -400,10 +443,16 @@ export function getCodexWindowDisplayLabel( const currentWindow: CodexWindowSummary = typeof labelOrWindow === 'string' ? { label, resetAfterSeconds: null } - : { label, resetAfterSeconds: labelOrWindow.resetAfterSeconds }; + : { + label, + resetAfterSeconds: labelOrWindow.resetAfterSeconds, + category: labelOrWindow.category, + cadence: labelOrWindow.cadence, + featureLabel: labelOrWindow.featureLabel, + }; const context = allWindows.length > 0 ? allWindows : [currentWindow]; - switch (getCodexWindowKind(label)) { + switch (getCodexWindowKind(currentWindow)) { case 'usage-5h': return i18n.t('quotaTooltip.fiveHourLimit'); case 'usage-weekly': @@ -416,6 +465,15 @@ export function getCodexWindowDisplayLabel( if (inferred === 'weekly') return i18n.t('utils.codeReviewWeekly'); return i18n.t('utils.codeReview'); } + case 'additional-5h': + case 'additional-weekly': { + const pretty = prettifyCodexFeatureLabel(currentWindow.featureLabel ?? ''); + const name = pretty || label; + const kind = getCodexWindowKind(currentWindow); + if (kind === 'additional-5h') return i18n.t('utils.codexAdditional5h', { name }); + if (kind === 'additional-weekly') return i18n.t('utils.codexAdditionalWeekly', { name }); + return i18n.t('utils.codexAdditional', { name }); + } case 'unknown': return label; } @@ -425,11 +483,18 @@ export interface CodexQuotaBreakdown { fiveHourWindow: CodexQuotaWindow | null; weeklyWindow: CodexQuotaWindow | null; codeReviewWindows: CodexQuotaWindow[]; + /** Additional rate-limit windows (e.g. GPT-5.3 Codex Spark). Excluded from core 5h/weekly summary. */ + additionalWindows: CodexQuotaWindow[]; unknownWindows: CodexQuotaWindow[]; } /** - * Break down Codex windows into core usage windows (5h + weekly) and auxiliary windows. + * Break down Codex windows into core usage windows (5h + weekly), code review, + * additional (e.g. Spark), and unknown buckets. + * + * Prefers explicit category metadata when present so 'additional' windows + * (e.g. GPT-5.3 Codex Spark) do not displace core usage windows in the summary. + * Falls back to label sniffing for legacy cached data without metadata. */ export function getCodexQuotaBreakdown(windows: CodexQuotaWindow[]): CodexQuotaBreakdown { if (!windows || windows.length === 0) { @@ -437,6 +502,7 @@ export function getCodexQuotaBreakdown(windows: CodexQuotaWindow[]): CodexQuotaB fiveHourWindow: null, weeklyWindow: null, codeReviewWindows: [], + additionalWindows: [], unknownWindows: [], }; } @@ -444,34 +510,64 @@ export function getCodexQuotaBreakdown(windows: CodexQuotaWindow[]): CodexQuotaB let fiveHourWindow: CodexQuotaWindow | null = null; let weeklyWindow: CodexQuotaWindow | null = null; const codeReviewWindows: CodexQuotaWindow[] = []; + const additionalWindows: CodexQuotaWindow[] = []; const unknownWindows: CodexQuotaWindow[] = []; + // Eligible windows for the 5h/weekly fallback (must NOT include code-review or additional). const nonCodeReviewWindows: CodexQuotaWindow[] = []; - for (const window of windows) { - const kind = getCodexWindowKind(window.label); + const hasCategoryMetadata = windows.some((w) => Boolean(w.category)); - switch (kind) { - case 'usage-5h': - if (!fiveHourWindow) fiveHourWindow = window; + if (hasCategoryMetadata) { + for (const window of windows) { + if (window.category === 'usage') { + if (window.cadence === '5h' && !fiveHourWindow) fiveHourWindow = window; + else if (window.cadence === 'weekly' && !weeklyWindow) weeklyWindow = 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': + } else if (window.category === 'code-review') { codeReviewWindows.push(window); - break; - case 'unknown': + } else if (window.category === 'additional') { + additionalWindows.push(window); + } else { + // Window has no category but the batch carries metadata for others -> treat as unknown. unknownWindows.push(window); nonCodeReviewWindows.push(window); - break; + } + } + } else { + // Legacy path: classify via label sniffing for cached windows without metadata. + 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 'additional-5h': + case 'additional-weekly': + // Unreachable from label-only kind, but kept for exhaustiveness. + additionalWindows.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. + // 'additional' windows are intentionally excluded from this pool to avoid leaking Spark windows + // into the core usage badges on a fresh Pro account. if ((!fiveHourWindow || !weeklyWindow) && nonCodeReviewWindows.length > 0) { const withReset = nonCodeReviewWindows .filter((w) => typeof w.resetAfterSeconds === 'number' && w.resetAfterSeconds >= 0) @@ -493,6 +589,7 @@ export function getCodexQuotaBreakdown(windows: CodexQuotaWindow[]): CodexQuotaB fiveHourWindow, weeklyWindow, codeReviewWindows, + additionalWindows, unknownWindows, }; } diff --git a/ui/tests/unit/ui/lib/codex-quota-breakdown.test.ts b/ui/tests/unit/ui/lib/codex-quota-breakdown.test.ts new file mode 100644 index 00000000..99c6c6e1 --- /dev/null +++ b/ui/tests/unit/ui/lib/codex-quota-breakdown.test.ts @@ -0,0 +1,316 @@ +/** + * Tests for Codex quota breakdown logic. + * + * Covers the dashboard-side mirror of the server's CodexQuotaWindow handling: + * additional rate-limit windows (e.g. GPT-5.3 Codex Spark) must NOT pollute + * the core 5h/weekly buckets, and category metadata takes precedence over + * label sniffing when present. + */ + +import { describe, it, expect } from 'vitest'; +import { + getCodexQuotaBreakdown, + getCodexWindowKind, + getCodexWindowDisplayLabel, + prettifyCodexFeatureLabel, +} from '@/lib/utils'; +import type { CodexQuotaWindow } from '@/lib/api-client'; + +describe('prettifyCodexFeatureLabel', () => { + it('strips "GPT-X.Y-Codex-" prefix and prepends "Codex"', () => { + expect(prettifyCodexFeatureLabel('GPT-5.3-Codex-Spark')).toBe('Codex Spark'); + }); + + it('is case-insensitive when stripping the GPT prefix', () => { + expect(prettifyCodexFeatureLabel('gpt-5.3-codex-spark')).toBe('Codex spark'); + }); + + it('returns labels without the GPT prefix verbatim', () => { + expect(prettifyCodexFeatureLabel('OmniReview')).toBe('OmniReview'); + }); + + it('returns empty string for empty input', () => { + expect(prettifyCodexFeatureLabel('')).toBe(''); + }); + + it('trims whitespace and handles whitespace-only input', () => { + expect(prettifyCodexFeatureLabel(' ')).toBe(''); + expect(prettifyCodexFeatureLabel(' GPT-5.3-Codex-Spark ')).toBe('Codex Spark'); + }); +}); + +describe('getCodexWindowKind', () => { + it('uses category metadata when present (additional weekly)', () => { + const window: CodexQuotaWindow = { + label: 'GPT-5.3-Codex-Spark (Secondary)', + usedPercent: 10, + remainingPercent: 90, + resetAfterSeconds: 600000, + resetAt: '2026-05-04T00:00:00Z', + category: 'additional', + cadence: 'weekly', + featureLabel: 'GPT-5.3-Codex-Spark', + }; + expect(getCodexWindowKind(window)).toBe('additional-weekly'); + }); + + it('uses category metadata when present (additional 5h)', () => { + const window: CodexQuotaWindow = { + label: 'GPT-5.3-Codex-Spark (Primary)', + usedPercent: 5, + remainingPercent: 95, + resetAfterSeconds: 18000, + resetAt: '2026-04-28T00:00:00Z', + category: 'additional', + cadence: '5h', + featureLabel: 'GPT-5.3-Codex-Spark', + }; + expect(getCodexWindowKind(window)).toBe('additional-5h'); + }); + + it('uses category metadata when present (usage 5h)', () => { + const window: CodexQuotaWindow = { + label: 'Primary', + usedPercent: 25, + remainingPercent: 75, + resetAfterSeconds: 18000, + resetAt: '2026-04-28T00:00:00Z', + category: 'usage', + cadence: '5h', + }; + expect(getCodexWindowKind(window)).toBe('usage-5h'); + }); + + it('uses category metadata when present (code-review weekly)', () => { + const window: CodexQuotaWindow = { + label: 'Code Review (Secondary)', + usedPercent: 50, + remainingPercent: 50, + resetAfterSeconds: 604800, + resetAt: '2026-05-04T00:00:00Z', + category: 'code-review', + cadence: 'weekly', + featureLabel: 'Code Review', + }; + expect(getCodexWindowKind(window)).toBe('code-review-weekly'); + }); + + it('falls back to label sniffing for legacy windows without category', () => { + expect(getCodexWindowKind('Primary')).toBe('usage-5h'); + expect(getCodexWindowKind('Secondary')).toBe('usage-weekly'); + expect(getCodexWindowKind('Code Review (Primary)')).toBe('code-review-5h'); + expect(getCodexWindowKind('Code Review (Secondary)')).toBe('code-review-weekly'); + expect(getCodexWindowKind('Code Review')).toBe('code-review'); + expect(getCodexWindowKind('Random Label')).toBe('unknown'); + }); + + it('falls back to label sniffing when window has no category set', () => { + const window: CodexQuotaWindow = { + label: 'Primary', + usedPercent: 25, + remainingPercent: 75, + resetAfterSeconds: 18000, + resetAt: '2026-04-28T00:00:00Z', + }; + expect(getCodexWindowKind(window)).toBe('usage-5h'); + }); +}); + +describe('getCodexQuotaBreakdown', () => { + it('returns empty buckets for empty windows', () => { + const result = getCodexQuotaBreakdown([]); + expect(result.fiveHourWindow).toBeNull(); + expect(result.weeklyWindow).toBeNull(); + expect(result.codeReviewWindows).toEqual([]); + expect(result.additionalWindows).toEqual([]); + expect(result.unknownWindows).toEqual([]); + }); + + it('routes Pro-account windows by category metadata (Spark stays out of core)', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 0, + remainingPercent: 100, + resetAfterSeconds: 18000, + resetAt: '2026-04-28T05:00:00Z', + category: 'usage', + cadence: '5h', + }, + { + label: 'Secondary', + usedPercent: 0, + remainingPercent: 100, + resetAfterSeconds: 604800, + resetAt: '2026-05-04T00:00:00Z', + category: 'usage', + cadence: 'weekly', + }, + { + label: 'GPT-5.3-Codex-Spark (Primary)', + usedPercent: 12, + remainingPercent: 88, + // Intentionally short reset to verify Spark cannot displace core 5h via reset-horizon fallback. + resetAfterSeconds: 60, + resetAt: '2026-04-27T00:01:00Z', + category: 'additional', + cadence: '5h', + featureLabel: 'GPT-5.3-Codex-Spark', + }, + { + label: 'GPT-5.3-Codex-Spark (Secondary)', + usedPercent: 30, + remainingPercent: 70, + resetAfterSeconds: 600000, + resetAt: '2026-05-04T00:00:00Z', + category: 'additional', + cadence: 'weekly', + featureLabel: 'GPT-5.3-Codex-Spark', + }, + { + label: 'Code Review (Primary)', + usedPercent: 25, + remainingPercent: 75, + resetAfterSeconds: 17000, + resetAt: '2026-04-28T05:00:00Z', + category: 'code-review', + cadence: '5h', + featureLabel: 'Code Review', + }, + ]; + + const result = getCodexQuotaBreakdown(windows); + + expect(result.fiveHourWindow?.label).toBe('Primary'); + expect(result.fiveHourWindow?.category).toBe('usage'); + expect(result.weeklyWindow?.label).toBe('Secondary'); + expect(result.weeklyWindow?.category).toBe('usage'); + + expect(result.additionalWindows).toHaveLength(2); + expect(result.additionalWindows.map((w) => w.featureLabel)).toEqual([ + 'GPT-5.3-Codex-Spark', + 'GPT-5.3-Codex-Spark', + ]); + expect(result.additionalWindows.map((w) => w.cadence)).toEqual(['5h', 'weekly']); + + expect(result.codeReviewWindows).toHaveLength(1); + expect(result.codeReviewWindows[0]?.featureLabel).toBe('Code Review'); + + expect(result.unknownWindows).toEqual([]); + }); + + it('returns empty additionalWindows for Plus-shape data without additional categories', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 30, + remainingPercent: 70, + resetAfterSeconds: 18000, + resetAt: '2026-04-28T05:00:00Z', + category: 'usage', + cadence: '5h', + }, + { + label: 'Secondary', + usedPercent: 60, + remainingPercent: 40, + resetAfterSeconds: 604800, + resetAt: '2026-05-04T00:00:00Z', + category: 'usage', + cadence: 'weekly', + }, + ]; + + const result = getCodexQuotaBreakdown(windows); + + expect(result.fiveHourWindow?.label).toBe('Primary'); + expect(result.weeklyWindow?.label).toBe('Secondary'); + expect(result.additionalWindows).toEqual([]); + expect(result.codeReviewWindows).toEqual([]); + expect(result.unknownWindows).toEqual([]); + }); + + it('falls back to label sniffing for legacy cached windows without metadata', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 25, + remainingPercent: 75, + resetAfterSeconds: 18000, + resetAt: '2026-04-28T05:00:00Z', + }, + { + label: 'Secondary', + usedPercent: 65, + remainingPercent: 35, + resetAfterSeconds: 604800, + resetAt: '2026-05-04T00:00:00Z', + }, + { + label: 'Code Review (Primary)', + usedPercent: 10, + remainingPercent: 90, + resetAfterSeconds: 17000, + resetAt: '2026-04-28T05:00:00Z', + }, + { + label: 'Code Review (Secondary)', + usedPercent: 5, + remainingPercent: 95, + resetAfterSeconds: 600000, + resetAt: '2026-05-04T00:00:00Z', + }, + ]; + + const result = getCodexQuotaBreakdown(windows); + + expect(result.fiveHourWindow?.label).toBe('Primary'); + expect(result.weeklyWindow?.label).toBe('Secondary'); + expect(result.codeReviewWindows).toHaveLength(2); + expect(result.additionalWindows).toEqual([]); + expect(result.unknownWindows).toEqual([]); + }); +}); + +describe('getCodexWindowDisplayLabel for additional windows', () => { + it('renders additional 5h windows with prettified Codex feature name', () => { + const window: CodexQuotaWindow = { + label: 'GPT-5.3-Codex-Spark (Primary)', + usedPercent: 12, + remainingPercent: 88, + resetAfterSeconds: 18000, + resetAt: '2026-04-28T05:00:00Z', + category: 'additional', + cadence: '5h', + featureLabel: 'GPT-5.3-Codex-Spark', + }; + expect(getCodexWindowDisplayLabel(window)).toBe('Codex Spark (5h)'); + }); + + it('renders additional weekly windows with prettified Codex feature name', () => { + const window: CodexQuotaWindow = { + label: 'GPT-5.3-Codex-Spark (Secondary)', + usedPercent: 30, + remainingPercent: 70, + resetAfterSeconds: 600000, + resetAt: '2026-05-04T00:00:00Z', + category: 'additional', + cadence: 'weekly', + featureLabel: 'GPT-5.3-Codex-Spark', + }; + expect(getCodexWindowDisplayLabel(window)).toBe('Codex Spark (weekly)'); + }); + + it('falls back to raw label when featureLabel is missing', () => { + const window: CodexQuotaWindow = { + label: 'OmniReview (Primary)', + usedPercent: 0, + remainingPercent: 100, + resetAfterSeconds: 18000, + resetAt: '2026-04-28T05:00:00Z', + category: 'additional', + cadence: '5h', + }; + expect(getCodexWindowDisplayLabel(window)).toBe('OmniReview (Primary) (5h)'); + }); +});