diff --git a/package.json b/package.json index f4e8bbd5c9..178d6a9c7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.24.2", + "version": "2.25.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", diff --git a/src/codex/quota.ts b/src/codex/quota.ts index a4c226db93..994fe7472d 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -246,6 +246,9 @@ export function setAccountQuotaFromParsed( if (existing?.monthlyPercent !== undefined) next.monthlyPercent = existing.monthlyPercent; if (existing?.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt; if (existing?.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true; + if (existing?.shortPercent !== undefined) next.shortPercent = existing.shortPercent; + if (existing?.shortResetAt !== undefined) next.shortResetAt = existing.shortResetAt; + if (existing?.shortWindowSeconds !== undefined) next.shortWindowSeconds = existing.shortWindowSeconds; next.resetCredits = quota.resetCredits; accountQuota.set(accountId, next); schedulePersistAccountQuotas(); @@ -276,6 +279,15 @@ export function setAccountQuotaFromParsed( if (existing.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true; } + // The burst (short) window is upstream-enforced on every plan, so its + // fields must survive parse → cache like the longer windows do: dropping + // them here reported shortPercent=null while the raw payload carried 0%, + // hiding imminent burst exhaustion from the API, dashboard, and routing + // (#2047). + if (quota.shortPercent !== undefined) next.shortPercent = quota.shortPercent; + if (quota.shortResetAt !== undefined) next.shortResetAt = quota.shortResetAt; + if (quota.shortWindowSeconds !== undefined) next.shortWindowSeconds = quota.shortWindowSeconds; + if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits; else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits; diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 7e201ea58a..53a29daf60 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -322,14 +322,23 @@ function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void { export function computeCodexUsageScore(quota: { weeklyPercent?: number; monthlyPercent?: number; + shortPercent?: number; } | null, plan?: unknown): number { if (!quota) return CODEX_UNKNOWN_USAGE_SCORE; + // The burst window counts on every plan (upstream-enforced independently, + // see isCodexQuotaExhausted): a 0% long window with the burst window at + // 100% must not score as idle, or routing picks the account that 429s + // on the very next request (#2047). + const burst = typeof quota.shortPercent === "number" && Number.isFinite(quota.shortPercent) + ? quota.shortPercent + : undefined; if (isThirtyDayOnlyCodexPlan(plan)) { - return typeof quota.monthlyPercent === "number" && Number.isFinite(quota.monthlyPercent) - ? quota.monthlyPercent - : CODEX_UNKNOWN_USAGE_SCORE; + if (typeof quota.monthlyPercent !== "number" || !Number.isFinite(quota.monthlyPercent)) { + return burst !== undefined ? burst : CODEX_UNKNOWN_USAGE_SCORE; + } + return burst !== undefined ? Math.max(quota.monthlyPercent, burst) : quota.monthlyPercent; } - const values = [quota.weeklyPercent, quota.monthlyPercent] + const values = [quota.weeklyPercent, quota.monthlyPercent, burst] .filter((value): value is number => typeof value === "number" && Number.isFinite(value)); return values.length > 0 ? Math.max(...values) : CODEX_UNKNOWN_USAGE_SCORE; } diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index 18d7a21ca0..8b6c6baad6 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -122,6 +122,16 @@ describe("codex routing", () => { expect(computeCodexUsageScore({ weeklyPercent: 15 })).toBe(15); }); + test("usage score counts the burst window on every plan (#2047)", () => { + // A long window at 0-1% with the burst window saturated must not score idle. + expect(computeCodexUsageScore({ weeklyPercent: 1, shortPercent: 100 })).toBe(100); + expect(computeCodexUsageScore({ monthlyPercent: 0, shortPercent: 87 }, "k12")).toBe(87); + expect(computeCodexUsageScore({ weeklyPercent: 15, shortPercent: 9 })).toBe(15); + // Missing burst data keeps the pre-existing behavior (long windows only). + expect(computeCodexUsageScore({ weeklyPercent: 15 })).toBe(15); + expect(computeCodexUsageScore(null)).toBe(CODEX_UNKNOWN_USAGE_SCORE); + }); + test("exact-account failures record health without rotating the active Pool account", () => { const transient = makeConfig({ upstreamFailoverThreshold: 1, activeCodexAccountId: "a" }); const transientThread = "fixed-transient-thread"; diff --git a/tests/rate-limit-reset-credits.test.ts b/tests/rate-limit-reset-credits.test.ts index 122d78c01a..9b1a23660b 100644 --- a/tests/rate-limit-reset-credits.test.ts +++ b/tests/rate-limit-reset-credits.test.ts @@ -445,3 +445,31 @@ describe("rate-limit reset credits", () => { }); }); }); + +// ── short-window quota survives the account cache (#2047) ────────────── + +describe("short-window quota cache (#2047)", () => { + it("setAccountQuotaFromParsed carries the burst window into the cached snapshot", () => { + setAccountQuotaFromParsed("acct-short", { + weeklyPercent: 1, + weeklyResetAt: 1_000, + shortPercent: 0, + shortResetAt: 2_000, + shortWindowSeconds: 18_000, + }); + const snap = getAccountQuota("acct-short"); + expect(snap).not.toBeNull(); + // 0% is data, not missing. + expect(snap!.shortPercent).toBe(0); + expect(snap!.shortResetAt).toBe(2_000); + expect(snap!.shortWindowSeconds).toBe(18_000); + expect(snap!.weeklyPercent).toBe(1); + + // credits-only refresh must not drop the burst window either + setAccountQuotaFromParsed("acct-short", { resetCredits: 12 }); + const carried = getAccountQuota("acct-short")!; + expect(carried.shortPercent).toBe(0); + expect(carried.shortWindowSeconds).toBe(18_000); + expect(carried.resetCredits).toBe(12); + }); +});