From 8c675b3da5e79d1ad56b5d028eadcb4b735aaef5 Mon Sep 17 00:00:00 2001 From: Sungyong Cho Date: Tue, 6 Oct 2026 20:31:53 +0200 Subject: [PATCH 1/2] fix(codex): refresh opted-in main credits before they go stale on the recovery sweep --- .../docs/reference/cli/providers-accounts.md | 3 + scripts/test-layout/layout.json | 1 + src/codex/auth-api.ts | 1 + src/codex/auth-api/main-credit-freshness.ts | 55 +++++ src/codex/auth-api/pool-mode-gate.ts | 2 + structure/providers/openai-tiers.md | 13 +- .../main-credit-freshness.test.ts | 221 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 8 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 src/codex/auth-api/main-credit-freshness.ts create mode 100644 tests/codex-integration/main-credit-freshness.test.ts 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 1c9cac81800..9f85261546f 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -315,6 +315,9 @@ list. Receipts are `{ok, id, creditsAfterLimit}` or `{ok, all, ids}`; retain the actual returned IDs and read back `ocx account list openai --json`. Showing credit balances with `--show-codex-credits` does not enable this policy. Do not enable credits to recover from a failed login or quota read. +While the main login has paid-credit use on and a usage window is full, the proxy +re-reads its credit balance in the background every few minutes, so requests keep +using credits without the dashboard being open. ### Quota activation and reset-grant observations diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d8947b9008f..7940a1b1c29 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -73,6 +73,7 @@ "codex-pool-credit-policy.test.ts": "codex-integration", "codex-sidecar-credit-policy.test.ts": "codex-integration", "codex-credits-after-limit-main.test.ts": "codex-integration", + "main-credit-freshness.test.ts": "codex-integration", "service-desktop-startup-health.test.ts": "service", "service-desktop-startup-linux.test.ts": "service", "service-desktop-startup.test.ts": "service", "startup-health-packaged-probe.test.ts": "server", "discovered-native-models.test.ts": "codex-integration", diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index c996aab96f4..aa184c74e67 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -20,6 +20,7 @@ export { listCodexAuthAccountsSnapshot, refreshCodexQuotaForActivation, listCode export type { MainAccountInfoSnapshot } from "./auth-api/main-account-probe"; export { fetchMainAccountInfoSnapshot, fetchMainAccountInfo } from "./auth-api/main-account-probe"; export { PoolQuotaProbeBusyError, seedCodexAuthAdmissionForTests, fetchPoolAccountQuota } from "./auth-api/pool-quota-probe"; +export { runMainCreditFreshnessRefresh, MAIN_CREDITS_REFRESH_AFTER_MS } from "./auth-api/main-credit-freshness"; export type { PrimeCodexPoolQuotasOptions } from "./auth-api/pool-mode-gate"; export { runCodexCooldownRecoveryProbes, diff --git a/src/codex/auth-api/main-credit-freshness.ts b/src/codex/auth-api/main-credit-freshness.ts new file mode 100644 index 00000000000..6536a9e5b59 --- /dev/null +++ b/src/codex/auth-api/main-credit-freshness.ts @@ -0,0 +1,55 @@ +import { codexAccountUsesCreditsAfterLimit, codexUsageLimitResetAt } from "../account-credit-use"; +import { isAccountNeedsReauth } from "../account-runtime-state"; +import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import { getMainQuotaCredentialGeneration } from "../main-account-cache"; +import { getMainAccountHardLockStatus } from "../main-account-hard-lock"; +import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; +import { getMainPolicyQuota } from "../quota"; +import { nextCodexUsageQueryAt } from "../quota-query-backoff"; +import { CODEX_CREDITS_FRESHNESS_MS } from "../quota-types"; +import { captureConfigGeneration } from "../../lib/state-store-sweeper"; +import type { OcxConfig } from "../../types"; +import { fetchMainAccountInfoAttempt } from "./main-account-probe"; + +/** + * Re-read opted-in main credits once they are this old. Usage headers never re-observe a balance + * and nothing on the request path reads WHAM for an already-known main quota, so without this the + * only refresh was the dashboard poll behind a five-minute cache, which reliably lands after + * `CODEX_CREDITS_FRESHNESS_MS` and stops while the dashboard is hidden. Two minutes of margin + * covers the one-minute sweep tick plus a slow WHAM read. + */ +export const MAIN_CREDITS_REFRESH_AFTER_MS = CODEX_CREDITS_FRESHNESS_MS - 2 * 60_000; + +let mainCreditRefreshInFlight: Promise | null = null; + +/** + * Keep the evidence that lets an opted-in, exhausted main login spend credits from expiring on the + * sweep. Metadata only: it never clears a cooldown, a hard lock or a retraction, and it reads WHAM + * only while the main credit hold would otherwise apply. + */ +export async function runMainCreditFreshnessRefresh(config: OcxConfig): Promise { + if (mainCreditRefreshInFlight) return mainCreditRefreshInFlight; + if (!codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)) return; + const now = Date.now(); + const quota = getMainPolicyQuota(); + const credits = quota?.credits; + // Retracted, empty or refused credit evidence cannot lift the hold; leave it to explicit reads. + if (!credits || credits.hasCredits === false || credits.overageLimitReached === true + || credits.allowed === false) return; + if (codexUsageLimitResetAt(quota, undefined, now) === undefined) return; + if (Number.isFinite(credits.observedAt) && now - credits.observedAt < MAIN_CREDITS_REFRESH_AFTER_MS) return; + // The hard lock refuses the main login regardless of credits and paces its own recovery reads. + if (getMainAccountHardLockStatus(config).state === "blocked") return; + if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; + const queryAfter = nextCodexUsageQueryAt(`main:${captureConfigGeneration()}:${getMainQuotaCredentialGeneration()}`); + if ((queryAfter ?? 0) > now) return; + const lease = tryAcquireNativeMainProfileClaim(); + if (!lease) return; + mainCreditRefreshInFlight = fetchMainAccountInfoAttempt(true, 1, lease, false, false, false, config) + .then(() => undefined, () => undefined) + .finally(() => { + lease.release(); + mainCreditRefreshInFlight = null; + }); + return mainCreditRefreshInFlight; +} diff --git a/src/codex/auth-api/pool-mode-gate.ts b/src/codex/auth-api/pool-mode-gate.ts index e3c9393bff7..36c4ebd84f1 100644 --- a/src/codex/auth-api/pool-mode-gate.ts +++ b/src/codex/auth-api/pool-mode-gate.ts @@ -21,6 +21,7 @@ import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http"; import type { PoolQuotaResult } from "./pool-quota-probe"; import { fetchMainAccountInfoAttempt, fetchMainAccountInfo, MAIN_CACHE_TTL } from "./main-account-probe"; +import { runMainCreditFreshnessRefresh } from "./main-credit-freshness"; import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_CACHE_TTL, POOL_QUOTA_REFRESH_CONCURRENCY } from "./pool-quota-probe"; import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config"; @@ -162,6 +163,7 @@ export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): voi afterTick: () => { void runCodexCooldownRecoveryProbes(config); void runMainAccountHardLockRecovery(config); + void runMainCreditFreshnessRefresh(config); }, }); } diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 99202c57eee..eb07b8aba68 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -316,10 +316,21 @@ payloads cannot clear cooldowns, actual request refusals still drive cooldown/fa default-on main-account hard lock retains its separate local admission policy. Registration warmup remains conservative and does not spend paid credits to validate an exhausted account. +Usage headers never re-observe a balance, and the request path does not read WHAM for a main +quota it already holds, so the main login's credit evidence has its own keeper. While main is in +`creditCodexAccountIds`, a usage window is full, the hard lock is not blocking and the cached +credits are still spendable, the existing cooldown-recovery sweep re-reads main WHAM once that +evidence is `MAIN_CREDITS_REFRESH_AFTER_MS` (three minutes) old, through the native-main claim and +the shared usage-query pacing. That lands a new observation before the five-minute horizon, so +the main credit hold never opens a gap between dashboard polls or while the dashboard is hidden. +Retracted, empty or refused credits, accounts without consent and reauth-pending logins make no +read. This is metadata only: it never clears a cooldown, a hard lock or a retraction. + 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-credits-after-limit.test.ts` and -`tests/codex-integration/codex-credits-after-limit-main.test.ts`, alongside the general routing, +`tests/codex-integration/codex-credits-after-limit-main.test.ts`; the main credit refresh is covered in +`tests/codex-integration/main-credit-freshness.test.ts`, alongside the general routing, bulk-pause and recovery suites `tests/codex-integration/codex-routing.test.ts`, `tests/codex-integration/codex-auth-api.test.ts` and `tests/codex-integration/codex-cooldown-recovery.test.ts`. diff --git a/tests/codex-integration/main-credit-freshness.test.ts b/tests/codex-integration/main-credit-freshness.test.ts new file mode 100644 index 00000000000..55ef78234dc --- /dev/null +++ b/tests/codex-integration/main-credit-freshness.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { registerCodexCooldownRecoveryProbeWorker, runMainCreditFreshnessRefresh } from "../../src/codex/auth-api"; +import { MAIN_CREDITS_REFRESH_AFTER_MS } from "../../src/codex/auth-api/main-credit-freshness"; +import { CodexMainAccountCreditsOffError, resolveCodexAuthContext } from "../../src/codex/auth-context"; +import { setCodexAccountCreditsAfterLimit } from "../../src/codex/account-credit-use"; +import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; +import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { setMainAccountPlan } from "../../src/codex/main-account"; +import { captureMainQuotaWriter, clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { CODEX_CREDITS_FRESHNESS_MS } from "../../src/codex/quota-types"; +import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import * as sweeper from "../../src/lib/state-store-sweeper"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const accountId = "credit-freshness-main"; +const whamUrl = "https://chatgpt.com/backend-api/wham/usage"; +const DAY_MS = 24 * 60 * 60_000; +let home: string; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; +let previousFetch: typeof fetch; + +/** The hard lock is off so that only the credits hold can refuse the main login. */ +function config(): OcxConfig { + const cfg: OcxConfig = { + port: 10100, + defaultProvider: "openai", + codexMainAccountHardLock: false, + autoSwitchThreshold: 0, + activeCodexAccountId: MAIN, + providers: { openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + } }, + codexAccounts: [], + }; + setCodexAccountCreditsAfterLimit(cfg, MAIN, true); + return cfg; +} + +/** Encode synthetic account and expiry claims for the fixture; this is not a signed credential. */ +function bearer(): string { + const payload = Buffer.from(JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + 86_400, + "https://api.openai.com/auth": { chatgpt_account_id: accountId }, + })).toString("base64url"); + return `header.${payload}.signature`; +} + +/** Seed the observed main login with a weekly window and a credit balance observed `creditAgeMs` ago. */ +function seedMain(weeklyPercent: number, creditAgeMs: number, credits: "positive" | "retracted" = "positive"): void { + const writer = captureMainQuotaWriter(accountId); + if (!writer) throw new Error("fixture identity must be observed first"); + setAccountQuotaFromParsed(MAIN, { + weeklyPercent, weeklyResetAt: Date.now() + DAY_MS, + credits: credits === "positive" + ? { hasCredits: true, balance: 42.5, observedAt: Date.now() - creditAgeMs } + : null, + }, undefined, writer); +} + +/** Stub WHAM with an exhausted weekly window that still reports a spendable balance. */ +function stubWham(): string[] { + const calls: string[] = []; + globalThis.fetch = Object.assign(async (input: Parameters[0]) => { + const url = String(input); + calls.push(url); + expect(url).toBe(whamUrl); + return Response.json({ + plan_type: "plus", + rate_limit: { primary_window: { + used_percent: 100, limit_window_seconds: 604_800, reset_at: Math.floor((Date.now() + DAY_MS) / 1000), + } }, + credits: { has_credits: true, balance: "42.5", overage_limit_reached: false }, + }); + }, { preconnect: previousFetch.preconnect }); + return calls; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + previousFetch = globalThis.fetch; + home = mkdtempSync(join(tmpdir(), "ocx-main-credit-freshness-")); + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = home; + const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + setIcaclsRunnerForTests(() => aclOk); + setAsyncIcaclsRunnerForTests(async () => aclOk); + clearAccountQuota(); + clearThreadAccountMap(); + clearAccountNeedsReauth(MAIN); + clearCodexUpstreamHealth(); + clearMainAccountInfoCache(); + resetMainCodexAccountIdentityTrackingForTests(); + resetLifecycleDrainStateForTests(); + setMainAccountPlan(null); + writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { + access_token: bearer(), refresh_token: "fixture-refresh", account_id: accountId, + } })); + reconcileMainCodexAccountRuntimeState(); +}); + +afterEach(async () => { + globalThis.fetch = previousFetch; + clearAccountQuota(); + clearThreadAccountMap(); + clearAccountNeedsReauth(MAIN); + clearCodexUpstreamHealth(); + clearMainAccountInfoCache(); + resetMainCodexAccountIdentityTrackingForTests(); + resetLifecycleDrainStateForTests(); + setMainAccountPlan(null); + try { + await flushConfigDirHardeningForTests(); + } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); + } +}); + +describe("main credit freshness refresh", () => { + test("refreshes before the freshness horizon so an opted-in main login keeps serving", async () => { + expect(MAIN_CREDITS_REFRESH_AFTER_MS).toBeLessThan(CODEX_CREDITS_FRESHNESS_MS - 60_000); + const cfg = config(); + seedMain(100, MAIN_CREDITS_REFRESH_AFTER_MS); + const calls = stubWham(); + let now = Date.now(); + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + await runMainCreditFreshnessRefresh(cfg); + expect(calls).toEqual([whamUrl]); + const observedAt = getMainPolicyQuota()?.credits?.observedAt; + expect(observedAt).toBe(now); + // Past the original observation's horizon, the refreshed evidence still serves the request. + now += CODEX_CREDITS_FRESHNESS_MS - MAIN_CREDITS_REFRESH_AFTER_MS + 1; + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) + .resolves.toMatchObject({ kind: "main-pool", accountId: MAIN }); + } finally { clock.mockRestore(); } + }); + + test("without the refresh the same request is refused once the evidence ages out", async () => { + const cfg = config(); + seedMain(100, CODEX_CREDITS_FRESHNESS_MS + 1); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) + .rejects.toBeInstanceOf(CodexMainAccountCreditsOffError); + const calls = stubWham(); + await runMainCreditFreshnessRefresh(cfg); + expect(calls).toEqual([whamUrl]); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) + .resolves.toMatchObject({ kind: "main-pool", accountId: MAIN }); + }); + + test("the existing sweep hook drives the refresh without adding a timer", async () => { + const cfg = config(); + seedMain(100, MAIN_CREDITS_REFRESH_AFTER_MS); + const calls = stubWham(); + let afterTick: (() => void) | undefined; + const registration = spyOn(sweeper, "registerStateSweepAfterTick").mockImplementation(entry => { + afterTick = entry.afterTick; + return () => {}; + }); + const timer = spyOn(globalThis, "setInterval"); + try { + registerCodexCooldownRecoveryProbeWorker(cfg); + expect(afterTick).toBeDefined(); + afterTick!(); + // Wait for the hook's own read; calling the refresh here would start one by itself. + for (let attempt = 0; attempt < 200 && calls.length === 0; attempt++) await Bun.sleep(5); + expect(timer).not.toHaveBeenCalled(); + expect(calls).toEqual([whamUrl]); + } finally { + registration.mockRestore(); + timer.mockRestore(); + } + }); + + test.each([ + ["fresh evidence", () => seedMain(100, 60_000)], + ["included headroom", () => seedMain(50, MAIN_CREDITS_REFRESH_AFTER_MS)], + ["retracted credits", () => seedMain(100, MAIN_CREDITS_REFRESH_AFTER_MS, "retracted")], + ] as const)("%s makes no WHAM request", async (_label, seed) => { + seed(); + const calls = stubWham(); + await runMainCreditFreshnessRefresh(config()); + expect(calls).toEqual([]); + }); + + test("a main login without credit consent makes no WHAM request", async () => { + const cfg = config(); + setCodexAccountCreditsAfterLimit(cfg, MAIN, false); + seedMain(100, MAIN_CREDITS_REFRESH_AFTER_MS); + const calls = stubWham(); + await runMainCreditFreshnessRefresh(cfg); + expect(calls).toEqual([]); + }); + + test("a main login that needs reauthentication makes no WHAM request", async () => { + seedMain(100, MAIN_CREDITS_REFRESH_AFTER_MS); + markAccountNeedsReauth(MAIN); + const calls = stubWham(); + await runMainCreditFreshnessRefresh(config()); + expect(calls).toEqual([]); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 8a385a65ee0..3b9020ae3c1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -78,7 +78,7 @@ "codex-credits-after-limit.test.ts": "codex-integration", "codex-pool-credit-policy.test.ts": "codex-integration", "codex-sidecar-credit-policy.test.ts": "codex-integration", - "codex-credits-after-limit-main.test.ts": "codex-integration", "service-desktop-startup-health.test.ts": "service", "service-desktop-startup-linux.test.ts": "service", + "codex-credits-after-limit-main.test.ts": "codex-integration", "main-credit-freshness.test.ts": "codex-integration", "service-desktop-startup-health.test.ts": "service", "service-desktop-startup-linux.test.ts": "service", "service-desktop-startup.test.ts": "service", "startup-health-packaged-probe.test.ts": "server", "discovered-native-models.test.ts": "codex-integration", "responses-hosted-image-delivery.test.ts": "responses", "responses-hosted-image-display.test.ts": "responses", "responses-hosted-image-limits.test.ts": "responses", From d24100dec5f27df6021aa8c41e56f5745df1f7db Mon Sep 17 00:00:00 2001 From: Sungyong Cho Date: Tue, 6 Oct 2026 20:52:07 +0200 Subject: [PATCH 2/2] fix(codex): address review on main credit refresh guard, docs and sweep test --- .../docs/reference/cli/providers-accounts.md | 8 +++++--- src/codex/auth-api.ts | 1 - src/codex/auth-api/main-credit-freshness.ts | 4 +++- structure/providers/openai-tiers.md | 9 +++++---- .../main-credit-freshness.test.ts | 18 +++++++++++------- 5 files changed, 24 insertions(+), 16 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 9f85261546f..41e65366e37 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -315,9 +315,11 @@ list. Receipts are `{ok, id, creditsAfterLimit}` or `{ok, all, ids}`; retain the actual returned IDs and read back `ocx account list openai --json`. Showing credit balances with `--show-codex-credits` does not enable this policy. Do not enable credits to recover from a failed login or quota read. -While the main login has paid-credit use on and a usage window is full, the proxy -re-reads its credit balance in the background every few minutes, so requests keep -using credits without the dashboard being open. +While the main login has paid-credit use on, a usage window is full and its cached +balance is still spendable, the proxy re-reads that balance in the background every +few minutes, without the dashboard being open. It skips the read while the main +hard lock blocks or the login needs reauthentication; if a read is paced or fails, +requests wait for the next successful read. ### Quota activation and reset-grant observations diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index aa184c74e67..c996aab96f4 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -20,7 +20,6 @@ export { listCodexAuthAccountsSnapshot, refreshCodexQuotaForActivation, listCode export type { MainAccountInfoSnapshot } from "./auth-api/main-account-probe"; export { fetchMainAccountInfoSnapshot, fetchMainAccountInfo } from "./auth-api/main-account-probe"; export { PoolQuotaProbeBusyError, seedCodexAuthAdmissionForTests, fetchPoolAccountQuota } from "./auth-api/pool-quota-probe"; -export { runMainCreditFreshnessRefresh, MAIN_CREDITS_REFRESH_AFTER_MS } from "./auth-api/main-credit-freshness"; export type { PrimeCodexPoolQuotasOptions } from "./auth-api/pool-mode-gate"; export { runCodexCooldownRecoveryProbes, diff --git a/src/codex/auth-api/main-credit-freshness.ts b/src/codex/auth-api/main-credit-freshness.ts index 6536a9e5b59..898995656b7 100644 --- a/src/codex/auth-api/main-credit-freshness.ts +++ b/src/codex/auth-api/main-credit-freshness.ts @@ -34,8 +34,10 @@ export async function runMainCreditFreshnessRefresh(config: OcxConfig): Promise< const quota = getMainPolicyQuota(); const credits = quota?.credits; // Retracted, empty or refused credit evidence cannot lift the hold; leave it to explicit reads. + // Age is deliberately not checked here: a stale positive balance is exactly what to refresh. if (!credits || credits.hasCredits === false || credits.overageLimitReached === true - || credits.allowed === false) return; + || credits.allowed === false + || (credits.unlimited !== true && !(typeof credits.balance === "number" && credits.balance > 0))) return; if (codexUsageLimitResetAt(quota, undefined, now) === undefined) return; if (Number.isFinite(credits.observedAt) && now - credits.observedAt < MAIN_CREDITS_REFRESH_AFTER_MS) return; // The hard lock refuses the main login regardless of credits and paces its own recovery reads. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index eb07b8aba68..125deb40a4d 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -321,10 +321,11 @@ quota it already holds, so the main login's credit evidence has its own keeper. `creditCodexAccountIds`, a usage window is full, the hard lock is not blocking and the cached credits are still spendable, the existing cooldown-recovery sweep re-reads main WHAM once that evidence is `MAIN_CREDITS_REFRESH_AFTER_MS` (three minutes) old, through the native-main claim and -the shared usage-query pacing. That lands a new observation before the five-minute horizon, so -the main credit hold never opens a gap between dashboard polls or while the dashboard is hidden. -Retracted, empty or refused credits, accounts without consent and reauth-pending logins make no -read. This is metadata only: it never clears a cooldown, a hard lock or a retraction. +the shared usage-query pacing. A successful read inside that margin extends the evidence before +the five-minute horizon, independent of dashboard polls; pacing, an unavailable native-main claim +or a failed read can still let it lapse, and the hold then applies as before. Retracted, empty, +zero-balance or refused credits, accounts without consent, a blocking hard lock and reauth-pending +logins make no read. This is metadata only: it never clears a cooldown, a hard lock or a retraction. 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 diff --git a/tests/codex-integration/main-credit-freshness.test.ts b/tests/codex-integration/main-credit-freshness.test.ts index 55ef78234dc..fc0b6b9a3d7 100644 --- a/tests/codex-integration/main-credit-freshness.test.ts +++ b/tests/codex-integration/main-credit-freshness.test.ts @@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { registerCodexCooldownRecoveryProbeWorker, runMainCreditFreshnessRefresh } from "../../src/codex/auth-api"; -import { MAIN_CREDITS_REFRESH_AFTER_MS } from "../../src/codex/auth-api/main-credit-freshness"; +import { registerCodexCooldownRecoveryProbeWorker } from "../../src/codex/auth-api"; +import { MAIN_CREDITS_REFRESH_AFTER_MS, runMainCreditFreshnessRefresh } from "../../src/codex/auth-api/main-credit-freshness"; import { CodexMainAccountCreditsOffError, resolveCodexAuthContext } from "../../src/codex/auth-context"; import { setCodexAccountCreditsAfterLimit } from "../../src/codex/account-credit-use"; import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; @@ -59,14 +59,14 @@ function bearer(): string { } /** Seed the observed main login with a weekly window and a credit balance observed `creditAgeMs` ago. */ -function seedMain(weeklyPercent: number, creditAgeMs: number, credits: "positive" | "retracted" = "positive"): void { +function seedMain(weeklyPercent: number, creditAgeMs: number, credits: "positive" | "zero" | "retracted" = "positive"): void { const writer = captureMainQuotaWriter(accountId); if (!writer) throw new Error("fixture identity must be observed first"); setAccountQuotaFromParsed(MAIN, { weeklyPercent, weeklyResetAt: Date.now() + DAY_MS, - credits: credits === "positive" - ? { hasCredits: true, balance: 42.5, observedAt: Date.now() - creditAgeMs } - : null, + credits: credits === "retracted" + ? null + : { hasCredits: true, balance: credits === "zero" ? 0 : 42.5, observedAt: Date.now() - creditAgeMs }, }, undefined, writer); } @@ -183,8 +183,11 @@ describe("main credit freshness refresh", () => { afterTick!(); // Wait for the hook's own read; calling the refresh here would start one by itself. for (let attempt = 0; attempt < 200 && calls.length === 0; attempt++) await Bun.sleep(5); - expect(timer).not.toHaveBeenCalled(); expect(calls).toEqual([whamUrl]); + // Join the hook's in-flight read so teardown never races it; fresh evidence makes no new read. + await runMainCreditFreshnessRefresh(cfg); + expect(calls).toEqual([whamUrl]); + expect(timer).not.toHaveBeenCalled(); } finally { registration.mockRestore(); timer.mockRestore(); @@ -195,6 +198,7 @@ describe("main credit freshness refresh", () => { ["fresh evidence", () => seedMain(100, 60_000)], ["included headroom", () => seedMain(50, MAIN_CREDITS_REFRESH_AFTER_MS)], ["retracted credits", () => seedMain(100, MAIN_CREDITS_REFRESH_AFTER_MS, "retracted")], + ["a known zero balance", () => seedMain(100, MAIN_CREDITS_REFRESH_AFTER_MS, "zero")], ] as const)("%s makes no WHAM request", async (_label, seed) => { seed(); const calls = stubWham();