diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index aa24e6c048f..7caf349d6a2 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -1056,6 +1056,15 @@ For an opted-in, unpaused main account with a full usage window, the background Background revalidation is separate and off by default. It requires Token Guardian, the `openai` provider's `proactive` refresh policy, and `tokenGuardian.codexWarmupEnabled`. It skips accounts awaiting deferred registration validation. +### After a ChatGPT plan upgrade + +Refresh the main account's quota in Codex Auth after changing your ChatGPT plan. When the +authenticated usage response confirms a long window has replaced the five-hour window, +opencodex removes the old five-hour reading from both request admission and account selection. +An allowed response with primary and secondary windows can omit the optional tertiary window. +This recovery uses your included plan allowance and does not require enabling **Use credits**. +Partial or invalid usage data retains the previous block until recovery is confirmed. + ### Cancelling main-account device reauthentication When cancelling main-account device reauthentication, a temporary DELETE or network failure, or a response with an unknown or nonterminal status, keeps the active flow and the cancellation-failure indication so cancellation can be retried. Status polling normally continues so a login that completes can still be detected. If a retryable cancellation failure overlaps a non-2xx GET status response while the flow is `pending` or `committing`, either response order preserves cancellation retry on that same flow, with its last server-provided device code, verification URL, and phase. The GET HTTP failure still stops polling, but cancellation can be retried without starting a second login POST. A terminal `failed` response releases the flow and displays the normalized failure reason; only `succeeded` reports login success. A confirmed `cancelled` response releases the flow so a new device login can be started. A definitive HTTP 404 response with code `unknown_flow` also releases the expired flow ID so a new device login can be started, but does not report a successful login or confirmed cancellation. Late POST, GET, or DELETE responses from an earlier flow cannot change the new flow or report login success for it. diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 4c737036595..34507ee808d 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -319,9 +319,12 @@ export function setAccountQuotaFromParsed( credentialGeneration: historyEvidence.writer.credentialGeneration, windows: historyWindows(historyEvidence.raw), }, updatedAt); } - // Legacy rotation keeps its existing carry behavior, but never inherits policy-only + // Rotation shares validated main-window retirement, but never inherits policy-only // evidence that outlived its disk TTL. Policy has a separate, identity-checked base. - const next = mergeAccountQuota(quota, legacyExisting, updatedAt); + const displayQuota: MainPolicyQuotaObservation = isMain && mainWriter && policyQuota?.shortWindowAbsent === true + ? { ...quota, shortWindowAbsent: true } + : quota; + const next = mergeAccountQuota(displayQuota, legacyExisting, updatedAt); accountQuota.set(accountId, next); if (isMain) { if (mainWriter && policyQuota && snapshotHasUsage(policyQuota)) { @@ -440,7 +443,7 @@ function mergeAccountQuota( } if (quota.shortResetAt !== undefined) next.shortResetAt = quota.shortResetAt; if (quota.shortWindowSeconds !== undefined) next.shortWindowSeconds = quota.shortWindowSeconds; - } else if (!policyEvidence || quota.shortWindowAbsent !== true) { + } else if (quota.shortWindowAbsent !== true) { // Unknown usage is not a lower reading. Retain the entire known tuple: pairing // its percentage with new metadata would silently extend or shorten its reset. // An elapsed reset is the exception. It describes a window that has already rolled over, @@ -879,14 +882,17 @@ function filterMainPolicyMonthlyQuota( /** * Parse ordinary main-policy usage, rejecting messages with invalid numeric window percentages. * A measured long primary, or explicitly absent primary with measured weekly secondary, - * proves replacement only with complete long/null topology. Null supplies no usable evidence. + * proves replacement with long/null topology; an allowed two-window response may omit tertiary. + * Null supplies no usable evidence. */ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQuotaObservation | null { 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; - // WHAM explicitly reports absent windows as null; omissions cannot prove replacement. + // 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. @@ -894,7 +900,8 @@ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQu if (quota && (isMeasuredLongWindow(primary) || (primary === null && isMeasuredLongWindow(secondary) && quota.weeklyPercent !== undefined)) && (secondary === null || isMeasuredLongWindow(secondary)) - && (tertiary === null || isMeasuredLongWindow(tertiary))) { + && (tertiary === null || isMeasuredLongWindow(tertiary) + || (tertiary === undefined && data.rate_limit?.allowed === true))) { return { ...quota, shortWindowAbsent: true }; } return quota; diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 3987c7fc2d2..c7d34948aac 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -9,10 +9,10 @@ in [GUI and management API](gui-and-management-api.md). The dashboard is a local control surface, not a separate service. It should reflect the same config and catalog invariants documented in this folder rather than inventing parallel state. -Codex quota cards consume the display cache from `src/codex/quota.ts`. A partial refresh -removes an omitted short tuple whose reset deadline has elapsed, so a stale model-derived -5h row does not persist on a weekly-only account. This is independent of the main-account -blocking hard-lock evidence and reset-notification history; their retention rules are documented in +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 +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). Codex account panels expose no Spark quota toggle or setting and retain quota refresh, pause/resume, selection order, reset-credit confirmation and Advanced settings; account/provider quota DTOs suppress retired windows upstream of the generic quota renderer, under [OpenAI quota scopes](providers/openai-tiers.md#public-provider-contract). diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index ff5e6840b6b..9631ceab68e 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -414,18 +414,22 @@ hard-lock tests registered in `scripts/test-layout/layout.json`, including A single fresh valid WHAM response with a measured long primary, or an explicitly null primary and measured secondary that supplies parsed weekly usage, can replace an obsolete short-window tuple. -Secondary and tertiary must each be explicitly null or explicitly long with a valid usage reading. +Secondary must be explicitly null or explicitly long with a valid usage reading. Tertiary follows +the same rule when present; a two-window response may omit it only with `rate_limit.allowed === true`. Long means **at least 24 hours**, matching the parser's short/long discriminator; a one-day primary qualifies, not only a seven-day or monthly window. The policy trusts that one reported topology; it does not require repeated observations or independently confirm upstream window completeness. -Any omitted window field, an unreadable long window, an unknown duration, partial headers, or invalid usage +An omitted primary/secondary, omitted tertiary without that verdict, unreadable long window, unknown duration, partial headers, or invalid usage cannot prove that the short window disappeared. All-null credits-only and tertiary-only Go/Free responses remain insufficient. Replacement proof belongs only to that observation and is never persisted; 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. Coverage lives in +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 `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 -`tests/codex-integration/main-account-hard-lock-recovery.test.ts`. +`tests/codex-integration/main-account-hard-lock-recovery.test.ts`; request admission without credit +consent is covered by `tests/codex-integration/main-account-credit-renewal.test.ts`. The policy reads a separately retained identity-tagged quota snapshot, so the legacy rotation cache's six-hour expiry does not silently release a known block. A confirmed account transition diff --git a/tests/codex-integration/main-account-credit-renewal.test.ts b/tests/codex-integration/main-account-credit-renewal.test.ts index 54beffbe752..856b36412ec 100644 --- a/tests/codex-integration/main-account-credit-renewal.test.ts +++ b/tests/codex-integration/main-account-credit-renewal.test.ts @@ -9,7 +9,7 @@ import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; import { captureMainQuotaWriter, clearMainAccountInfoCache, getMainAccountInfoCache, isMainAccountIdentityGenerationLive, observeMainQuotaCredential, setMainAccountInfoCache } from "../../src/codex/main-account-cache"; -import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { clearAccountQuota, getAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; import { hasSpendableCodexCredits, type CodexSpendableCredits } from "../../src/codex/quota-types"; import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; import { clearCodexUpstreamHealth } from "../../src/codex/routing"; @@ -157,6 +157,35 @@ test("without renewal, five-minute credit expiry produces a local 429", async () expect(calls).toEqual([]); }); +test.each([[false, false], [true, false], [false, true], [true, true]] as const)( + "plan upgrade retires an exhausted short window without credit consent (hard lock=%j, tertiary null=%j)", + async (hardLock, tertiaryNull) => { + cfg.codexMainAccountHardLock = hardLock; + cfg.creditCodexAccountIds = []; + clearAccountQuota(MAIN); + setAccountQuotaFromParsed(MAIN, { shortPercent: 100, shortWindowSeconds: 18_000, + shortResetAt: Math.floor((now + HOUR) / 1000), weeklyPercent: 20 }, undefined, + captureMainQuotaWriter(accountId)); + fetchWith(async () => Response.json({ plan_type: "prolite", rate_limit: { + allowed: true, + primary_window: { used_percent: 3, limit_window_seconds: 604_800, + reset_at: Math.floor((now + 7 * 24 * HOUR) / 1000) }, + secondary_window: null, + ...(tertiaryNull ? { tertiary_window: null } : {}), + }, credits: { has_credits: false, unlimited: false, balance: "0" } })); + const result = await fetchMainAccountInfoAttempt(true, 0); + expect(result.info.plan).toBe("prolite"); + expect(result.info.quota?.weeklyPercent).toBe(3); + expect(getMainPolicyQuota()?.shortPercent).toBeUndefined(); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")).resolves.toMatchObject({ kind: "main-pool" }); + for (const field of ["shortPercent", "shortObservedAt", "shortResetAt", "shortWindowSeconds"] as const) { + expect(getMainPolicyQuota()?.[field]).toBeUndefined(); + expect(getAccountQuota(MAIN)?.[field]).toBeUndefined(); + } + expect(calls).toEqual([whamUrl]); + }, +); + test.each([false, true])("hidden-dashboard sweep renews at three minutes and admits past original expiry (unlimited=%j)", async unlimited => { const original = now; seed({ hasCredits: true, ...(unlimited ? { unlimited: true } : { balance: 5 }), observedAt: now }); diff --git a/tests/codex-integration/main-account-hard-lock-retirement.test.ts b/tests/codex-integration/main-account-hard-lock-retirement.test.ts index 21db57c6527..3ea983bad4f 100644 --- a/tests/codex-integration/main-account-hard-lock-retirement.test.ts +++ b/tests/codex-integration/main-account-hard-lock-retirement.test.ts @@ -6,7 +6,7 @@ import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; import { - applyAccountQuotaFromUpstreamHeaders, clearAccountQuota, getMainPolicyQuota, + applyAccountQuotaFromUpstreamHeaders, clearAccountQuota, getAccountQuota, getMainPolicyQuota, parseMainPolicyUsageQuota, parseUsageQuota, setAccountQuotaFromParsed, type WhamUsageResponse, } from "../../src/codex/quota"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -49,6 +49,26 @@ function publish(data: WhamUsageResponse): void { } describe("authoritative main 5h window retirement (#6244)", () => { + for (const primaryAbsent of [false, true]) { + test.each([0, 35, 98, 100])(`allowed two-window snapshot retires short usage in both caches (primary absent=${primaryAbsent}, weekly=%s)`, percent => { + setAccountQuotaFromParsed(MAIN, { shortPercent: 100, shortWindowSeconds: 18_000, + shortResetAt: Date.now() / 1000 + 3_600 }, undefined, captureMainQuotaWriter(account)); + const measured = { ...weekly, used_percent: percent }; + publish({ rate_limit: { allowed: true, + primary_window: primaryAbsent ? null : measured, + secondary_window: primaryAbsent ? measured : null, + } }); + for (const quota of [getAccountQuota(MAIN), getMainPolicyQuota()]) { + expect(quota?.weeklyPercent).toBe(percent); + for (const field of ["shortPercent", "shortObservedAt", "shortResetAt", "shortWindowSeconds"] as const) { + expect(quota?.[field]).toBeUndefined(); + } + expect(quota).not.toHaveProperty("shortWindowAbsent"); + } + expect(getMainAccountHardLockStatus({}).state).toBe(percent < 98 ? "ready" : "blocked"); + }); + } + test.each([0, 35, 97.99, 98, 100])("explicit absent primary retires old 5h while weekly %s still governs", percent => { publish({ rate_limit: { primary_window: null, secondary_window: { ...weekly, used_percent: percent }, tertiary_window: null, @@ -63,6 +83,17 @@ describe("authoritative main 5h window retirement (#6244)", () => { }); test.each([ + { rate_limit: { allowed: false, primary_window: weekly, secondary_window: null } }, + { rate_limit: { allowed: "true", primary_window: weekly, secondary_window: null } }, + { rate_limit: { allowed: true, primary_window: weekly } }, + { rate_limit: { allowed: true, secondary_window: weekly } }, + { rate_limit: { allowed: true, primary_window: {}, secondary_window: weekly } }, + { rate_limit: { allowed: true, primary_window: weekly, secondary_window: {} } }, + { rate_limit: { allowed: true, primary_window: weekly, secondary_window: null, + tertiary_window: { limit_window_seconds: 2_592_000 } } }, + { rate_limit: { allowed: true, primary_window: weekly, secondary_window: null, + tertiary_window: { used_percent: 0, limit_window_seconds: 18_000 } } }, + { rate_limit: { allowed: true, primary_window: { ...weekly, used_percent: 101 }, secondary_window: null } }, { rate_limit: { secondary_window: weekly, tertiary_window: null } }, { rate_limit: { primary_window: null, secondary_window: weekly } }, { rate_limit: { primary_window: null, secondary_window: {}, tertiary_window: null } }, @@ -106,11 +137,12 @@ describe("authoritative main 5h window retirement (#6244)", () => { expect(getMainAccountHardLockStatus({}, Date.now() + 30 * 24 * 60 * 60_000).state).toBe("blocked"); }); - test("a stale identity writer cannot publish otherwise authoritative absence", () => { + test.each([false, true])("a stale identity writer cannot publish authoritative absence (tertiary null=%j)", tertiaryNull => { const staleWriter = captureMainQuotaWriter(account)!; clearMainAccountInfoCache(); observeMainQuotaIdentity(account); - const data = { rate_limit: { primary_window: null, secondary_window: weekly, tertiary_window: null } }; + const data = { rate_limit: { allowed: true, primary_window: null, secondary_window: weekly, + ...(tertiaryNull ? { tertiary_window: null } : {}) } }; setAccountQuotaFromParsed(MAIN, parseUsageQuota(data), undefined, staleWriter, parseMainPolicyUsageQuota(data)); expect(getMainPolicyQuota()?.shortPercent).toBe(100); expect(getMainAccountHardLockStatus({}).state).toBe("blocked"); diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index b73680c2e03..d8cb4b4fd65 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -310,10 +310,11 @@ test("window replacement persists without carrying its proof into later partial const publish = (data: WhamUsageResponse) => setAccountQuotaFromParsed( MAIN, parseUsageQuota(data), undefined, writer, parseMainPolicyUsageQuota(data), ); - setAccountQuotaFromParsed(MAIN, { shortPercent: 100, shortWindowSeconds: 18_000, shortResetAt: 1 }, undefined, writer); + setAccountQuotaFromParsed(MAIN, { shortPercent: 100, shortWindowSeconds: 18_000, + shortResetAt: Date.now() / 1000 + 3_600 }, undefined, writer); expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); publish({ rate_limit: { - primary_window: null, secondary_window: { used_percent: 35, limit_window_seconds: 604_800 }, tertiary_window: null, + allowed: true, primary_window: { used_percent: 35, limit_window_seconds: 604_800 }, secondary_window: null, } }); // Execute quota's actual debounced serializer through the existing deterministic clock. const persisted = flushPersistence(); @@ -323,6 +324,7 @@ test("window replacement persists without carrying its proof into later partial writeFileSync(join(testDir, "codex-quota-cache.json"), persisted); expect(getMainAccountHardLockStatus(cfg).state).toBe("ready"); expect(getMainPolicyQuota()?.shortPercent).toBeUndefined(); + expect(getAccountQuota(MAIN)?.shortPercent).toBeUndefined(); publish({ rate_limit: { primary_window: { used_percent: 99, limit_window_seconds: 18_000 } } }); publish({ rate_limit: { primary_window: { used_percent: 0 } } }); expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked");