From 4fc19c43902330da31b49b6296082fb13e22bebf Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 15 Feb 2026 09:48:39 +0700 Subject: [PATCH] feat(cliproxy): expose codex weekly reset schedule in quota views --- README.md | 1 + src/cliproxy/quota-fetcher-codex.ts | 97 ++++++++++++++++++- src/cliproxy/quota-types.ts | 22 +++++ src/commands/cliproxy/help-subcommand.ts | 2 +- src/commands/cliproxy/quota-subcommand.ts | 78 ++++++++++++++- .../unit/cliproxy/quota-fetcher-codex.test.ts | 70 ++++++++++++- .../shared/quota-tooltip-content.tsx | 45 ++++++++- ui/src/lib/api-client.ts | 22 +++++ 8 files changed, 329 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b6a62ffe..6b135b0f 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ Re-creates symlinks for shared commands, skills, and settings. ```bash ccs cliproxy doctor # Check quota status for all agy accounts +ccs cliproxy quota # Show agy/codex/gemini quotas (Codex: 5h + weekly reset schedule) ``` **Auto-Failover**: When an Antigravity account runs out of quota, CCS automatically switches to another account with remaining capacity. Shared GCP project accounts are excluded (pooled quota). diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 88ac119a..3c860d5b 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -10,7 +10,7 @@ import * as path from 'node:path'; import { getAuthDir } from './config-generator'; import { getProviderAccounts, getPausedDir } from './account-manager'; import { sanitizeEmail, isTokenExpired } from './auth-utils'; -import type { CodexQuotaResult, CodexQuotaWindow } from './quota-types'; +import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types'; /** ChatGPT backend API base URL */ const CODEX_API_BASE = 'https://chatgpt.com/backend-api'; @@ -57,6 +57,99 @@ interface CodexWindowData { resetAfterSeconds?: number | null; } +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'; +} + +/** + * Build explicit 5h + weekly usage summary from raw Codex windows. + * Falls back to shortest/longest reset windows if API labels change. + */ +export function buildCodexCoreUsageSummary(windows: CodexQuotaWindow[]): CodexCoreUsageSummary { + if (!windows || windows.length === 0) { + return { fiveHour: null, weekly: null }; + } + + let fiveHourWindow: CodexQuotaWindow | null = null; + 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; + } + 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' && + isFinite(w.resetAfterSeconds) && + 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; + } + } + + const mapWindow = (window: CodexQuotaWindow | null): CodexCoreUsageSummary['fiveHour'] => { + if (!window) return null; + return { + label: window.label, + remainingPercent: window.remainingPercent, + resetAfterSeconds: window.resetAfterSeconds, + resetAt: window.resetAt, + }; + }; + + return { + fiveHour: mapWindow(fiveHourWindow), + weekly: mapWindow(weeklyWindow), + }; +} + /** * Read auth data from Codex auth file */ @@ -295,6 +388,7 @@ export async function fetchCodexQuota( const data = (await response.json()) as CodexUsageResponse; const windows = buildCodexQuotaWindows(data); + const coreUsage = buildCodexCoreUsageSummary(windows); // Extract plan type const planTypeRaw = data.plan_type || data.planType; @@ -311,6 +405,7 @@ export async function fetchCodexQuota( return { success: true, windows, + coreUsage, planType, lastUpdated: Date.now(), accountId, diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index f8759bbe..17f32f72 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -27,6 +27,26 @@ export interface CodexQuotaWindow { resetAt: string | null; } +/** Core Codex usage window (5h/weekly) extracted from raw windows */ +export interface CodexCoreUsageWindow { + /** Source window label */ + label: string; + /** Percentage remaining (0-100) */ + remainingPercent: number; + /** Seconds until quota resets, null if unknown */ + resetAfterSeconds: number | null; + /** ISO timestamp when quota resets, null if unknown */ + resetAt: string | null; +} + +/** Core Codex usage summary with explicit 5h and weekly windows */ +export interface CodexCoreUsageSummary { + /** Short-cycle usage limit window (typically 5h) */ + fiveHour: CodexCoreUsageWindow | null; + /** Long-cycle usage limit window (typically weekly) */ + weekly: CodexCoreUsageWindow | null; +} + /** * Codex quota fetch result */ @@ -35,6 +55,8 @@ export interface CodexQuotaResult { success: boolean; /** Quota windows (primary, secondary, code review) */ windows: CodexQuotaWindow[]; + /** Explicit core usage windows (5h + weekly) for easier reset display */ + coreUsage?: CodexCoreUsageSummary; /** Plan type: free, plus, team, or null if unknown */ planType: 'free' | 'plus' | 'team' | null; /** Timestamp of fetch */ diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 7971c701..a5c2ed4d 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -54,7 +54,7 @@ export async function showHelp(): Promise { ['default ', 'Set default account for rotation'], ['pause ', 'Pause account (skip in rotation)'], ['resume ', 'Resume paused account'], - ['quota', 'Show quota status for all providers'], + ['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'], ['quota --provider ', 'Filter by provider (agy|codex|gemini)'], ], ], diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index f426e490..8bdf4830 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -67,7 +67,13 @@ function formatResetTime(seconds: number): string { if (seconds <= 0) return 'now'; if (seconds < 60) return `in ${seconds}s`; if (seconds < 3600) return `in ${Math.round(seconds / 60)}m`; - return `in ${Math.round(seconds / 3600)}h`; + if (seconds < 86400) return `in ${Math.round(seconds / 3600)}h`; + + const days = Math.floor(seconds / 86400); + const hours = Math.round((seconds % 86400) / 3600); + if (hours <= 0) return `in ${days}d`; + if (hours >= 24) return `in ${days + 1}d`; + return `in ${days}d ${hours}h`; } function formatResetTimeISO(isoTime: string): string { @@ -78,6 +84,40 @@ function formatResetTimeISO(isoTime: string): string { return formatResetTime(seconds); } +function formatAbsoluteResetTime(isoTime: string): string | null { + if (!isoTime) return null; + const resetDate = new Date(isoTime); + if (isNaN(resetDate.getTime())) return null; + const date = resetDate.toLocaleDateString(undefined, { + month: '2-digit', + day: '2-digit', + }); + const time = resetDate.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + }); + return `${date} ${time}`; +} + +function formatCodexWindowReset( + window: Pick +): string | null { + if (typeof window.resetAfterSeconds === 'number' && isFinite(window.resetAfterSeconds)) { + const relative = formatResetTime(Math.max(0, window.resetAfterSeconds)); + if (window.resetAfterSeconds >= 86400 && window.resetAt) { + const absolute = formatAbsoluteResetTime(window.resetAt); + return absolute ? `${relative} (${absolute})` : relative; + } + return relative; + } + + if (window.resetAt) { + return formatResetTimeISO(window.resetAt); + } + + return null; +} + type CodexWindowKind = | 'usage-5h' | 'usage-weekly' @@ -286,15 +326,45 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`); + const coreUsageSummary = quota.coreUsage ?? { + fiveHour: fiveHourWindow + ? { + label: fiveHourWindow.label, + remainingPercent: fiveHourWindow.remainingPercent, + resetAfterSeconds: fiveHourWindow.resetAfterSeconds, + resetAt: fiveHourWindow.resetAt, + } + : null, + weekly: weeklyWindow + ? { + label: weeklyWindow.label, + remainingPercent: weeklyWindow.remainingPercent, + resetAfterSeconds: weeklyWindow.resetAfterSeconds, + resetAt: weeklyWindow.resetAt, + } + : null, + }; + const resetParts: string[] = []; + const fiveHourReset = coreUsageSummary.fiveHour + ? formatCodexWindowReset(coreUsageSummary.fiveHour) + : null; + const weeklyReset = coreUsageSummary.weekly + ? formatCodexWindowReset(coreUsageSummary.weekly) + : null; + if (fiveHourReset) resetParts.push(`5h ${fiveHourReset}`); + if (weeklyReset) resetParts.push(`weekly ${weeklyReset}`); + if (resetParts.length > 0) { + console.log(` ${dim(`Reset schedule: ${resetParts.join(' | ')}`)}`); + } + 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)}`) - : ''; + const resetValue = formatCodexWindowReset(window); + const resetLabel = resetValue ? dim(` Resets ${resetValue}`) : ''; console.log( ` ${getCodexWindowDisplayLabel(window, orderedWindows).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` ); diff --git a/tests/unit/cliproxy/quota-fetcher-codex.test.ts b/tests/unit/cliproxy/quota-fetcher-codex.test.ts index 5c774f4b..b771ee4d 100644 --- a/tests/unit/cliproxy/quota-fetcher-codex.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-codex.test.ts @@ -5,7 +5,10 @@ */ import { describe, it, expect } from 'bun:test'; -import { buildCodexQuotaWindows } from '../../../src/cliproxy/quota-fetcher-codex'; +import { + buildCodexQuotaWindows, + buildCodexCoreUsageSummary, +} from '../../../src/cliproxy/quota-fetcher-codex'; describe('Codex Quota Fetcher', () => { describe('buildCodexQuotaWindows', () => { @@ -187,4 +190,69 @@ describe('Codex Quota Fetcher', () => { expect(windows[0].remainingPercent).toBe(100); }); }); + + describe('buildCodexCoreUsageSummary', () => { + it('extracts 5h and weekly windows from labeled usage windows', () => { + const windows = buildCodexQuotaWindows({ + rate_limit: { + primary_window: { + used_percent: 35, + reset_after_seconds: 18000, + }, + secondary_window: { + used_percent: 60, + reset_after_seconds: 604800, + }, + }, + }); + + const summary = buildCodexCoreUsageSummary(windows); + + expect(summary.fiveHour?.label).toBe('Primary'); + expect(summary.fiveHour?.remainingPercent).toBe(65); + expect(summary.fiveHour?.resetAfterSeconds).toBe(18000); + expect(summary.weekly?.label).toBe('Secondary'); + expect(summary.weekly?.remainingPercent).toBe(40); + expect(summary.weekly?.resetAfterSeconds).toBe(604800); + }); + + it('falls back to shortest and longest reset windows when labels are unknown', () => { + const windows = [ + { + label: 'Window A', + usedPercent: 20, + remainingPercent: 80, + resetAfterSeconds: 18000, + resetAt: '2026-02-15T15:00:00Z', + }, + { + label: 'Window B', + usedPercent: 45, + remainingPercent: 55, + resetAfterSeconds: 604800, + resetAt: '2026-02-21T10:00:00Z', + }, + { + label: 'Code Review (Primary)', + usedPercent: 10, + remainingPercent: 90, + resetAfterSeconds: 3600, + resetAt: '2026-02-15T11:00:00Z', + }, + ]; + + const summary = buildCodexCoreUsageSummary(windows); + + expect(summary.fiveHour?.label).toBe('Window A'); + expect(summary.fiveHour?.resetAfterSeconds).toBe(18000); + expect(summary.weekly?.label).toBe('Window B'); + expect(summary.weekly?.resetAfterSeconds).toBe(604800); + }); + + it('returns null summaries when no windows are available', () => { + const summary = buildCodexCoreUsageSummary([]); + expect(summary.fiveHour).toBeNull(); + expect(summary.weekly).toBeNull(); + }); + }); }); diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 492924c3..cdb834ad 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -68,6 +68,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro if (isCodexQuotaResult(quota)) { const { fiveHourWindow, weeklyWindow, codeReviewWindows, 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] .filter((w): w is NonNullable => !!w) .filter( @@ -92,7 +94,11 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro {w.remainingPercent}% ))} - + ); } @@ -132,3 +138,40 @@ function ResetTimeIndicator({ resetTime }: { resetTime: string | null }) { ); } + +function CodexResetIndicators({ + fiveHourResetTime, + weeklyResetTime, + fallbackResetTime, +}: { + fiveHourResetTime: string | null; + weeklyResetTime: string | null; + fallbackResetTime: string | null; +}) { + const hasSpecificReset = !!fiveHourResetTime || !!weeklyResetTime; + if (!hasSpecificReset && !fallbackResetTime) return null; + + return ( +
+ {fiveHourResetTime && ( +
+ + + 5h resets {formatResetTime(fiveHourResetTime)} + +
+ )} + {weeklyResetTime && ( +
+ + + Weekly resets {formatResetTime(weeklyResetTime)} + +
+ )} + {!hasSpecificReset && fallbackResetTime && ( + + )} +
+ ); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 5fefd08c..9f01191f 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -183,12 +183,34 @@ export interface CodexQuotaWindow { resetAt: string | null; } +/** Core Codex usage window (5h/weekly) extracted from raw windows */ +export interface CodexCoreUsageWindow { + /** Source window label */ + label: string; + /** Percentage remaining (0-100) */ + remainingPercent: number; + /** Seconds until quota resets, null if unknown */ + resetAfterSeconds: number | null; + /** ISO timestamp when quota resets, null if unknown */ + resetAt: string | null; +} + +/** Core Codex usage summary with explicit 5h and weekly windows */ +export interface CodexCoreUsageSummary { + /** Short-cycle usage limit window (typically 5h) */ + fiveHour: CodexCoreUsageWindow | null; + /** Long-cycle usage limit window (typically weekly) */ + weekly: CodexCoreUsageWindow | null; +} + /** Codex quota result */ export interface CodexQuotaResult { /** Whether fetch succeeded */ success: boolean; /** Quota windows (primary, secondary, code review) */ windows: CodexQuotaWindow[]; + /** Explicit core usage windows (5h + weekly) for easier reset display */ + coreUsage?: CodexCoreUsageSummary; /** Plan type: free, plus, team, or null if unknown */ planType: 'free' | 'plus' | 'team' | null; /** Timestamp of fetch */