Skip to content
Merged
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
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
"codex-config-preservation.test.ts": "codex-integration",
"codex-credits.test.ts": "codex-integration",
"codex-credits-settings.test.ts": "codex-integration",
"codex-credits-probes.test.ts": "codex-integration",
"codex-credits-probes.test.ts": "codex-integration", "pool-monthly-window-retirement.test.ts": "codex-integration",
"codex-credits-after-limit.test.ts": "codex-integration",
"codex-pool-credit-policy.test.ts": "codex-integration", "codex-credit-retry-after.test.ts": "codex-integration", "codex-credit-scoped-retry.test.ts": "codex-integration",
"codex-sidecar-credit-policy.test.ts": "codex-integration",
Expand Down
28 changes: 22 additions & 6 deletions src/codex/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type QuotaDiskFile = {

type MainPolicyQuota = { identityKey: string; quota: StoredAccountQuota };
/** Fresh WHAM topology proof is consumed by the merge, never retained in a cache or DTO. */
type MainPolicyQuotaObservation = Omit<StoredAccountQuota, "updatedAt"> & { shortWindowAbsent?: true };
type MainPolicyQuotaObservation = Omit<StoredAccountQuota, "updatedAt"> & { shortWindowAbsent?: true; monthlyWindowAbsent?: true };
let mainPolicyQuota: MainPolicyQuota | null = null;
let diskHydrated = false;
let persistTimer: ReturnType<typeof setTimeout> | null = null;
Expand Down Expand Up @@ -343,9 +343,12 @@ export function setAccountQuotaFromParsed(
// Retiring a short tuple releases a routing block, so it needs the same writer proof as
// history: a live main writer for main, a live captured pool writer for pool requests.
const mayRetireShort = isMain ? mainWriter !== undefined : livePoolEvidence;
const displayQuota: MainPolicyQuotaObservation = mayRetireShort && policyQuota?.shortWindowAbsent === true
? { ...quota, shortWindowAbsent: true }
: quota;
// Monthly-window absence (#8067) comes from the same complete topology and needs the same proof.
const displayQuota: MainPolicyQuotaObservation = {
...quota,
...(mayRetireShort && policyQuota?.shortWindowAbsent === true ? { shortWindowAbsent: true as const } : {}),
...(mayRetireShort && policyQuota?.monthlyWindowAbsent === true ? { monthlyWindowAbsent: true as const } : {}),
};
const next = mergeAccountQuota(displayQuota, legacyExisting, updatedAt);
accountQuota.set(accountId, next);
if (isMain) {
Expand Down Expand Up @@ -448,8 +451,11 @@ function mergeAccountQuota(
// while silently dropping `monthlyIsPrimaryWindow` would look like tertiary-only data to
// any future reader, and that failure would be invisible.
if (quota.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
} else if ((snapshotHasWeekly(quota) || snapshotHasShort(quota) || snapshotHasCustom(quota))
} else if (quota.monthlyWindowAbsent !== true
&& (snapshotHasWeekly(quota) || snapshotHasShort(quota) || snapshotHasCustom(quota))
&& existing?.monthlyPercent !== undefined) {
// A complete topology with no monthly-length window retires the carried tuple, so a plan
// change away from a monthly primary (Free to Pro Lite) cannot keep its old exhausted reading.
next.monthlyPercent = existing.monthlyPercent;
if (existing.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt;
if (existing.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
Expand Down Expand Up @@ -991,7 +997,17 @@ export function poolWhamPolicyObservation(
}
const malformed = readings.some(window => window?.used_percent != null
&& normalizeUsagePercent(window.used_percent) === undefined);
return data.rate_limit?.allowed === false || malformed ? quota : withWhamShortWindowAbsence(data, quota);
if (data.rate_limit?.allowed === false || malformed) return quota;
const observation = withWhamShortWindowAbsence(data, quota);
// #8067: the same complete topology proves a pool account has no monthly window when no window
// declares a monthly duration and no tertiary window is present, so a plan change away from a
// monthly primary (Free to Pro Lite) retires the carried tuple. Pool-only: main observations
// keep their existing retention rules.
const [primary, secondary, tertiary] = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window];
return observation.shortWindowAbsent === true
&& !isExplicitMonthlyWindow(primary) && !isExplicitMonthlyWindow(secondary) && tertiary == null
? { ...observation, monthlyWindowAbsent: true }
: observation;
}

function isMeasuredLongWindow(window: WhamUsageWindow | null | undefined): boolean {
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 @@ -435,7 +435,7 @@ cannot prove that the short window disappeared. All-null credits-only and tertia
the resulting weekly/monthly window still blocks at its long threshold (98% by default). This prevents old short-window exhaustion
from surviving indefinitely on a now weekly/monthly account. A live main writer applies that same
retirement proof to the display/rotation cache, so account selection cannot keep refusing the
recovered main account on its old short tuple. Pool accounts get the same proof from a valid complete WHAM probe (#7961): `poolWhamPolicyObservation` marks the observation from its window topology only, so a Plus to Pro upgrade cannot keep a stored pool account on the old plan's 5-hour tuple, while partial WHAM, invalid or unparseable readings, an explicit `allowed: false` verdict and response headers keep carrying it. A pool probe retires the tuple only with a live captured pool writer for that account and credential generation, the same proof its history write needs; a missing, stale or other-account writer keeps the tuple. The login usage read marks its observation the same way, but reauthentication clears the row before writing and creation refuses existing ids, so it has nothing to retire. Main-only monthly filtering is not applied to pool observations, and a main observation without a live main writer still carries its tuple. Coverage lives in `tests/codex-integration/codex-credits-probes.test.ts` (pool),
recovered main account on its old short tuple. Pool accounts get the same proof from a valid complete WHAM probe (#7961): `poolWhamPolicyObservation` marks the observation from its window topology only, so a Plus to Pro upgrade cannot keep a stored pool account on the old plan's 5-hour tuple, while partial WHAM, invalid or unparseable readings, an explicit `allowed: false` verdict and response headers keep carrying it. A pool probe retires the tuple only with a live captured pool writer for that account and credential generation, the same proof its history write needs; a missing, stale or other-account writer keeps the tuple. The login usage read marks its observation the same way, but reauthentication clears the row before writing and creation refuses existing ids, so it has nothing to retire. The same complete topology, when no window declares a monthly duration and no tertiary window is present, also retires a carried monthly tuple under the same writer proof (#8067), so a Free to Pro Lite change cannot keep a pool account on the old plan's exhausted monthly reading. Main-only monthly filtering is not applied to pool observations, and a main observation without a live main writer still carries its tuple. Coverage lives in `tests/codex-integration/codex-credits-probes.test.ts` (pool),
`tests/codex-integration/main-quota-evidence-validation.test.ts`,
`tests/codex-integration/main-account-hard-lock-retirement.test.ts`,
`tests/codex-integration/main-quota-provenance.test.ts`, and
Expand Down
166 changes: 166 additions & 0 deletions tests/codex-integration/pool-monthly-window-retirement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { capturePoolQuotaWriter, readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store";
import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id";
import { commitPoolQuotaResponse } from "../../src/codex/auth-api/pool-quota-probe";
import { resetCodexCreditsForTests } from "../../src/codex/credits";
import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache";
import {
applyAccountQuotaFromUpstreamHeaders, clearAccountQuota, getAccountQuota, getMainPolicyQuota, isCodexQuotaExhausted,
parseMainPolicyUsageQuota, parseUsageQuota, poolWhamPolicyObservation, setAccountQuotaFromParsed, type WhamUsageResponse,
} from "../../src/codex/quota";
import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff";
import { captureConfigGeneration } from "../../src/lib/state-store-sweeper";
import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle";
import { removeTreeWithRetry } from "../helpers/remove-tree";

// #8067: a pool account that moved from a monthly-primary plan (Free) to a weekly plan
// (Pro Lite) kept the old exhausted monthly tuple forever and was never selected.
const accountId = "monthly-retire-pool";
let testDir: string;
let previousHome: string | undefined;
let previousCodexHome: string | undefined;
let originalFetch: typeof fetch;

function savePool() {
saveCodexAccountCredential(accountId, {
accessToken: "fixture-pool-bearer", refreshToken: "fixture-pool-refresh",
chatgptAccountId: "fixture-pool-identity", expiresAt: Date.now() + 3_600_000,
});
}
function poolContext() {
const record = readCodexAccountRecord(accountId)!;
return { accountId, existing: null, configuredPlan: undefined,
generation: record.generation, writerGeneration: captureConfigGeneration(),
poolWriter: capturePoolQuotaWriter(accountId, { ...record.credential!, generation: record.generation }),
};
}

const now = () => Math.floor(Date.now() / 1000);
const monthly = (used_percent = 100) => ({ used_percent, limit_window_seconds: 2_592_000, reset_at: now() + 2_592_000 });
const weekly = (used_percent = 0) => ({ used_percent, limit_window_seconds: 604_800, reset_at: now() + 604_800 });
const observe = (plan_type: string, rate_limit: Record<string, unknown>) => Response.json({ plan_type, rate_limit });
const freeExhausted = () => observe("free", { allowed: false, primary_window: monthly(), secondary_window: null });
const proLite = (extra: Record<string, unknown> = {}) =>
observe("prolite", { allowed: true, primary_window: weekly(), secondary_window: null, ...extra });

beforeEach(() => {
previousHome = process.env.OPENCODEX_HOME;
previousCodexHome = process.env.CODEX_HOME;
originalFetch = globalThis.fetch;
testDir = mkdtempSync(join(tmpdir(), "ocx-monthly-retire-"));
process.env.OPENCODEX_HOME = testDir;
process.env.CODEX_HOME = join(testDir, "codex");
mkdirSync(process.env.CODEX_HOME, { recursive: true });
globalThis.fetch = (async () => { throw new Error("unexpected fixture network request"); }) as typeof fetch;
clearAccountQuota();
clearMainAccountInfoCache();
resetLifecycleDrainStateForTests();
resetCodexCreditsForTests();
resetQuotaQueryBackoffForTests();
});
afterEach(() => {
clearAccountQuota();
clearMainAccountInfoCache();
resetLifecycleDrainStateForTests();
resetCodexCreditsForTests();
resetQuotaQueryBackoffForTests();
globalThis.fetch = originalFetch;
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(testDir);
});

describe("pool WHAM monthly-window retirement (#8067)", () => {
test("a complete weekly-only probe after a plan change retires the carried monthly tuple", async () => {
savePool();
const ctx = poolContext();
const before = await commitPoolQuotaResponse(freeExhausted(), ctx);
expect(before.quota?.monthlyPercent).toBe(100);
expect(before.quota?.monthlyIsPrimaryWindow).toBe(true);

const after = await commitPoolQuotaResponse(proLite(), ctx);
expect(after.quota?.weeklyPercent).toBe(0);
expect(after.quota?.monthlyPercent).toBeUndefined();
expect(after.quota?.monthlyResetAt).toBeUndefined();
expect(after.quota?.monthlyIsPrimaryWindow).toBeUndefined();
expect(isCodexQuotaExhausted(getAccountQuota(accountId), "prolite")).toBe(false);
expect(getAccountQuota(accountId)).not.toHaveProperty("monthlyWindowAbsent");
});

test("a present tertiary window replaces the monthly reading instead of retiring it", async () => {
savePool();
const ctx = poolContext();
await commitPoolQuotaResponse(freeExhausted(), ctx);
const result = await commitPoolQuotaResponse(proLite({ tertiary_window: monthly(40) }), ctx);
expect(result.quota?.monthlyPercent).toBe(40);
});

test("partial, denied, header-only and writerless observations keep the carried tuple", async () => {
savePool();
const ctx = poolContext();
await commitPoolQuotaResponse(freeExhausted(), ctx);
const partial = await commitPoolQuotaResponse(Response.json({ plan_type: "prolite", rate_limit: { primary_window: weekly() } }), ctx);
expect(partial.quota?.monthlyPercent).toBe(100);
const denied = await commitPoolQuotaResponse(observe("prolite", {
allowed: false, primary_window: weekly(), secondary_window: null, tertiary_window: null,
}), ctx);
expect(denied.quota?.monthlyPercent).toBe(100);
applyAccountQuotaFromUpstreamHeaders(accountId, new Headers({
"x-codex-primary-used-percent": "0", "x-codex-primary-window-minutes": "10080",
}));
expect(getAccountQuota(accountId)?.monthlyPercent).toBe(100);
const writerless = await commitPoolQuotaResponse(proLite(), { ...ctx, poolWriter: undefined });
expect(writerless.quota?.monthlyPercent).toBe(100);
const otherAccount = await commitPoolQuotaResponse(proLite(), { ...ctx, poolWriter: { ...ctx.poolWriter!, accountId: "other-pool" } });
expect(otherAccount.quota?.monthlyPercent).toBe(100);
});

test("policy evidence marks monthly absence only when no window is monthly", () => {
const evidence = (data: WhamUsageResponse) => poolWhamPolicyObservation(data, parseUsageQuota(data));
const weeklyOnly = { plan_type: "prolite", rate_limit: { allowed: true, primary_window: weekly(), secondary_window: null } };
expect(evidence(weeklyOnly)?.monthlyWindowAbsent).toBe(true);
expect(evidence({ ...weeklyOnly, rate_limit: { ...weeklyOnly.rate_limit, tertiary_window: monthly(5) } })?.monthlyWindowAbsent).toBeUndefined();
expect(evidence({ plan_type: "team", rate_limit: { allowed: true, primary_window: monthly(5), secondary_window: weekly() } })?.monthlyWindowAbsent).toBeUndefined();
expect(evidence({ plan_type: "prolite", rate_limit: { primary_window: weekly() } })?.monthlyWindowAbsent).toBeUndefined();
});
});

describe("main observations keep their monthly retention rules", () => {
const mainIdentity = "monthly-retire-main-fixture";
const publish = (data: WhamUsageResponse) => setAccountQuotaFromParsed(MAIN, parseUsageQuota(data), undefined,
captureMainQuotaWriter(mainIdentity), parseMainPolicyUsageQuota(data));
const caches = () => [getAccountQuota(MAIN), getMainPolicyQuota()];

test("an explicit denial with null secondary and tertiary keeps the monthly tuple in both caches", () => {
observeMainQuotaIdentity(mainIdentity);
publish({ plan_type: "free", rate_limit: { allowed: false, primary_window: monthly(), secondary_window: null } });
for (const quota of caches()) expect(quota?.monthlyPercent).toBe(100);
const denied = { plan_type: "prolite", rate_limit: {
allowed: false, primary_window: weekly(), secondary_window: null, tertiary_window: null,
} };
expect(parseMainPolicyUsageQuota(denied)?.monthlyWindowAbsent).toBeUndefined();
publish(denied);
for (const quota of caches()) {
expect(quota?.monthlyPercent).toBe(100);
expect(quota?.monthlyIsPrimaryWindow).toBe(true);
}
expect(isCodexQuotaExhausted(getAccountQuota(MAIN), "prolite")).toBe(true);
});

test("an allowed snapshot with a malformed additional window keeps the monthly tuple", () => {
observeMainQuotaIdentity(mainIdentity);
publish({ plan_type: "free", rate_limit: { allowed: false, primary_window: monthly(), secondary_window: null } });
const malformed = { plan_type: "prolite", rate_limit: { allowed: true, primary_window: weekly(), secondary_window: null },
additional_rate_limits: [{ rate_limit: { primary_window: { used_percent: 101, limit_window_seconds: 604_800 } } }] };
expect(parseMainPolicyUsageQuota(malformed)).not.toHaveProperty("monthlyWindowAbsent");
publish(malformed);
for (const quota of caches()) expect(quota?.monthlyPercent).toBe(100);
expect(isCodexQuotaExhausted(getAccountQuota(MAIN), "prolite")).toBe(true);
});
});

1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@
"claude-devin-output-order.test.ts": "claude-integration",
"codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration",
"codex-credits-settings.test.ts": "codex-integration", "codex-credits-probes.test.ts": "codex-integration",
"pool-monthly-window-retirement.test.ts": "codex-integration",
"codex-credits-after-limit.test.ts": "codex-integration",
"codex-pool-credit-policy.test.ts": "codex-integration", "codex-credit-retry-after.test.ts": "codex-integration", "codex-credit-scoped-retry.test.ts": "codex-integration",
"codex-direct-credit-renewal.test.ts": "codex-integration", "codex-direct-renewal-revocation.test.ts": "codex-integration",
Expand Down
Loading