From eb39420d1f46395757884c3b860ee6902fd12569 Mon Sep 17 00:00:00 2001 From: Hylouis233 <88263959+Hylouis233@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:38:02 +0800 Subject: [PATCH 1/2] fix(codex): honor fresh spendable credits at included quota limits --- .../docs/reference/cli/providers-accounts.md | 8 ++ src/codex/quota-types.ts | 34 ++++++++- src/codex/quota.ts | 67 ++++++++++++---- src/codex/routing/cooldown-math.ts | 12 ++- structure/catalog.md | 2 + structure/codex-home.md | 2 + structure/config.md | 2 +- structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/providers/openai-tiers.md | 20 +++++ structure/runtime.md | 2 +- structure/subagents.md | 2 + .../codex-integration/codex-auth-api.test.ts | 22 ++++++ .../codex-quota-parser-parity.test.ts | 76 +++++++++++++++++++ tests/codex-integration/codex-routing.test.ts | 16 ++++ 15 files changed, 249 insertions(+), 20 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 839bc7161b4..64eba85dda9 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -795,6 +795,14 @@ Use `ocx provider add mine --adapter openai-chat --base-url https://example.com/ ### Cached quota history +Automatic OpenAI account exhaustion checks distinguish purchased usage credits from reset +tickets. A fresh positive spendable balance or unlimited credits can keep an account eligible +after its included usage reaches 100%; an explicit upstream refusal or overage limit still +blocks that evidence. Credit evidence expires after five minutes, and usage headers do not +renew its clock. `pause-exhausted` uses this rule. Reset tickets alone never grant automatic +headroom, and a credits-only response cannot clear a request cooldown. The opt-in main-account +hard lock remains a separate local policy. + `ocx account history openai [--limit 1-200] [--json]` reads stored observations without contacting the provider. The output separates actual observation time, WHAM or response-header source, window family and usage percentage. At most 200 observations per account are retained for 30 days, with global storage bounds. Ordinary token refresh preserves history. Reauthentication, removal or account replacement retires the old publication. Native main and probes performed before a login is published are not included. Missing history means insufficient observations, not zero usage. This command does not spend quota. Effective estimates, when supported by observations, carry the limitations below. diff --git a/src/codex/quota-types.ts b/src/codex/quota-types.ts index e15ad5befc2..0f69d38654d 100644 --- a/src/codex/quota-types.ts +++ b/src/codex/quota-types.ts @@ -22,6 +22,31 @@ export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000; */ export const CODEX_EXHAUSTED_USAGE_PERCENT = 100; +/** Credits have a separate clock: ordinary usage headers do not re-observe the balance. */ +export const CODEX_CREDITS_FRESHNESS_MS = 5 * 60_000; + +export interface CodexCredits { + hasCredits?: boolean; + unlimited?: boolean; + balance?: number; + overageLimitReached?: boolean; + allowed?: boolean; + observedAt: number; +} + +export function hasSpendableCodexCredits( + quota: Pick | null, + now = Date.now(), +): boolean { + const credits = quota?.credits; + if (!credits || !Number.isFinite(credits.observedAt)) return false; + const age = now - credits.observedAt; + if (age < 0 || age > CODEX_CREDITS_FRESHNESS_MS + || credits.allowed === false || credits.overageLimitReached === true || credits.hasCredits === false) return false; + return credits.unlimited === true || (credits.hasCredits === true + && typeof credits.balance === "number" && Number.isFinite(credits.balance) && credits.balance > 0); +} + /** * Above this a value is already milliseconds; at or below it, it is Unix seconds. * @@ -80,6 +105,8 @@ export type StoredAccountQuota = { shortWindowSeconds?: number; customWindows?: Array<{ label: string; percent: number; resetAt?: number }>; resetCredits?: number; + /** Spendable usage credits; resetCredits are separate, manually redeemed reset tickets. */ + credits?: CodexCredits | null; /** Monthly usage came from an explicitly monthly PRIMARY, not supplementary tertiary, window. */ monthlyIsPrimaryWindow?: boolean; updatedAt: number; @@ -114,7 +141,12 @@ export type WhamUsageResponse = { secondary_window?: WhamUsageWindow | null; tertiary_window?: WhamUsageWindow | null; }; - credits?: unknown; + credits?: { + has_credits?: unknown; + unlimited?: unknown; + balance?: unknown; + overage_limit_reached?: unknown; + } | null; rate_limit_reset_credits?: { available_count: number } | null; additional_rate_limits?: WhamAdditionalRateLimit[] | null; }; diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 289f0ed7a9a..c27bb8fb4dd 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -11,7 +11,7 @@ import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaW import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history"; import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store"; -import { CODEX_EXHAUSTED_USAGE_PERCENT, MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT, resetAtToMs } from "./quota-types"; +import { CODEX_EXHAUSTED_USAGE_PERCENT, MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT, hasSpendableCodexCredits, resetAtToMs } from "./quota-types"; import type { PoolQuotaWriter, StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types"; export type { StoredAccountQuota, WhamUsageResponse } from "./quota-types"; @@ -83,10 +83,11 @@ function mayCommitAccountQuota(accountId: string, writerGeneration: number): boo // actually exhausted account is still eligible for threshold rotation. export const CODEX_UNKNOWN_USAGE_SCORE = 101; export function isCodexQuotaExhausted( - quota: Pick | null, + quota: Pick | null, plan?: unknown, ): boolean { if (!quota) return false; + if (hasSpendableCodexCredits(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). @@ -118,7 +119,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; @@ -277,6 +278,10 @@ function snapshotHasCustom(quota: Omit): boolea function snapshotHasUsage(quota: Omit): boolean { return snapshotHasWeekly(quota) || snapshotHasMonthly(quota) || snapshotHasShort(quota) || snapshotHasCustom(quota); } + +function snapshotHasCredits(quota: Omit): boolean { + return quota.resetCredits !== undefined || quota.credits !== undefined; +} /** * Publish parsed display quota and separately validated policy evidence after writer checks. * A null policy observation retains only the matching main identity's previous evidence; @@ -296,7 +301,7 @@ export function setAccountQuotaFromParsed( ): void { quota = withoutRetiredCodexQuota(quota); policyQuota = withoutRetiredCodexQuota(policyQuota); - if (!quota || (!snapshotHasUsage(quota) && quota.resetCredits === undefined)) return; + if (!quota || (!snapshotHasUsage(quota) && !snapshotHasCredits(quota))) return; if (!mayCommitAccountQuota(accountId, writerGeneration)) return; const isMain = accountId === MAIN_CODEX_ACCOUNT_ID; if (isMain && mainWriter && !isMainQuotaWriterLive(mainWriter)) return; @@ -343,7 +348,7 @@ export function setAccountQuotaFromParsed( } schedulePersistAccountQuotas(); // Credits carry the previous usage tuple; they must not refresh its observation clock. - if (!(quota.resetCredits !== undefined && !snapshotHasUsage(quota))) { + if (!(snapshotHasCredits(quota) && !snapshotHasUsage(quota))) { if (!isMain && policyQuota && livePoolEvidence) observeCodexLowQuota(accountId, policyQuota); notifyCodexQuotaSnapshot(accountId, next); } @@ -363,7 +368,11 @@ function mergeAccountQuota( quota = withoutRetiredCodexQuota(quota) ?? {}; existing = withoutRetiredCodexQuota(existing ?? null) ?? undefined; const next: StoredAccountQuota = { updatedAt }; - const creditsOnly = quota.resetCredits !== undefined && !snapshotHasUsage(quota); + const creditsOnly = snapshotHasCredits(quota) && !snapshotHasUsage(quota); + // An explicit empty/invalid credits summary retracts the old balance. Partial headers + // retain the original observation clock instead of extending its spending authority. + if (quota.credits !== undefined) next.credits = quota.credits; + else if (existing?.credits !== undefined) next.credits = existing.credits; if (creditsOnly) { if (existing?.weeklyPercent !== undefined) next.weeklyPercent = existing.weeklyPercent; @@ -373,7 +382,8 @@ function mergeAccountQuota( if (existing?.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true; assignCarriedShort(next, existing, updatedAt, policyEvidence); if (existing?.customWindows !== undefined) next.customWindows = existing.customWindows; - next.resetCredits = quota.resetCredits; + if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits; + else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits; return stampCodexQuotaUsageObservation(next, quota, existing); } @@ -525,7 +535,7 @@ export function withoutRetiredCodexQuota 0) next.customWindows = kept; else delete next.customWindows; - return snapshotHasUsage(next) || next.resetCredits !== undefined ? next as T : null; + return snapshotHasUsage(next) || snapshotHasCredits(next) ? next as T : null; } export function parseUpstreamQuotaHeaders(headers: Headers, options?: { modelId?: string }): Omit | null { @@ -644,6 +654,7 @@ export function updateAccountQuota( ...(existing?.monthlyResetAt !== undefined ? { monthlyResetAt: existing.monthlyResetAt } : {}), ...(existing?.customWindows !== undefined ? { customWindows: existing.customWindows } : {}), ...(existing?.resetCredits !== undefined ? { resetCredits: existing.resetCredits } : {}), + ...(existing?.credits !== undefined ? { credits: existing.credits } : {}), updatedAt: Date.now(), }; assignCarriedShort(quota, existing, quota.updatedAt); @@ -702,6 +713,20 @@ function readMainPolicyQuota(value: unknown): MainPolicyQuota | null { if (typeof number === "number" && Number.isFinite(number) && number >= 0) quota[field] = number; } if (quota.monthlyPercent !== undefined && raw.monthlyIsPrimaryWindow === true) quota.monthlyIsPrimaryWindow = true; + // Retain the independent credit timestamp on restart; it still expires at its original deadline. + if (raw.credits === null) quota.credits = null; + else if (raw.credits && typeof raw.credits === "object" && !Array.isArray(raw.credits)) { + const stored = raw.credits as Record; + if (typeof stored.observedAt === "number" && Number.isFinite(stored.observedAt) && stored.observedAt >= 0) { + quota.credits = { observedAt: stored.observedAt }; + for (const key of ["hasCredits", "unlimited", "overageLimitReached", "allowed"] as const) { + if (typeof stored[key] === "boolean") quota.credits[key] = stored[key]; + } + if (typeof stored.balance === "number" && Number.isFinite(stored.balance) && stored.balance >= 0) { + quota.credits.balance = stored.balance; + } + } + } return { identityKey: entry.identityKey, quota }; } @@ -845,7 +870,7 @@ function filterMainPolicyMonthlyQuota( delete filtered.monthlyResetAt; delete filtered.monthlyIsPrimaryWindow; // Null retains the matching prior observation; an empty object would merge away evidence. - return hasKnownQuotaValue(filtered) || filtered.resetCredits !== undefined ? filtered : null; + return hasKnownQuotaValue(filtered) || snapshotHasCredits(filtered) ? filtered : null; } /** @@ -886,11 +911,25 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit = {}; + if (resetCredits !== undefined) quota.resetCredits = resetCredits; + if (data.credits !== undefined) { + quota.credits = null; + if (data.credits && typeof data.credits === "object" && !Array.isArray(data.credits)) { + const raw = data.credits; + const balance = typeof raw.balance === "number" ? raw.balance + : typeof raw.balance === "string" && raw.balance.trim() !== "" ? Number(raw.balance) : undefined; + quota.credits = { + observedAt: Date.now(), + ...(typeof raw.has_credits === "boolean" ? { hasCredits: raw.has_credits } : {}), + ...(typeof raw.unlimited === "boolean" ? { unlimited: raw.unlimited } : {}), + ...(typeof raw.overage_limit_reached === "boolean" ? { overageLimitReached: raw.overage_limit_reached } : {}), + ...(typeof data.rate_limit?.allowed === "boolean" ? { allowed: data.rate_limit.allowed } : {}), + ...(typeof balance === "number" && Number.isFinite(balance) && balance >= 0 ? { balance } : {}), + }; + } + } + if (!data.rate_limit) return snapshotHasCredits(quota) ? quota : null; const thirtyDayOnly = codexQuotaWindowForPlan(data.plan_type) === "monthly"; const primaryWindow = data.rate_limit.primary_window; const secondaryWindow = data.rate_limit.secondary_window; @@ -951,7 +990,7 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit typeof value === "number" && Number.isFinite(value); @@ -126,10 +127,15 @@ export function computeCodexUsageScore(quota: { // correct for uncertainty and wrong for a measured refusal: the account stays selected, // `applyQuotaAutoSwitch` never fires, and the pool wedges on an exhausted credential. if (knownLong.length === 0) { - return isTerminalShortWindow(quota, now) ? CODEX_EXHAUSTED_USAGE_PERCENT : CODEX_UNKNOWN_USAGE_SCORE; + return isTerminalShortWindow(quota, now) + ? hasSpendableCodexCredits(quota, now) ? CODEX_EXHAUSTED_USAGE_PERCENT - 1 : CODEX_EXHAUSTED_USAGE_PERCENT + : CODEX_UNKNOWN_USAGE_SCORE; } const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong; - return Math.max(...values); + const score = Math.max(...values); + // Keep included-capacity accounts ahead of a paid overage account, but do not + // classify the latter as exhausted solely from its subscription percentages. + return hasSpendableCodexCredits(quota, now) ? Math.min(score, CODEX_EXHAUSTED_USAGE_PERCENT - 1) : score; } // `isTerminalShortWindow` moved to ../quota-types, the leaf the dashboard can import. Routing diff --git a/structure/catalog.md b/structure/catalog.md index ce52d3cf05f..1806cb29866 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -596,3 +596,5 @@ Startup and explicit catalog synchronization in `src/codex/sync.ts` refresh the gathering. Each sync waits at most two seconds for a fresh or shared fetch, then continues with the existing snapshot; the fetch retains its own abort deadline. Routed effort reads in `src/reasoning-effort.ts` use a snapshot immediately and request a best-effort background refresh only when an existing snapshot answers with an expired ladder. Missing or corrupt snapshots do not fetch on the request path; catalog sync owns their bootstrap. + +Automatic account exhaustion and recovery use the [spendable Codex credit evidence contract](providers/openai-tiers.md#spendable-codex-credits), including independent freshness, upstream refusal, and reset-ticket separation. diff --git a/structure/codex-home.md b/structure/codex-home.md index 26a215b59fc..749f617ce86 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -465,3 +465,5 @@ Upstream API-key usage follows the [physical-attempt account attribution contrac Stored Direct substitution follows the [credential identity contract](providers/openai-accounts.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged. Native-main owner claims and credential-generation backoff remain authoritative during [priority failback priming](providers/openai-accounts.md#ongoing-priority-failback); the preference grants no access through a fenced main profile. + +Automatic account exhaustion and recovery use the [spendable Codex credit evidence contract](providers/openai-tiers.md#spendable-codex-credits), including independent freshness, upstream refusal, and reset-ticket separation. diff --git a/structure/config.md b/structure/config.md index 25839ef3390..f59f9e1abdb 100644 --- a/structure/config.md +++ b/structure/config.md @@ -498,7 +498,7 @@ Client catalog readiness observes the selected Codex runtime without creating or Client connection metadata stores a stable `apiKeyId` and a non-secret rotation `pendingOperation`. The current data secret remains only in `service-api-token`; a bounded rotation temporarily keeps the old secret in owner-only `service-api-token.prev`. Commit or recovery clears the marker before orphan cleanup. `ocx disconnect` is local-only and leaves remote revocation to the hub's **Integrations → API Keys** page. Hub and local usage stores are not mirrored. -Codex display-cache expiry, retained blocking main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). +Codex display-cache expiry, retained blocking main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history); automatic account decisions also use [spendable credit evidence](providers/openai-tiers.md#spendable-codex-credits). `codexPool.excludedPlans` is interpreted only by automatic selection; its all-excluded and explicit-route behavior follows the [plan exclusion contract](providers/openai-accounts.md#automatic-pool-plan-exclusions). Optional `codexPool.startIdleWindows` defaults off and follows the [idle-window steering contract](providers/openai-accounts.md#idle-window-steering), using real new requests to start observed idle 5-hour windows. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 89c05c8a39d..8f52b33a3e9 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -561,3 +561,5 @@ keep their existing no-catalog-refresh behavior. The regression suite is > Decision record: [Durable provider PATCH](decisions/ADR-0104-durable-provider-patch.md) > Decision record: [Publication-aware rollback](decisions/ADR-0120-provider-patch-publication-boundary.md) + +Automatic account exhaustion and recovery use the [spendable Codex credit evidence contract](providers/openai-tiers.md#spendable-codex-credits), including independent freshness, upstream refusal, and reset-ticket separation. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 3ee968e25c3..4cbb781a0cc 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -595,3 +595,5 @@ Linux release bundling enables Tauri verbosity on the primary attempt so linuxde Universal macOS release builds install both aarch64-apple-darwin and x86_64-apple-darwin Rust targets. Windows builds consume the private JSON override generated by `desktop/scripts/windows-installer-config.ts`: only WiX ProductVersion uses the validated numeric public version core. Public package/application versions, tags, asset names and updater manifests retain full SemVer. The pinned Tauri MSI template permits equal-core replacement; manual MSI installation does not enforce same-core preview/stable downgrade prevention. The existing `codex-routing`, `codex-auth-context` and `codex-quota-prime` tests cover [priority failback](../providers/openai-accounts.md#ongoing-priority-failback), including cache-default retention, stale evidence, main fencing and failed-attempt cadence. + +Automatic account exhaustion and recovery use the [spendable Codex credit evidence contract](../providers/openai-tiers.md#spendable-codex-credits), including independent freshness, upstream refusal, and reset-ticket separation. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 31d1dca99cc..70db2f3d14f 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -295,6 +295,26 @@ refusal, so the two predicates can never both hold. ### Quota cache and short-window history +### Spendable Codex credits + +WHAM `credits` is spendable usage capacity, separate from manually redeemed +`rate_limit_reset_credits`. The quota store keeps normalized flags, a finite nonnegative balance, +and a separate observation clock. Explicit zero/null replaces earlier evidence; partial usage +headers retain its original clock. A positive balance with `has_credits`, or unlimited credits, +can keep an account selectable at 100% included usage for five minutes. Explicit upstream +refusal or an overage limit always defeats that credit evidence. Selection caps its usage score +at 99 so accounts with more included headroom remain preferred; observed percentage bars stay +unchanged. Bulk pause and complete-snapshot recovery use the same credit decision. Credits-only +payloads cannot clear cooldowns, actual request refusals still drive cooldown/failover, and the +opt-in main-account hard lock retains its explicit local admission policy. + +Credit parsing, expiry, partial updates and reset-ticket separation are covered in +`tests/codex-integration/codex-quota-parser-parity.test.ts`; selection and bulk-pause behavior +are covered in `tests/codex-integration/codex-routing.test.ts` and +`tests/codex-integration/codex-auth-api.test.ts`. + +### Short-window retention + `src/codex/quota.ts` drops an omitted account-level short tuple from the display/rotation cache when its stored reset instant has elapsed. Seconds and milliseconds are accepted; future or missing deadlines remain carried, and explicit incoming short readings remain stored. diff --git a/structure/runtime.md b/structure/runtime.md index f988c76a0f6..c77e1b54bcf 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -375,7 +375,7 @@ A withheld token carries its own cause into the reported `reason` through fingerprint mismatch are named separately rather than all reported as a missing data key. Codex display-cache expiry, retained blocking main-policy evidence, and reset history follow the -[quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). +[quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history); automatic exhaustion and recovery also use [spendable credit evidence](providers/openai-tiers.md#spendable-codex-credits). Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](dashboard-and-usage.md#usage-accounting); readable totals are not represented diff --git a/structure/subagents.md b/structure/subagents.md index 3b81fd1978f..c9357f434f8 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -538,3 +538,5 @@ Dashboard Fast-row persistence and client refresh follow the [Fast selector rows The [compaction routing override](transports/responses-failover.md#compaction-routing-overrides) uses explicit request-kind and trigger metadata, independently of spawned-child markers. Ongoing priority failback keeps model-detour and independent-quota affinity isolated; preview remains read-only and no child changes an unrelated shared cursor. The routing details live in [OpenAI account operations](providers/openai-accounts.md#ongoing-priority-failback). + +Automatic account exhaustion and recovery use the [spendable Codex credit evidence contract](providers/openai-tiers.md#spendable-codex-credits), including independent freshness, upstream refusal, and reset-ticket separation. diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 7a6ef7a398a..328e7fe2006 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -4145,6 +4145,28 @@ describe("codex-auth API", () => { expect(resolveCodexAccountForThread("bulk-pause-thread", config)).toBe("free-weekly-only"); }); + test("bulk pause preserves spendable-credit accounts at 100% but pauses empty and reset-only accounts", async () => { + const config = makeConfig({ activeCodexAccountId: "paid" }); + for (const id of ["paid", "empty", "reset-only"]) { + seedPoolAccount(config, { id, email: `${id}@example.test`, plan: "pro", chatgptAccountId: id }); + } + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const id = new Headers(init?.headers).get("ChatGPT-Account-Id"); + return Response.json({ + plan_type: "pro", + rate_limit: { allowed: id === "paid", primary_window: { used_percent: 100, limit_window_seconds: 604800 } }, + rate_limit_reset_credits: { available_count: id === "reset-only" ? 3 : 0 }, + credits: { has_credits: id === "paid", unlimited: false, balance: id === "paid" ? "42.5" : "0" }, + }); + }) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ pausedAccountIds: ["empty", "reset-only"], pausedCount: 2 }); + expect(config.pausedCodexAccountIds).toEqual(["empty", "reset-only"]); + expect(config.activeCodexAccountId).toBe("paid"); + }); + test("bulk pause preserves a known Free main plan when WHAM omits plan_type", async () => { writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: "main-access", account_id: "main-account" }, diff --git a/tests/codex-integration/codex-quota-parser-parity.test.ts b/tests/codex-integration/codex-quota-parser-parity.test.ts index 7c3953e1d7d..4154baed051 100644 --- a/tests/codex-integration/codex-quota-parser-parity.test.ts +++ b/tests/codex-integration/codex-quota-parser-parity.test.ts @@ -3,12 +3,88 @@ import { clearAccountQuota, applyAccountQuotaFromUpstreamHeaders, getAccountQuota, + isCodexQuotaExhausted, + isCompleteCodexQuotaRecoverySnapshot, parseUpstreamQuotaHeaders, parseUsageQuota, setAccountQuotaFromParsed, updateAccountQuota, } from "../../src/codex/quota"; import { codexPoolQuotaEvidence } from "../../src/routing/quota"; +import { computeCodexUsageScore } from "../../src/codex/routing/cooldown-math"; +import type { WhamUsageResponse } from "../../src/codex/quota"; + +describe("consumable credits at an included usage limit", () => { + const wham = (credits: unknown, allowed = true): WhamUsageResponse => ({ + plan_type: "pro", + rate_limit: { allowed, primary_window: { used_percent: 100, limit_window_seconds: 604800 } }, + rate_limit_reset_credits: { available_count: 0 }, + credits, + } as WhamUsageResponse); + const available = { has_credits: true, unlimited: false, overage_limit_reached: false, balance: "42.5" }; + + it("parses spendable balance separately from reset tickets and keeps a full account selectable", () => { + const quota = parseUsageQuota(wham(available))!; + expect(quota).toMatchObject({ weeklyPercent: 100, resetCredits: 0, credits: { balance: 42.5 } }); + expect(isCodexQuotaExhausted(quota, "pro")).toBe(false); + expect(computeCodexUsageScore(quota, "pro")).toBe(99); + expect(isCompleteCodexQuotaRecoverySnapshot(quota, "pro")).toBe(true); + }); + + it("supports unlimited credits without inventing a numeric balance", () => { + const quota = parseUsageQuota(wham({ has_credits: true, unlimited: true, balance: null }))!; + expect(isCodexQuotaExhausted(quota, "pro")).toBe(false); + }); + + it.each([ + null, {}, { ...available, balance: "0" }, { ...available, balance: "-1" }, + { ...available, balance: "NaN" }, { ...available, balance: "Infinity" }, + { ...available, balance: true }, { ...available, has_credits: false }, + { ...available, overage_limit_reached: true }, + ])("does not override exhaustion with unavailable or invalid credits: %j", credits => { + const quota = parseUsageQuota(wham(credits))!; + expect(isCodexQuotaExhausted(quota, "pro")).toBe(true); + expect(computeCodexUsageScore(quota, "pro")).toBe(100); + }); + + it("honors an explicit upstream refusal even with a positive balance", () => { + expect(isCodexQuotaExhausted(parseUsageQuota(wham(available, false)), "pro")).toBe(true); + }); + + it("reset tickets alone do not grant automatic spending headroom", () => { + const data = wham(null); + data.rate_limit_reset_credits = { available_count: 3 }; + expect(isCodexQuotaExhausted(parseUsageQuota(data), "pro")).toBe(true); + }); + + it("credits-only payloads cannot clear a quota cooldown", () => { + const quota = parseUsageQuota({ credits: available } as WhamUsageResponse)!; + expect(quota).toMatchObject({ credits: { balance: 42.5 } }); + expect(isCompleteCodexQuotaRecoverySnapshot(quota, "pro")).toBe(false); + expect(computeCodexUsageScore(quota, "pro")).toBe(101); + }); + + it("partial usage headers preserve the credit clock; explicit null and zero retract it", () => { + clearAccountQuota(); + const parsed = parseUsageQuota(wham(available))!; + setAccountQuotaFromParsed("paid-partial", parsed); + applyAccountQuotaFromUpstreamHeaders("paid-partial", new Headers({ "x-codex-primary-used-percent": "100" })); + expect(getAccountQuota("paid-partial")?.credits).toEqual(parsed.credits); + updateAccountQuota("paid-partial", 100); + expect(getAccountQuota("paid-partial")?.credits).toEqual(parsed.credits); + const expired = { ...parsed, credits: { ...parsed.credits!, observedAt: Date.now() - 300001 } }; + expect(isCodexQuotaExhausted(expired, "pro")).toBe(true); + expect(computeCodexUsageScore(expired, "pro")).toBe(100); + setAccountQuotaFromParsed("paid-partial", parseUsageQuota(wham({ ...available, balance: "0" }))); + expect(isCodexQuotaExhausted(getAccountQuota("paid-partial"), "pro")).toBe(true); + setAccountQuotaFromParsed("paid-partial", parsed); + setAccountQuotaFromParsed("paid-partial", parseUsageQuota(wham(null))); + expect(getAccountQuota("paid-partial")?.credits).toBeNull(); + clearAccountQuota("paid-partial"); + setAccountQuotaFromParsed("paid-partial", { weeklyPercent: 100 }); + expect(getAccountQuota("paid-partial")?.credits).toBeUndefined(); + }); +}); describe("retired and generic quota partial updates", () => { it.each([1, 1000])("legacy quota updates expire short reset units (divisor %s)", divisor => { diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index 7d0d1b689b2..9d5217724af 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -471,6 +471,22 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("new-thread", config)).toBe("b"); }); + test("a paid account remains bound at 100% but real cooldowns still select an alternate", () => { + const config = makeConfig({ autoSwitchThreshold: 99 }); + updateAccountQuota("a", 99); + updateAccountQuota("b", 100); + expect(resolveCodexAccountForThread("paid-affinity", config)).toBe("a"); + setAccountQuotaFromParsed("a", parseUsageQuota({ + rate_limit: { allowed: true, primary_window: { used_percent: 100, limit_window_seconds: 604800 } }, + credits: { has_credits: true, unlimited: false, balance: "42.5" }, + })); + expect(resolveCodexAccountForThread("paid-affinity", config)).toBe("a"); + updateAccountQuota("b", 30); + expect(resolveCodexAccountForThread("new-included-capacity", config)).toBe("b"); + recordCodexUpstreamOutcome(config, "a", 429, { retryAfter: "60" }); + expect(resolveCodexAccountForThread("paid-affinity", config)).toBe("b"); + }); + test("known 100% weekly usage is exhausted, not unknown, and switches accounts", () => { const config = makeConfig(); updateAccountQuota("a", 100); From 64e350ab105accc8a55d66877b2fe67ab4e4fda7 Mon Sep 17 00:00:00 2001 From: Hylouis233 <88263959+Hylouis233@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:46:42 +0800 Subject: [PATCH 2/2] test(codex): keep paid-credit coverage within the file-size gate --- structure/providers/openai-tiers.md | 3 ++- .../codex-cooldown-recovery.test.ts | 21 +++++++++++++++++++ tests/codex-integration/codex-routing.test.ts | 16 -------------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 70db2f3d14f..cb96d94bcff 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -311,7 +311,8 @@ opt-in main-account hard lock retains its explicit local admission policy. Credit parsing, expiry, partial updates and reset-ticket separation are covered in `tests/codex-integration/codex-quota-parser-parity.test.ts`; selection and bulk-pause behavior are covered in `tests/codex-integration/codex-routing.test.ts` and -`tests/codex-integration/codex-auth-api.test.ts`. +`tests/codex-integration/codex-auth-api.test.ts` and +`tests/codex-integration/codex-cooldown-recovery.test.ts`. ### Short-window retention diff --git a/tests/codex-integration/codex-cooldown-recovery.test.ts b/tests/codex-integration/codex-cooldown-recovery.test.ts index d354dc8d3cc..8342e6b4b6a 100644 --- a/tests/codex-integration/codex-cooldown-recovery.test.ts +++ b/tests/codex-integration/codex-cooldown-recovery.test.ts @@ -114,6 +114,27 @@ describe("Codex cooldown recovery worker", () => { if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); }); + test("a paid account remains bound at 100% but real cooldowns still select an alternate", () => { + const config = makeConfig(); + config.autoSwitchThreshold = 99; + config.accountPoolStrategy = "quota"; + saveCredential("a"); + saveCredential("b"); + updateAccountQuota("a", 99); + updateAccountQuota("b", 100); + expect(resolveCodexAccountForThread("paid-affinity", config)).toBe("a"); + setAccountQuotaFromParsed("a", parseUsageQuota({ + rate_limit: { allowed: true, primary_window: { used_percent: 100, limit_window_seconds: 604800 } }, + credits: { has_credits: true, unlimited: false, balance: "42.5" }, + })); + expect(resolveCodexAccountForThread("paid-affinity", config)).toBe("a"); + updateAccountQuota("b", 30); + expect(resolveCodexAccountForThread("new-included-capacity", config)).toBe("b"); + recordCodexUpstreamOutcome(config, "a", 429, { retryAfter: "60" }); + expect(resolveCodexAccountForThread("paid-affinity", config)).toBe("b"); + }); + + test("manual reset bypasses pacing but does not steal a live background lease", () => { const config = makeConfig(["a"]); saveCredential("a"); cool(config, "a"); const manual = claimManualResetCooldowns(config, "a", START + 1); diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index 9d5217724af..7d0d1b689b2 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -471,22 +471,6 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("new-thread", config)).toBe("b"); }); - test("a paid account remains bound at 100% but real cooldowns still select an alternate", () => { - const config = makeConfig({ autoSwitchThreshold: 99 }); - updateAccountQuota("a", 99); - updateAccountQuota("b", 100); - expect(resolveCodexAccountForThread("paid-affinity", config)).toBe("a"); - setAccountQuotaFromParsed("a", parseUsageQuota({ - rate_limit: { allowed: true, primary_window: { used_percent: 100, limit_window_seconds: 604800 } }, - credits: { has_credits: true, unlimited: false, balance: "42.5" }, - })); - expect(resolveCodexAccountForThread("paid-affinity", config)).toBe("a"); - updateAccountQuota("b", 30); - expect(resolveCodexAccountForThread("new-included-capacity", config)).toBe("b"); - recordCodexUpstreamOutcome(config, "a", 429, { retryAfter: "60" }); - expect(resolveCodexAccountForThread("paid-affinity", config)).toBe("b"); - }); - test("known 100% weekly usage is exhausted, not unknown, and switches accounts", () => { const config = makeConfig(); updateAccountQuota("a", 100);