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
4 changes: 2 additions & 2 deletions src/codex/auth-api/login-flow.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { withCodexAccountLogLabel } from "../account-label";
import { getCodexAccountCredential, markCodexAccountValidated, readCodexAccountRecord, saveCodexAccountCredential, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError } from "../account-store";
import { clearAccountQuota, isCodexQuotaExhausted, isValidWhamHistoryObservation, parseUsageQuota, setAccountQuotaFromParsed } from "../quota";
import { clearAccountQuota, isCodexQuotaExhausted, parseUsageQuota, poolWhamPolicyObservation, setAccountQuotaFromParsed } from "../quota";
import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config";
import { appendDefaultCodexAccountNamespace, codexAccountPickerEnabled } from "../account-namespaces";
Expand Down Expand Up @@ -278,7 +278,7 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
email = data.email?.trim() || email;
plan = nonEmptyPlan(data.plan_type) ?? plan;
quota = parseUsageQuota(data);
policyQuota = isValidWhamHistoryObservation(data) ? quota : null;
policyQuota = poolWhamPolicyObservation(data, quota);
}
} catch { /* wham fetch is non-blocking */ }
// Reauth must refresh the same ChatGPT identity already bound to this pool slot.
Expand Down
4 changes: 2 additions & 2 deletions src/codex/auth-api/pool-quota-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { fetchCodexUsage } from "../quota-query-backoff";
import type { CodexUsageOwner } from "../quota-query-backoff";
import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, forceRefreshCodexPoolToken, markCodexAccountValidated, markCodexAccountValidationFailed, readCodexAccountRecord, isTerminalCodexPoolRefreshFailure, CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, TokenRefreshError } from "../account-store";
import type { PoolQuotaWriter } from "../quota-types";
import { isValidWhamHistoryObservation, getAccountQuota, isCompleteCodexQuotaRecoverySnapshot, parseUsageQuota, setAccountQuotaFromParsed } from "../quota";
import { isValidWhamHistoryObservation, getAccountQuota, isCompleteCodexQuotaRecoverySnapshot, parseUsageQuota, poolWhamPolicyObservation, setAccountQuotaFromParsed } from "../quota";
import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
import type { ManualResetRefreshLineage } from "../routing";
import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
Expand Down Expand Up @@ -377,7 +377,7 @@ export async function commitPoolQuotaResponse(
return { quota: null, needsReauth: false, credentialGeneration: generation };
}
const validPolicyObservation = isValidWhamHistoryObservation(data);
setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, validPolicyObservation ? quota : null,
setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, poolWhamPolicyObservation(data, quota),
ctx.poolWriter && validPolicyObservation ? { writer: ctx.poolWriter, observedAt, source: "wham", raw: quota } : undefined,
true);
return {
Expand Down
35 changes: 32 additions & 3 deletions src/codex/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,10 @@ export function setAccountQuotaFromParsed(
const mainDecisionGeneration = mainWriter && mainDecisionQuotaWriterGeneration(mainWriter);
if (isMain && mainDecisionGeneration !== undefined && decisionRaw)
publishCodexDecisionQuota(accountId, mainDecisionGeneration, decisionRaw, updatedAt, "codex-main", decisionObservationPartial);
const displayQuota: MainPolicyQuotaObservation = isMain && mainWriter && policyQuota?.shortWindowAbsent === true
// 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;
const next = mergeAccountQuota(displayQuota, legacyExisting, updatedAt);
Expand Down Expand Up @@ -934,15 +937,22 @@ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQu
const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window];
if (windows.some(window => isInvalidPolicyUsagePercent(window?.used_percent))) return null;
const quota = filterMainPolicyMonthlyQuota(parseUsageQuota(data), isThirtyDayOnlyCodexPlan(data.plan_type));
const [primary, secondary, tertiary] = windows;
return quota ? withWhamShortWindowAbsence(data, quota) : null;
}

/** Derive absence from complete WHAM topology without changing the display quota. */
export function withWhamShortWindowAbsence(
data: WhamUsageResponse, quota: Omit<StoredAccountQuota, "updatedAt">,
): MainPolicyQuotaObservation {
const [primary, secondary, tertiary] = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window];
// Primary and secondary must explicitly report their topology. WHAM also sends
// two-window responses without tertiary; an explicit allowed verdict disambiguates those
// from partial observations. A present tertiary still requires measured long/null evidence.
// Policy trusts one complete snapshot only when every non-null window is >=24h AND
// carries a valid usage reading: a long window without used_percent leaves that
// window's usage unknown, and unknown usage must never release a block.
// Headers never supply this proof, and reset time alone still cannot release a block.
if (quota && (isMeasuredLongWindow(primary)
if ((isMeasuredLongWindow(primary)
|| (primary === null && isMeasuredLongWindow(secondary) && quota.weeklyPercent !== undefined))
&& (secondary === null || isMeasuredLongWindow(secondary))
&& (tertiary === null || isMeasuredLongWindow(tertiary)
Expand All @@ -952,6 +962,25 @@ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQu
return quota;
}

/**
* Policy evidence for a stored pool account's WHAM read (pool probe or re-login): null when any
* reading is invalid. A complete long/null topology marks short-window absence (#7961) unless
* upstream explicitly denied the account, since a denial is never evidence that a block ended.
*/
export function poolWhamPolicyObservation(
data: WhamUsageResponse, quota: Omit<StoredAccountQuota, "updatedAt"> | null,
): MainPolicyQuotaObservation | null {
if (!quota || !isValidWhamHistoryObservation(data)) return null;
// Retirement releases a block, so a present reading that does not parse is not "unknown".
const readings = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window];
for (const limit of Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : []) {
if (limit && typeof limit === "object") readings.push(limit.rate_limit?.primary_window, limit.rate_limit?.secondary_window);
}
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);
}

function isMeasuredLongWindow(window: WhamUsageWindow | null | undefined): boolean {
return isExplicitLongWindow(window) && normalizeUsagePercent(window?.used_percent) !== undefined;
}
Expand Down
2 changes: 1 addition & 1 deletion structure/dashboard-and-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The dashboard is a local control surface, not a separate service. It should refl
and catalog invariants documented in this folder rather than inventing parallel state. The sign-in form offers opt-in **Remember this device** for direct HTTPS dashboards or loopback, storing an HttpOnly device cookie plus a separate proof in this site's origin-scoped localStorage; neither is the admin token or works alone, cookies span ports of one hostname so the proof binds the dashboard origin (still prefer a dedicated hostname), and storage failure reports Remember unavailable. Both mobile and sidebar **Forget this device** actions use logout to revoke the record and derived sessions immediately after successful publication and remove the proof, retaining it on failure for retry (directory-entry crash durability is best effort); remembered sessions cannot approve GitHub stars, reset-credit consumption, prompt-layer edits, Meta Muse sign-in, desktop compatibility changes or quota validation, which require a fresh local or paired session, and session-change events update both surfaces under [GUI and management API](gui-and-management-api.md).

Codex quota cards consume the display cache from `src/codex/quota.ts`. Partial refreshes remove
omitted short tuples after their reset deadline; validated main-window absence also removes
omitted short tuples after their reset deadline; validated main-window absence, or validated complete WHAM absence for a pool account (#7961), also removes
future-reset short tuples. Main-account hard-lock evidence and reset-notification history follow
separate retention rules in
[OpenAI account modes](providers/openai-tiers.md#quota-cache-and-short-window-history). See [permanent refusal auto-pause](account-auto-pause.md).
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. Coverage lives in
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),
`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
113 changes: 112 additions & 1 deletion tests/codex-integration/codex-credits-probes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { fetchMainAccountInfoSnapshot } from "../../src/codex/auth-api/main-acco
import { commitPoolQuotaResponse } from "../../src/codex/auth-api/pool-quota-probe";
import { clearMainAccountInfoCache } from "../../src/codex/main-account-cache";
import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle";
import { clearAccountQuota } from "../../src/codex/quota";
import { applyAccountQuotaFromUpstreamHeaders, clearAccountQuota, getAccountQuota, parseUsageQuota, poolWhamPolicyObservation, 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";
Expand Down Expand Up @@ -177,3 +177,114 @@ describe("pool credits publication", () => {
expect(poolReads).toBe(1);
});
});

describe("pool WHAM short-window retirement", () => {
const now = () => Math.floor(Date.now() / 1000);
const short = () => ({ used_percent: 100, limit_window_seconds: 18_000, reset_at: now() + 3600 });
const weekly = () => ({ used_percent: 0, limit_window_seconds: 604_800, reset_at: now() + 604_800 });
const observe = (primary: unknown, secondary: unknown, extra = {}) => Response.json({
plan_type: "pro", rate_limit: { allowed: true, primary_window: primary, secondary_window: secondary }, ...extra,
});

test("complete weekly-only probe retires the carried short tuple", async () => {
savePool();
const ctx = poolContext();
await commitPoolQuotaResponse(observe(short(), weekly()), ctx);
const result = await commitPoolQuotaResponse(observe(weekly(), null), ctx);
expect(result.quota?.shortPercent).toBeUndefined();
expect(result.quota?.shortResetAt).toBeUndefined();
expect(result.quota?.weeklyPercent).toBe(0);
});

test("a Plus-style five-hour window remains present", async () => {
savePool();
const ctx = poolContext();
const result = await commitPoolQuotaResponse(observe(short(), weekly(), { plan_type: "plus" }), ctx);
expect(result.quota?.shortPercent).toBe(100);
expect(result.quota?.shortResetAt).toBeDefined();
});

test("partial and invalid WHAM observations retain the carried tuple", async () => {
savePool();
const ctx = poolContext();
await commitPoolQuotaResponse(observe(short(), weekly()), ctx);
const partial = await commitPoolQuotaResponse(Response.json({ rate_limit: { primary_window: weekly() } }), ctx);
expect(partial.quota?.shortPercent).toBe(100);
const invalid = await commitPoolQuotaResponse(observe(weekly(), null, {
additional_rate_limits: [{ rate_limit: { primary_window: { used_percent: 101 } } }],
}), ctx);
expect(invalid.quota?.shortPercent).toBe(100);
});
test("partial response headers do not retire a carried short window", async () => {
savePool();
await commitPoolQuotaResponse(observe(short(), weekly()), poolContext());
applyAccountQuotaFromUpstreamHeaders(account.id, new Headers({ "x-codex-primary-used-percent": "12" }));
expect(getAccountQuota(account.id)?.shortPercent).toBe(100);
});

test("monthly-only pool plans keep their monthly observation", async () => {
savePool();
const ctx = poolContext();
await commitPoolQuotaResponse(observe(short(), weekly()), ctx);
const monthly = { used_percent: 14, limit_window_seconds: 2_592_000, reset_at: now() + 2_592_000 };
const result = await commitPoolQuotaResponse(observe(monthly, null, { plan_type: "go" }), ctx);
expect(result.quota?.shortPercent).toBeUndefined();
expect(result.quota?.monthlyPercent).toBe(14);
});

test("a probe without a live writer for this account retains the carried tuple", async () => {
savePool();
await commitPoolQuotaResponse(observe(short(), weekly()), poolContext());
const missing = await commitPoolQuotaResponse(observe(weekly(), null), { ...poolContext(), poolWriter: undefined });
expect(missing.quota?.shortPercent).toBe(100);
const live = poolContext().poolWriter!;
const stale = { ...live, historyIdentity: "00000000-0000-4000-8000-000000000000" };
expect((await commitPoolQuotaResponse(observe(weekly(), null), { ...poolContext(), poolWriter: stale })).quota?.shortPercent).toBe(100);
const other = { ...live, accountId: "other-pool" };
expect((await commitPoolQuotaResponse(observe(weekly(), null), { ...poolContext(), poolWriter: other })).quota?.shortPercent).toBe(100);
expect((await commitPoolQuotaResponse(observe(weekly(), null), poolContext())).quota?.shortPercent).toBeUndefined();
});
test("an explicit upstream denial never retires the carried tuple", async () => {
savePool();
const ctx = poolContext();
await commitPoolQuotaResponse(observe(short(), weekly()), ctx);
const denied = await commitPoolQuotaResponse(Response.json({ plan_type: "pro", rate_limit: {
allowed: false, primary_window: weekly(), secondary_window: null, tertiary_window: null,
} }), ctx);
expect(denied.quota?.shortPercent).toBe(100);
});

test("pool policy evidence marks absence only for a valid, allowed, complete topology", () => {
const evidence = (data: WhamUsageResponse) => poolWhamPolicyObservation(data, parseUsageQuota(data));
const weeklyOnly = { rate_limit: { allowed: true, primary_window: weekly(), secondary_window: null } };
expect(evidence(weeklyOnly)?.shortWindowAbsent).toBe(true);
expect(evidence({ rate_limit: { ...weeklyOnly.rate_limit, allowed: false, tertiary_window: null } })?.shortWindowAbsent).toBeUndefined();
expect(evidence({ rate_limit: { allowed: true, primary_window: short(), secondary_window: weekly() } })?.shortWindowAbsent).toBeUndefined();
expect(evidence({ rate_limit: { primary_window: weekly() } })?.shortWindowAbsent).toBeUndefined();
expect(evidence({ ...weeklyOnly, additional_rate_limits: [{ rate_limit: { primary_window: { used_percent: 101 } } }] })).toBeNull();
});

test("a malformed usage reading keeps the carried tuple and its reset", async () => {
savePool();
const ctx = poolContext();
const first = await commitPoolQuotaResponse(observe(short(), weekly()), ctx);
for (const used_percent of ["unreadable", true, {}]) {
const result = await commitPoolQuotaResponse(observe(weekly(), null, {
additional_rate_limits: [{ rate_limit: { primary_window: { used_percent, limit_window_seconds: 604_800 } } }],
}), ctx);
expect(result.quota?.shortPercent).toBe(100);
expect(result.quota?.shortResetAt).toBe(first.quota?.shortResetAt);
}
});

test("a writer captured before a credential rollover cannot retire the tuple", async () => {
savePool();
await commitPoolQuotaResponse(observe(short(), weekly()), poolContext());
const before = poolContext().poolWriter!;
savePool();
const after = poolContext();
expect(after.generation).not.toBe(before.credentialGeneration);
const replay = await commitPoolQuotaResponse(observe(weekly(), null), { ...after, poolWriter: before });
expect(replay.quota?.shortPercent).toBe(100);
});
});
10 changes: 10 additions & 0 deletions tests/codex-integration/main-quota-provenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,3 +658,13 @@ test("native main observations and oversized cache never become pool history", (
} }, padding: "x".repeat(4 * 1024 * 1024) }));
expect(getAccountQuotaHistory("history-pool").observations).toEqual([]);
});


test("main short window is retained without a main writer even with WHAM absence evidence", () => {
const short = { shortPercent: 100, shortResetAt: Date.now() / 1000 + 3600 };
setAccountQuotaFromParsed(MAIN, short);
const data: WhamUsageResponse = { rate_limit: { allowed: true,
primary_window: { used_percent: 0, limit_window_seconds: 604_800 }, secondary_window: null } };
setAccountQuotaFromParsed(MAIN, parseUsageQuota(data), undefined, undefined, parseMainPolicyUsageQuota(data));
expect(getAccountQuota(MAIN)?.shortPercent).toBe(100);
});
Loading