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 @@ -106,6 +106,9 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR
| `cacheRetention?` | `"none" \| "short" \| "long"` | first-party Anthropic: `"long"`; other Anthropic-compatible endpoints: `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. When unset, requests to `api.anthropic.com` (API key or the OAuth account pools) use the 1-hour TTL, because agentic turns often pause longer than five minutes and a 1-hour write (2x base input instead of 1.25x) pays for itself on the first reuse; Anthropic-compatible gateways and vendor endpoints keep the 5-minute TTL because their 1-hour support is undocumented. On native `/v1/messages` passthrough, caller breakpoints without a `ttl` get `"1h"` unless the request pins any breakpoint to `"5m"`, in which case it is forwarded unchanged. On a translated `/v1/messages` route, caller breakpoints that all ask for `ttl: "1h"` mean `"long"` and any explicit `"5m"` means `"short"`. An explicit value always wins. Changing the TTL makes each open conversation write its cache once more, since 5-minute and 1-hour entries are separate. Cost estimates price writes reported as 1-hour at the 1-hour rate. |
| `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy. |

Opted-in pool accounts with a full included usage window refresh their credit evidence through the existing background sweep from three minutes of age. Ordinary response headers do not refresh credit balances. A failed metadata read never extends the five-minute spending-evidence lifetime.


Across these strategies, each account's effective threshold is its `codexAccountAutoSwitchThresholds` entry when present, otherwise global `autoSwitchThreshold`. A zero override disables only proactive usage switching; startup binding, hard-lock, cooldown, entitlement checks, and failure recovery remain enforced.

Setting an account's switching threshold to `0` does not disable main-account hard-lock, startup
Expand Down
25 changes: 23 additions & 2 deletions src/codex/auth-api/pool-mode-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da

const MAIN_CREDIT_RENEWAL_AGE_MS = 3 * 60_000;

/** Traffic headers renew usage, never the independent credit observation (#7377). */
function poolCreditRenewalDue(config: OcxConfig, accountId: string, now = Date.now()): boolean {
const account = configuredPoolAccount(config, accountId);
if (!account || !codexAccountUsesCreditsAfterLimit(config, accountId)
|| isCodexAccountPaused(config, accountId) || isAccountNeedsReauth(accountId)) return false;
const quota = getAccountQuota(accountId);
if (codexUsageLimitResetAt(quota, account.plan, now) === undefined) return false;
const observedAt = quota?.credits?.observedAt;
if (observedAt !== undefined && Number.isFinite(observedAt)
&& now - observedAt >= 0 && now - observedAt < MAIN_CREDIT_RENEWAL_AGE_MS) return false;
const attempt = poolQuotaPrimeAttemptedAt.get(accountId);
return !attempt || attempt.generation !== readCodexAccountRecord(accountId)?.generation
|| now - attempt.at >= MAIN_CREDIT_RENEWAL_AGE_MS;
}

let mainHardLockRecoveryInFlight: Promise<void> | null = null;
let mainHardLockRecoveryAttempt: { identity: number; credential: number; after: number; delay: number } | undefined;

Expand Down Expand Up @@ -186,6 +201,7 @@ export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): voi
afterTick: () => {
void runCodexCooldownRecoveryProbes(config);
void runMainAccountHardLockRecovery(config);
void primeCodexPoolQuotas(config, "credit-renewal");
},
});
}
Expand Down Expand Up @@ -265,6 +281,7 @@ export async function primeCodexPoolQuotas(
primeInFlight = trackBackgroundWork(`codex-pool-quota-prime:${reason}`, (async () => {
const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
const stale = pool.filter(a => {
if (reason === "credit-renewal") return poolCreditRenewalDue(runtimeConfig, a.id);
const q = getAccountQuota(a.id);
const observationStale = reason === "priority-failback" && q
&& !codexQuotaHasFreshUsage(q, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS);
Expand All @@ -278,7 +295,10 @@ export async function primeCodexPoolQuotas(
if (lastAttempt.generation !== readCodexAccountRecord(a.id)?.generation) return true;
return Date.now() - lastAttempt.at >= POOL_CACHE_TTL;
});
if (reason === "credit-renewal" && stale.length === 0) return;
const primeMain = async () => {
// Main renewal owns its physical-identity claims and passive probe separately.
if (reason === "credit-renewal") return;
const mainLease = tryAcquireNativeMainPrimeLease();
if (!mainLease) return;
try {
Expand Down Expand Up @@ -311,11 +331,12 @@ export async function primeCodexPoolQuotas(
primeMain(),
mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
if (!getCodexAccountCredential(a.id)) return;
if (reason === "credit-renewal" && !poolCreditRenewalDue(getRuntimeConfig(config), a.id)) return;
let result: PoolQuotaResult;
try {
const quota = getAccountQuota(a.id);
const bypassCachedQuota = reason === "priority-failback" && quota !== null
&& !codexQuotaHasFreshUsage(quota, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS);
const bypassCachedQuota = reason === "credit-renewal" || (reason === "priority-failback" && quota !== null
&& !codexQuotaHasFreshUsage(quota, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS));
result = await fetchPoolAccountQuota(a.id, bypassCachedQuota, a.plan, getValidPoolTokenForPrime);
} catch (error) {
// Local quota-flight saturation proves no WHAM request existed for this account.
Expand Down
2 changes: 2 additions & 0 deletions structure/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Per-vendor contracts and the adapter authority that constructs them.
| [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | Anthropic OAuth instances (primary and Pool 2), account pause, model routes, quota labels, and pool-bound helpers, quota, reset grants and management surfaces. |
| [`providers/oauth-code-display-login.md`](providers/oauth-code-display-login.md) | Headless code-display OAuth login: flow ownership, expiry, unsupported providers, single-operator store. |
| [`providers/openai-tiers.md`](providers/openai-tiers.md) | Pool/Direct account modes, API-key separation, and the public provider and quota contract. |
| [`providers/pool-credit-renewal.md`](providers/pool-credit-renewal.md) | Independent credit-evidence renewal for opted-in exhausted Codex pool accounts. |
| [`providers/openai-accounts.md`](providers/openai-accounts.md) | Migration and restore, wire identity, store concurrency, pool ordering and exclusions, quota observations, and account-bound retention. |
| [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | Opt-in effective priority bands that spend other pool accounts before the signed-in identity, its identity observation and management API. |
| [`providers/cursor.md`](providers/cursor.md) | Cursor native exec, parameterized models, checkpoints, and active-context usage. |
Expand Down Expand Up @@ -185,6 +186,7 @@ A source area can be described by more than one doc, because these docs are orga
| `src/codex/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) |
| `src/codex/app-server-processes.ts` | [`codex-cli-update.md`](codex-cli-update.md) |
| `src/codex/auth-api/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) |
| `src/codex/auth-api/pool-mode-gate.ts` | [`providers/pool-credit-renewal.md`](providers/pool-credit-renewal.md) |
| `src/codex/catalog/effort.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md)<br>[`catalog-effort-clamp.md`](catalog-effort-clamp.md) |
| `src/codex/catalog/model-hints.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) |
| `src/codex/catalog/ownership.ts` | [`catalog-routed-ownership.md`](catalog-routed-ownership.md) |
Expand Down
15 changes: 15 additions & 0 deletions structure/decisions/ADR-7377-pool-credit-renewal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# ADR-7377: decision recorded under "Pool credit renewal"

- Purpose: keep an explicitly opted-in credited pool account observable after its included usage
fills, without requiring a dashboard/manual metadata read every five minutes (#7377).
- Constraint: response headers update aggregate usage but do not observe credit balance. Reusing
aggregate freshness can therefore starve credit refresh while normal requests keep arriving.
- Alternatives: extend credit validity from traffic, add another polling loop, or reuse the existing
sweep and bounded pool quota primer. Extending validity invents spendable balance evidence;
another loop duplicates lifecycle/concurrency ownership.
- Decision: a pool-only prime reason selects exhausted, opted-in eligible accounts by the independent
credit clock, bypasses aggregate cache freshness, and reuses generation-aware probe/backoff owners.
- Consequences: metadata work starts from three minutes of evidence age with a matching minimum
per-credential retry gap. Native main keeps its separate passive/physical-identity path. Failed
reads do not make an account selectable or authorize any inference. A network outage can still
let credits expire; admission correctly remains closed until fresh evidence returns.
7 changes: 7 additions & 0 deletions structure/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,13 @@
"src/codex/"
]
},
{
"path": "providers/pool-credit-renewal.md",
"tier": 4,
"title": "Pool credit renewal",
"scope": "Independent credit-evidence renewal for opted-in exhausted Codex pool accounts.",
"documents": ["src/codex/auth-api/pool-mode-gate.ts"]
},
{
"path": "providers/openai-accounts.md",
"tier": 4,
Expand Down
2 changes: 1 addition & 1 deletion structure/providers/openai-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ including failures, while preserving main-owner claims and per-credential WHAM d
No requests means no new polling. For this reason only, stale observation proof bypasses aggregate
quota-cache freshness after the existing attempt backoff. Main refresh keeps its owned lease and
passive intent: cache bypass does not clear an inference reauthentication mark. Other prime reasons
retain their existing cache rules. The split config schema degrades malformed optional values to
retain their existing cache rules except the independently scheduled [pool credit renewal](pool-credit-renewal.md). The split config schema degrades malformed optional values to
false. Exact-account and Direct routes are unchanged.

## Main-account policy observations
Expand Down
2 changes: 1 addition & 1 deletion structure/providers/openai-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ identity retries: WHAM success and terminal 401/403 responses never set or clear
this mode. Native token preparation also preserves the traffic quarantine. Other callers retain
the existing auth behavior. Failed or incomplete observations never renew the credit clock;
refreshing usage does not redeem reset credits or validate pending accounts through inference.
Coverage: `tests/codex-integration/main-account-credit-renewal.test.ts`.
Coverage: `tests/codex-integration/main-account-credit-renewal.test.ts`. Pool accounts use the separate [pool credit renewal](pool-credit-renewal.md) contract; their fresh spendable-evidence requirement is identical.

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
Expand Down
24 changes: 24 additions & 0 deletions structure/providers/pool-credit-renewal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Pool credit renewal

`src/codex/auth-api/pool-mode-gate.ts` renews independently aged pool credit evidence on the
existing `codex-cooldown-recovery` after-tick hook, using `primeCodexPoolQuotas` with reason
`credit-renewal`. No extra timer or inference request is added.

Only configured pool accounts opted into `creditCodexAccountIds` with a currently exhausted
included window qualify. Paused, reauthentication-required, below-limit and non-opted-in accounts
are skipped. A credit observation younger than three minutes is retained; due or missing evidence
uses the existing bounded-concurrency quota reader with cache bypass, because traffic headers can
keep aggregate usage fresh while the independent credit clock expires.

The existing per-credential attempt map bounds retries to at least three minutes. Query backoff,
request admission and generation/configuration publication fences remain owned by the quota reader.
Only actual WHAM evidence advances the credit clock. A failed read leaves stale evidence unusable;
no credit freshness limit or spending consent is relaxed. Negative evidence remains discoverable.
The eligibility check runs again before each queued account read. The pool-only reason does not
claim, reconcile or read native main; its separate renewal owner is unchanged.

Regression coverage: `tests/codex-integration/codex-quota-prime.test.ts` exercises pre-expiry and
expired evidence, admission after a fresh read, the sweep entry, no native-main work, exclusions
and failed-read backoff. Tests use fake metadata and private homes, not paid inference.

> Decision record: [ADR-7377](../decisions/ADR-7377-pool-credit-renewal.md)
84 changes: 84 additions & 0 deletions tests/codex-integration/codex-quota-prime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import {
saveCodexAccountCredential,
} from "../../src/codex/account-store";
import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle";
import { isCodexAccountHeldForCredits } from "../../src/codex/account-credit-use";
import * as sweeper from "../../src/lib/state-store-sweeper";
import { registerCodexCooldownRecoveryProbeWorker } from "../../src/codex/auth-api/pool-mode-gate";
import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account";
import {
completeNativeMainRecovery,
Expand Down Expand Up @@ -142,6 +145,87 @@ describe("primeCodexPoolQuotas", () => {
if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR);
});

test.each([180_000, 360_000])("credit renewal at %d ms reads WHAM despite fresh traffic headers and never touches native main (#7377)", async age => {
const config = makeConfig({ creditCodexAccountIds: ["p1"] });
seedPoolAccount(config, "p1", "plus");
const now = Date.now();
setAccountQuotaFromParsed("p1", { shortPercent: 100, shortResetAt: now + 3_600_000,
shortObservedAt: now, credits: { hasCredits: true, balance: 5, allowed: true, observedAt: now - age } });
expect(isCodexAccountHeldForCredits(config, "p1", getAccountQuota("p1"), "plus", now)).toBe(age > 300_000);
// A real response header updates usage, not the older credit observation.
updateAccountQuota("p1", 10);
const originalFetch = globalThis.fetch;
const probed: string[] = [];
try {
globalThis.fetch = (async (input, init) => {
expect(String(input)).toContain("/backend-api/wham/usage");
probed.push(new Headers(init?.headers).get("chatgpt-account-id")!);
return Response.json({ plan_type: "plus", rate_limit: {
primary_window: { used_percent: 100, reset_at: Math.floor((now + 3_600_000) / 1000), limit_window_seconds: 18_000 },
}, credits: { has_credits: true, balance: 5, overage_limit_reached: false }, spend_control: { reached: false } });
}) as typeof fetch;
let mainAccesses = 0;
const noMain = () => { mainAccesses++; throw new Error("credit renewal must not access native main"); };
await primeCodexPoolQuotas(config, "credit-renewal", { reconcileMainAccount: noMain, readMainTokens: noMain, fetchMainInfo: noMain });
expect(mainAccesses).toBe(0);
expect(probed).toEqual(["acct-p1"]);
expect(getAccountQuota("p1")?.credits?.observedAt).toBeGreaterThanOrEqual(now);
expect(isCodexAccountHeldForCredits(config, "p1", getAccountQuota("p1"), "plus", Date.now())).toBe(false);
let afterTick: (() => void) | undefined;
const registration = spyOn(sweeper, "registerStateSweepAfterTick").mockImplementation(entry => {
afterTick = entry.afterTick; return () => {};
});
try {
// Stale credit evidence is independently renewable on the existing sweep.
clearCodexQuotaPrimeState();
setAccountQuotaFromParsed("p1", { credits: { hasCredits: true, balance: 5, allowed: true, observedAt: now - age } });
registerCodexCooldownRecoveryProbeWorker(config);
expect(afterTick).toBeDefined();
afterTick!();
await primeCodexPoolQuotas(config, "credit-renewal");
expect(probed).toHaveLength(2);
await primeCodexPoolQuotas(config, "credit-renewal");
expect(probed).toHaveLength(2);
} finally { registration.mockRestore(); }
} finally { globalThis.fetch = originalFetch; }
});

test("credit renewal excludes non-opted-in, paused, reauth, below-limit and fresh-credit accounts", async () => {
const config = makeConfig({ creditCodexAccountIds: ["paused", "reauth", "below", "fresh"], pausedCodexAccountIds: ["paused"] });
const now = Date.now();
for (const id of ["off", "paused", "reauth", "below", "fresh"]) {
seedPoolAccount(config, id, "plus");
setAccountQuotaFromParsed(id, { shortPercent: id === "below" ? 50 : 100, shortResetAt: now + 3_600_000,
shortObservedAt: now, credits: { hasCredits: true, balance: 5, allowed: true, observedAt: now - (id === "fresh" ? 0 : 180_000) } });
}
markAccountNeedsReauth("reauth");
const originalFetch = globalThis.fetch;
let calls = 0;
try {
globalThis.fetch = (async () => { calls++; throw new Error("no eligible credit renewal"); }) as typeof fetch;
await primeCodexPoolQuotas(config, "credit-renewal");
expect(calls).toBe(0);
expect(isAccountNeedsReauth("reauth")).toBe(true);
} finally { globalThis.fetch = originalFetch; clearAccountNeedsReauth("reauth"); }
});

test("failed credit renewal backs off without making stale credits spendable", async () => {
const config = makeConfig({ creditCodexAccountIds: ["p1"] });
seedPoolAccount(config, "p1", "plus");
const now = Date.now();
setAccountQuotaFromParsed("p1", { shortPercent: 100, shortResetAt: now + 3_600_000,
shortObservedAt: now, credits: { hasCredits: true, balance: 5, allowed: true, observedAt: now - 360_000 } });
const originalFetch = globalThis.fetch;
let calls = 0;
try {
globalThis.fetch = (async () => { calls++; return new Response(null, { status: 503 }); }) as typeof fetch;
await primeCodexPoolQuotas(config, "credit-renewal");
await primeCodexPoolQuotas(config, "credit-renewal");
expect(calls).toBe(1);
expect(getAccountQuota("p1")?.credits?.observedAt).toBe(now - 360_000);
} finally { globalThis.fetch = originalFetch; }
});

test("startup may observe pending account quota without inference or validation", async () => {
const config = makeConfig();
seedPoolAccount(config, "pending", "pro");
Expand Down
Loading