From b070215882a9fbec5c845a4133ee73ced72e5d58 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:35:55 +0200 Subject: [PATCH 1/2] fix(codex): clear stale weekly quota on WHAM/header refresh (#382) --- src/codex/auth-api.ts | 37 ++++-------- src/codex/quota.ts | 79 ++++++++++++++++++++++++++ src/server/responses/core.ts | 26 +++------ tests/codex-auth-api.test.ts | 39 +++++++++++++ tests/rate-limit-reset-credits.test.ts | 73 ++++++++++++++++++++++++ 5 files changed, 210 insertions(+), 44 deletions(-) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 83a2746e530..079d7e7bb1f 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -19,11 +19,19 @@ import { getAccountQuota, listAccountQuotas, parseUsageQuota, + setAccountQuotaFromParsed, updateAccountQuota, type StoredAccountQuota, type WhamUsageResponse, } from "./quota"; -export { clearAccountQuota, getAccountQuota, parseUsageQuota, updateAccountQuota } from "./quota"; +export { + applyAccountQuotaFromUpstreamHeaders, + clearAccountQuota, + getAccountQuota, + parseUsageQuota, + setAccountQuotaFromParsed, + updateAccountQuota, +} from "./quota"; import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt"; import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; import { maskEmail } from "../lib/privacy"; @@ -280,14 +288,7 @@ export async function fetchMainAccountInfo(forceRefresh = false): Promise<{ emai // score and auto-switch the main account exactly like a pool account (Option A). setMainAccountPlan(result.plan); if (result.quota) { - updateAccountQuota( - MAIN_CODEX_ACCOUNT_ID, - result.quota.weeklyPercent, - result.quota.weeklyResetAt, - result.quota.monthlyPercent, - result.quota.monthlyResetAt, - result.quota.resetCredits, - ); + setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota); } return result; } catch { @@ -327,14 +328,7 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co const data = (await resp.json()) as WhamUsageResponse; const quota = parseUsageQuota({ ...data, plan_type: data.plan_type ?? configuredPlan }); if (!quota) return { quota: existing ?? null, needsReauth: false }; - updateAccountQuota( - accountId, - quota.weeklyPercent, - quota.weeklyResetAt, - quota.monthlyPercent, - quota.monthlyResetAt, - quota.resetCredits, - ); + setAccountQuotaFromParsed(accountId, quota); return { quota: getAccountQuota(accountId), needsReauth: false }; } catch (e) { if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError) return { quota: existing ?? null, needsReauth: false }; @@ -767,14 +761,7 @@ export async function handleCodexAuthAPI( markCodexAccountValidated(accountId, warmup.validatedAt); clearAccountNeedsReauth(accountId); if (quota) { - updateAccountQuota( - accountId, - quota.weeklyPercent, - quota.weeklyResetAt, - quota.monthlyPercent, - quota.monthlyResetAt, - quota.resetCredits, - ); + setAccountQuotaFromParsed(accountId, quota); } const latestConfig = getRuntimeConfig(config); diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 180e2690ec5..4b66e3667b9 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -28,6 +28,7 @@ type WhamUsageWindow = { }; const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60; +const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60; const accountQuota = new Map(); @@ -65,6 +66,84 @@ function isExplicitMonthlyWindow(window: WhamUsageWindow | null | undefined): bo && seconds >= MONTHLY_WINDOW_MIN_SECONDS; } +function isExplicitMonthlyWindowMinutes(windowMinutes: unknown): boolean { + const minutes = typeof windowMinutes === "number" + ? windowMinutes + : typeof windowMinutes === "string" && windowMinutes.trim() !== "" + ? Number(windowMinutes) + : undefined; + return typeof minutes === "number" + && Number.isFinite(minutes) + && minutes >= MONTHLY_WINDOW_MIN_MINUTES; +} + +export function setAccountQuotaFromParsed( + accountId: string, + quota: Omit | null, +): void { + if (!quota) return; + const next: StoredAccountQuota = { updatedAt: Date.now() }; + if (quota.weeklyPercent !== undefined) next.weeklyPercent = quota.weeklyPercent; + if (quota.weeklyResetAt !== undefined) next.weeklyResetAt = quota.weeklyResetAt; + if (quota.monthlyPercent !== undefined) next.monthlyPercent = quota.monthlyPercent; + if (quota.monthlyResetAt !== undefined) next.monthlyResetAt = quota.monthlyResetAt; + if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits; + accountQuota.set(accountId, next); +} + +export function parseUpstreamQuotaHeaders(headers: Headers): Omit | null { + const primaryRaw = headers.get("x-codex-primary-used-percent"); + const secondaryRaw = headers.get("x-codex-secondary-used-percent"); + const tertiaryRaw = headers.get("x-codex-tertiary-used-percent"); + const primaryResetRaw = headers.get("x-codex-primary-reset-at"); + const secondaryResetRaw = headers.get("x-codex-secondary-reset-at"); + const tertiaryResetRaw = headers.get("x-codex-tertiary-reset-at"); + const primaryWindowMinutes = headers.get("x-codex-primary-window-minutes"); + const secondaryWindowMinutes = headers.get("x-codex-secondary-window-minutes"); + + const quota: Omit = {}; + const primaryPercent = normalizeUsagePercent(primaryRaw); + const secondaryPercent = normalizeUsagePercent(secondaryRaw); + const tertiaryPercent = normalizeUsagePercent(tertiaryRaw); + const primaryResetAt = normalizeResetAt(primaryResetRaw); + const secondaryResetAt = normalizeResetAt(secondaryResetRaw); + const tertiaryResetAt = normalizeResetAt(tertiaryResetRaw); + const primaryIsMonthly = primaryRaw !== null && isExplicitMonthlyWindowMinutes(primaryWindowMinutes); + + if (primaryIsMonthly) { + if (primaryPercent !== undefined) { + quota.monthlyPercent = primaryPercent; + if (primaryResetAt !== undefined) quota.monthlyResetAt = primaryResetAt; + } + if (secondaryPercent !== undefined) { + quota.weeklyPercent = secondaryPercent; + if (secondaryResetAt !== undefined) quota.weeklyResetAt = secondaryResetAt; + } + } else { + const weeklyPercent = primaryPercent ?? secondaryPercent; + const weeklyResetAt = primaryPercent !== undefined + ? primaryResetAt + : secondaryResetAt; + if (weeklyPercent !== undefined) { + quota.weeklyPercent = weeklyPercent; + if (weeklyResetAt !== undefined) quota.weeklyResetAt = weeklyResetAt; + } + } + + if (tertiaryPercent !== undefined) { + quota.monthlyPercent = tertiaryPercent; + if (tertiaryResetAt !== undefined) quota.monthlyResetAt = tertiaryResetAt; + } + + return hasKnownQuotaValue(quota) ? quota : null; +} + +export function applyAccountQuotaFromUpstreamHeaders(accountId: string, headers: Headers): void { + const quota = parseUpstreamQuotaHeaders(headers); + if (!quota) return; + setAccountQuotaFromParsed(accountId, quota); +} + export function updateAccountQuota( accountId: string, weekly: unknown, diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 018751a49bb..ce3063494ac 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1147,25 +1147,9 @@ export async function handleResponses( if (usesCodexForwardPoolAuth(authCtx, route.provider)) { // primary was the 5h window; it now carries weekly data for GPT plans. // Prefer primary when present, fall back to secondary for compatibility. - const primaryRaw = upstreamResponse.headers.get("x-codex-primary-used-percent"); - const secondaryRaw = upstreamResponse.headers.get("x-codex-secondary-used-percent"); - const weeklyRaw = primaryRaw ?? secondaryRaw; - const monthlyRaw = upstreamResponse.headers.get("x-codex-tertiary-used-percent"); - const primaryResetRaw = upstreamResponse.headers.get("x-codex-primary-reset-at"); - const secondaryResetRaw = upstreamResponse.headers.get("x-codex-secondary-reset-at"); - const weeklyResetRaw = primaryRaw ? primaryResetRaw : secondaryResetRaw; - const monthlyResetRaw = upstreamResponse.headers.get("x-codex-tertiary-reset-at"); const retryAfterRaw = upstreamResponse.headers.get("retry-after"); - if (weeklyRaw || monthlyRaw) { - const { updateAccountQuota } = await import("../../codex/auth-api"); - updateAccountQuota( - authCtx.accountId, - weeklyRaw, - weeklyResetRaw, - monthlyRaw, - monthlyResetRaw, - ); - } + const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api"); + applyAccountQuotaFromUpstreamHeaders(authCtx.accountId, upstreamResponse.headers); if (terminalBodyWillRecord) { options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => { terminalRecorder(status, httpStatusOverride); @@ -1174,7 +1158,11 @@ export async function handleResponses( } else { recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, { retryAfter: retryAfterRaw, - resetAt: [primaryResetRaw, secondaryResetRaw, monthlyResetRaw].filter(Boolean), + resetAt: [ + upstreamResponse.headers.get("x-codex-primary-reset-at"), + upstreamResponse.headers.get("x-codex-secondary-reset-at"), + upstreamResponse.headers.get("x-codex-tertiary-reset-at"), + ].filter(Boolean), threadId: req.headers.get("x-codex-parent-thread-id"), }); } diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 2769d399b04..5eda106fb62 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -575,6 +575,45 @@ describe("codex-auth API", () => { } }); + test("GET /api/codex-auth/accounts refresh=1 clears stale weekly for monthly primary-only WHAM", async () => { + const config = makeConfig(); + seedPoolAccount(config, { + id: "monthly-A", + email: "monthly-A@example.com", + accessToken: "tok", + refreshToken: "ref", + chatgptAccountId: "acc-monthly-A", + plan: "team", + }); + updateAccountQuota("monthly-A", 100, 1787401330, 100, 1787401330); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(JSON.stringify({ + plan_type: "team", + rate_limit: { + primary_window: { + used_percent: 100, + limit_window_seconds: 2628000, + reset_at: 1787401330, + }, + secondary_window: null, + }, + }), { status: 200 })) as typeof fetch; + + try { + const req = new Request("http://localhost/api/codex-auth/accounts?refresh=1", { method: "GET" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(200); + const data = await resp!.json() as { accounts: Array<{ id: string; quota?: Record }> }; + const quota = data.accounts.find(a => a.id === "monthly-A")?.quota; + expect(quota).toMatchObject({ monthlyPercent: 100, monthlyResetAt: 1787401330 }); + expect(quota).not.toHaveProperty("weeklyPercent"); + expect(quota).not.toHaveProperty("weeklyResetAt"); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("reset-credit lookup rejects orphaned credential records before upstream fetch", async () => { const config = makeConfig(); saveCodexAccountCredential("orphan", { diff --git a/tests/rate-limit-reset-credits.test.ts b/tests/rate-limit-reset-credits.test.ts index e94ad9b0c92..c0d6884c378 100644 --- a/tests/rate-limit-reset-credits.test.ts +++ b/tests/rate-limit-reset-credits.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "bun:test"; import { + applyAccountQuotaFromUpstreamHeaders, parseUsageQuota, + setAccountQuotaFromParsed, updateAccountQuota, getAccountQuota, clearAccountQuota, @@ -292,4 +294,75 @@ describe("rate-limit reset credits", () => { expect(q!.resetCredits).toBe(1); }); }); + + describe("quota snapshot replacement (issue #382)", () => { + it("clears stale weekly when a monthly-only WHAM snapshot is applied", () => { + clearAccountQuota(); + updateAccountQuota("monthly-A", 100, 1787401330); + const quota = parseUsageQuota({ + plan_type: "team", + rate_limit: { + primary_window: { used_percent: 100, reset_at: 1787401330, limit_window_seconds: 2628000 }, + secondary_window: null, + }, + }); + expect(quota).toEqual({ monthlyPercent: 100, monthlyResetAt: 1787401330 }); + setAccountQuotaFromParsed("monthly-A", quota!); + expect(getAccountQuota("monthly-A")).toEqual({ + monthlyPercent: 100, + monthlyResetAt: 1787401330, + updatedAt: expect.any(Number), + }); + }); + + it("classifies a ~30d primary header as monthly and clears stale weekly", () => { + clearAccountQuota(); + updateAccountQuota("monthly-A", 100, 1787401330); + const headers = new Headers({ + "x-codex-primary-used-percent": "100", + "x-codex-primary-window-minutes": "43800", + "x-codex-primary-reset-at": "1787401330", + }); + applyAccountQuotaFromUpstreamHeaders("monthly-A", headers); + expect(getAccountQuota("monthly-A")).toEqual({ + monthlyPercent: 100, + monthlyResetAt: 1787401330, + updatedAt: expect.any(Number), + }); + }); + + it("keeps weekly primary headers weekly", () => { + clearAccountQuota(); + const headers = new Headers({ + "x-codex-primary-used-percent": "80", + "x-codex-primary-window-minutes": "10080", + "x-codex-primary-reset-at": "1787000000", + }); + applyAccountQuotaFromUpstreamHeaders("weekly-A", headers); + expect(getAccountQuota("weekly-A")).toEqual({ + weeklyPercent: 80, + weeklyResetAt: 1787000000, + updatedAt: expect.any(Number), + }); + }); + + it("maps monthly primary plus secondary weekly headers together", () => { + clearAccountQuota(); + const headers = new Headers({ + "x-codex-primary-used-percent": "39", + "x-codex-primary-window-minutes": "43800", + "x-codex-primary-reset-at": "1787401330", + "x-codex-secondary-used-percent": "20", + "x-codex-secondary-reset-at": "1787000000", + }); + applyAccountQuotaFromUpstreamHeaders("team-A", headers); + expect(getAccountQuota("team-A")).toEqual({ + monthlyPercent: 39, + monthlyResetAt: 1787401330, + weeklyPercent: 20, + weeklyResetAt: 1787000000, + updatedAt: expect.any(Number), + }); + }); + }); }); From 068a9f6562180048716fec1483fea103f1c6bbae Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:32:23 +0200 Subject: [PATCH 2/2] fix(codex): merge quota snapshots without dropping preserved fields --- src/codex/quota.ts | 51 +++++++++++++++++-- tests/rate-limit-reset-credits.test.ts | 68 ++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 4b66e3667b9..f500b3641d1 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -77,17 +77,58 @@ function isExplicitMonthlyWindowMinutes(windowMinutes: unknown): boolean { && minutes >= MONTHLY_WINDOW_MIN_MINUTES; } + +function snapshotHasWeekly(quota: Omit): boolean { + return quota.weeklyPercent !== undefined || quota.weeklyResetAt !== undefined; +} + +function snapshotHasMonthly(quota: Omit): boolean { + return quota.monthlyPercent !== undefined || quota.monthlyResetAt !== undefined; +} + +function snapshotHasUsage(quota: Omit): boolean { + return snapshotHasWeekly(quota) || snapshotHasMonthly(quota); +} export function setAccountQuotaFromParsed( accountId: string, quota: Omit | null, ): void { if (!quota) return; + const existing = accountQuota.get(accountId); const next: StoredAccountQuota = { updatedAt: Date.now() }; - if (quota.weeklyPercent !== undefined) next.weeklyPercent = quota.weeklyPercent; - if (quota.weeklyResetAt !== undefined) next.weeklyResetAt = quota.weeklyResetAt; - if (quota.monthlyPercent !== undefined) next.monthlyPercent = quota.monthlyPercent; - if (quota.monthlyResetAt !== undefined) next.monthlyResetAt = quota.monthlyResetAt; + const creditsOnly = quota.resetCredits !== undefined && !snapshotHasUsage(quota); + + if (creditsOnly) { + if (existing?.weeklyPercent !== undefined) next.weeklyPercent = existing.weeklyPercent; + if (existing?.weeklyResetAt !== undefined) next.weeklyResetAt = existing.weeklyResetAt; + if (existing?.monthlyPercent !== undefined) next.monthlyPercent = existing.monthlyPercent; + if (existing?.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt; + next.resetCredits = quota.resetCredits; + accountQuota.set(accountId, next); + return; + } + + if (snapshotHasWeekly(quota)) { + if (quota.weeklyPercent !== undefined) next.weeklyPercent = quota.weeklyPercent; + if (quota.weeklyResetAt !== undefined) next.weeklyResetAt = quota.weeklyResetAt; + } else if (snapshotHasMonthly(quota) && !snapshotHasWeekly(quota)) { + // Monthly-only snapshots intentionally clear stale weekly values (issue #382). + } else if (existing?.weeklyPercent !== undefined) { + next.weeklyPercent = existing.weeklyPercent; + if (existing.weeklyResetAt !== undefined) next.weeklyResetAt = existing.weeklyResetAt; + } + + if (snapshotHasMonthly(quota)) { + if (quota.monthlyPercent !== undefined) next.monthlyPercent = quota.monthlyPercent; + if (quota.monthlyResetAt !== undefined) next.monthlyResetAt = quota.monthlyResetAt; + } else if (snapshotHasWeekly(quota) && existing?.monthlyPercent !== undefined) { + next.monthlyPercent = existing.monthlyPercent; + if (existing.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt; + } + if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits; + else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits; + accountQuota.set(accountId, next); } @@ -130,7 +171,7 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit { updatedAt: expect.any(Number), }); }); + it("preserves resetCredits when applying header quota snapshots", () => { + clearAccountQuota(); + updateAccountQuota("credits-A", 10, 111, 20, 222, 3); + const headers = new Headers({ + "x-codex-primary-used-percent": "80", + "x-codex-primary-window-minutes": "10080", + "x-codex-primary-reset-at": "1787000000", + }); + applyAccountQuotaFromUpstreamHeaders("credits-A", headers); + expect(getAccountQuota("credits-A")).toEqual({ + weeklyPercent: 80, + weeklyResetAt: 1787000000, + monthlyPercent: 20, + monthlyResetAt: 222, + resetCredits: 3, + updatedAt: expect.any(Number), + }); + }); + + it("keeps tertiary from overriding explicit monthly primary headers", () => { + clearAccountQuota(); + const headers = new Headers({ + "x-codex-primary-used-percent": "39", + "x-codex-primary-window-minutes": "43800", + "x-codex-primary-reset-at": "1787401330", + "x-codex-tertiary-used-percent": "50", + "x-codex-tertiary-reset-at": "1788000000", + }); + applyAccountQuotaFromUpstreamHeaders("team-tertiary", headers); + expect(getAccountQuota("team-tertiary")).toEqual({ + monthlyPercent: 39, + monthlyResetAt: 1787401330, + updatedAt: expect.any(Number), + }); + }); + + it("preserves usage on credits-only WHAM refreshes", () => { + clearAccountQuota(); + updateAccountQuota("credits-only", 10, 111, 20, 222, 1); + const quota = parseUsageQuota({ rate_limit_reset_credits: { available_count: 2 } }); + setAccountQuotaFromParsed("credits-only", quota!); + expect(getAccountQuota("credits-only")).toEqual({ + weeklyPercent: 10, + weeklyResetAt: 111, + monthlyPercent: 20, + monthlyResetAt: 222, + resetCredits: 2, + updatedAt: expect.any(Number), + }); + }); + + it("preserves monthly quota when weekly-only headers arrive", () => { + clearAccountQuota(); + updateAccountQuota("weekly-only", 10, 111, 50, 222); + const headers = new Headers({ + "x-codex-primary-used-percent": "80", + "x-codex-primary-window-minutes": "10080", + "x-codex-primary-reset-at": "1787000000", + }); + applyAccountQuotaFromUpstreamHeaders("weekly-only", headers); + expect(getAccountQuota("weekly-only")).toEqual({ + weeklyPercent: 80, + weeklyResetAt: 1787000000, + monthlyPercent: 50, + monthlyResetAt: 222, + updatedAt: expect.any(Number), + }); + }); it("maps monthly primary plus secondary weekly headers together", () => { clearAccountQuota();