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..3466e1e7 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 || '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', () => {