From 04c1e9026b54e43d6fc484aaf4c42eb2588a1bfc Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 17 Aug 2026 02:38:57 +0900 Subject: [PATCH] fix(quota): keep the sub-day burst window instead of discarding it #1791, second half. The earlier fix stopped a 5-hour primary window from being written into `weeklyPercent` -- so the dashboard no longer labels a 5-hour bar as "Week" -- but it did that by dropping the reading entirely. The issue reports both windows as live upstream limits (5-hour at 99% remaining, weekly at 100%), so a K12 account could sit at 100% of its 5-hour quota while opencodex showed a healthy weekly bar and kept routing into a guaranteed 429. Store it. `shortPercent` / `shortResetAt` / `shortWindowSeconds` carry the burst window with its own reset, next to the weekly one rather than instead of it. Exhaustion counts it on EVERY plan, including 30-day-only ones: upstream enforces this window independently of whichever longer window governs the plan, so an account full here is blocked no matter what the weekly or monthly reading says. The same field flows through the recovery snapshot, so a cooldown cannot clear while the burst window is still full, and through the dashboard and CLI DTOs so a user can see the limit that is actually holding them. The duration is retained rather than the slot it arrived in: the slot is not stable across plans, and the duration is the only thing that makes the window self-describing. Verification: two new cases in tests/codex-routing.test.ts use the sanitized K12 payload from the issue verbatim -- both windows survive with independent resets, and a full burst window marks the account exhausted. Driven red by disabling the capture, which reproduces the discarded reading exactly. 358 tests green across routing, cooldown recovery, reset credits and auth-api; `bun x tsc --noEmit` clean. --- src/cli/account-api.ts | 6 +++++- src/codex/auth-api.ts | 5 +++++ src/codex/quota.ts | 33 +++++++++++++++++++++++++++++---- tests/codex-routing.test.ts | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 5 deletions(-) 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.