diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index be0e2e39ec..190db2bd1f 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -145,6 +145,10 @@ export interface CodexQuotaDto { monthlyPercent?: number; weeklyResetAt?: number; monthlyResetAt?: number; + /** Sub-day burst window, when upstream declares one (#1791). */ + shortPercent?: number; + shortResetAt?: number; + shortWindowSeconds?: number; } export interface ProviderQuotaWindowDto { @@ -183,7 +187,7 @@ interface CodexAccountDto { function projectQuota(quota: CodexQuotaDto | null | undefined): CodexQuotaDto | null { if (!quota) return null; const projected: CodexQuotaDto = {}; - for (const key of ["weeklyPercent", "monthlyPercent", "weeklyResetAt", "monthlyResetAt"] as const) { + for (const key of ["weeklyPercent", "monthlyPercent", "weeklyResetAt", "monthlyResetAt", "shortPercent", "shortResetAt", "shortWindowSeconds"] as const) { if (typeof quota[key] === "number" && Number.isFinite(quota[key])) projected[key] = quota[key]; } return projected; diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index c233630aa2..f2d08188b5 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -217,6 +217,11 @@ function quotaForPlan | StoredAc return { ...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}), ...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}), + // A 30-day plan can still carry a burst window, and it blocks the account on its own. + // Dropping it here would show a healthy card for an account upstream is refusing (#1791). + ...(quota.shortPercent !== undefined ? { shortPercent: quota.shortPercent } : {}), + ...(quota.shortResetAt !== undefined ? { shortResetAt: quota.shortResetAt } : {}), + ...(quota.shortWindowSeconds !== undefined ? { shortWindowSeconds: quota.shortWindowSeconds } : {}), ...(quota.resetCredits !== undefined ? { resetCredits: quota.resetCredits } : {}), ...("updatedAt" in quota ? { updatedAt: quota.updatedAt } : {}), } as T; diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 5e7b6db684..a4c226db93 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -9,6 +9,20 @@ export type StoredAccountQuota = { monthlyPercent?: number; weeklyResetAt?: number; monthlyResetAt?: number; + /** + * A sub-day burst window, when upstream declares one (#1791). + * + * K12 and similar plans enforce a rolling 5-hour limit ALONGSIDE the weekly one. + * Not folding it into `weeklyPercent` stopped the mislabeling, but dropping it + * entirely hides a limit that genuinely blocks the account: a 429 at 100% here is + * real even while the weekly quota is untouched. + * + * `shortWindowSeconds` is retained because the duration is the only thing that makes + * this window self-describing; the slot it arrived in is not stable across plans. + */ + shortPercent?: number; + shortResetAt?: number; + shortWindowSeconds?: number; resetCredits?: number; /** * True when `monthlyPercent` came from an explicitly-monthly PRIMARY window — @@ -85,13 +99,16 @@ export const CODEX_UNKNOWN_USAGE_SCORE = 101; export const CODEX_EXHAUSTED_USAGE_PERCENT = 100; export function isCodexQuotaExhausted( - quota: Pick | null, + quota: Pick | null, plan?: unknown, ): boolean { if (!quota) return false; + // The burst window counts on EVERY plan. It is upstream-enforced independently, so an + // account at 100% there is blocked regardless of which longer window governs its plan; + // omitting it would route traffic straight into a 429 (#1791). const values = codexQuotaWindowForPlan(plan) === "monthly" - ? [quota.monthlyPercent] - : [quota.weeklyPercent, quota.monthlyPercent]; + ? [quota.monthlyPercent, quota.shortPercent] + : [quota.weeklyPercent, quota.monthlyPercent, quota.shortPercent]; return values.some(value => typeof value === "number" && Number.isFinite(value) && value >= CODEX_EXHAUSTED_USAGE_PERCENT); @@ -117,7 +134,7 @@ export function codexQuotaWindowForPlan(plan?: unknown): "monthly" | "weekly" { } export function isCompleteCodexQuotaRecoverySnapshot( - quota: Pick | null, + quota: Pick | null, plan?: unknown, ): boolean { if (!quota || isCodexQuotaExhausted(quota, plan)) return false; @@ -494,6 +511,14 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit { })).toMatchObject({ weeklyPercent: 20, weeklyResetAt: 2 }); }); + test("a sub-day primary window is KEPT as its own burst window (#1791)", () => { + // Not masquerading as weekly was only half the fix. The 5-hour reading is a real + // upstream-enforced limit -- the issue reports it at 99% remaining alongside a + // separate weekly limit -- so discarding it hides a window that genuinely gates + // the account. Both windows must survive parsing with independent resets. + expect(parseUsageQuota({ + plan_type: "k12", + rate_limit: { + primary_window: { used_percent: 1, reset_at: 2000000000, limit_window_seconds: 18000 }, + secondary_window: { used_percent: 0, reset_at: 2000586800, limit_window_seconds: 604800 }, + }, + })).toMatchObject({ + shortPercent: 1, + shortResetAt: 2000000000, + shortWindowSeconds: 18000, + weeklyPercent: 0, + weeklyResetAt: 2000586800, + }); + }); + + test("an exhausted burst window takes the account out of rotation (#1791)", () => { + // Upstream enforces the 5-hour window independently, so an account at 100% there is + // genuinely blocked even while its weekly quota is untouched. Reporting it as usable + // would route traffic straight into a 429. + const quota = parseUsageQuota({ + plan_type: "k12", + rate_limit: { + primary_window: { used_percent: 100, reset_at: 2000000000, limit_window_seconds: 18000 }, + secondary_window: { used_percent: 10, reset_at: 2000586800, limit_window_seconds: 604800 }, + }, + }); + expect(isCodexQuotaExhausted(quota, "k12")).toBe(true); + }); test("a primary window with no declared duration is still treated as weekly (#1791)", () => { // Older payloads omit limit_window_seconds entirely. Guessing there would reclassify // every legacy account, so an undeclared duration keeps the historical behavior.