Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +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, 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

Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
57 changes: 57 additions & 0 deletions src/codex/auth-api/main-credit-freshness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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<void> | 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<void> {
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.
// 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
|| (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.
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;
}
2 changes: 2 additions & 0 deletions src/codex/auth-api/pool-mode-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -162,6 +163,7 @@ export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): voi
afterTick: () => {
void runCodexCooldownRecoveryProbes(config);
void runMainAccountHardLockRecovery(config);
void runMainCreditFreshnessRefresh(config);
},
});
}
Expand Down
14 changes: 13 additions & 1 deletion structure/providers/openai-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,22 @@ 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. 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
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`.

Expand Down
225 changes: 225 additions & 0 deletions tests/codex-integration/main-credit-freshness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
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 } 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";
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" | "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 === "retracted"
? null
: { hasCredits: true, balance: credits === "zero" ? 0 : 42.5, observedAt: Date.now() - creditAgeMs },
}, 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<typeof fetch>[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(calls).toEqual([whamUrl]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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();
}
});

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")],
["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();
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([]);
});
});
2 changes: 1 addition & 1 deletion tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading