From ee76eb3c0160b706b96f2b381dbe78d2cae20517 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:20:38 +0900 Subject: [PATCH 1/2] feat(codex): list the flagship natives regardless of the entitlement roster gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna and gpt-6-astra now appear on every install. Every other native still derives visibility from the live catalog and the authenticated roster, and gpt-daybreak-blue-latest stays account-gated. This is the second half of #3442. That PR stopped a stale client version from making discovery ask a question whose answer omits gpt-5.6, which guarantees the QUESTION is fair -- it cannot guarantee an ANSWER. An unconfirmed account, a timed-out fetch or a shard that has not caught up all produce the same silent disappearance, and a model vanishing from the picker reads as "opencodex lost my model" rather than "upstream did not confirm it". Two subagent dispatches during this work died on the proxy's own 401 No eligible Codex account supports this model. Membership in ACCOUNT_GATED_NATIVE_OPENAI_MODELS is the single switch: it hides the row from the catalog, /v1/models, the dashboard and the desktop projection until a roster confirms it, AND makes auth-context refuse before dispatch. Both halves fail closed on absence of evidence rather than on a denial. gpt-6-astra was ungated by exactly this route in 6f634eddc and the trio was already in DOCUMENTED_NATIVE_OPENAI_ADDITIONS, so the change is removing three strings from one set. The accepted cost: Pool routing no longer prefers an account that owns the model, so a multi-account user may take one upstream 400 and one alternate retry where they used to be routed straight to the owner. Nothing unsafe -- each account still sends its own credential. gpt-5.6-luna is also the default web-search sidecar and shadow-call source model, so a single-account user who does not own it can now select it. Both are recorded in the devlog unit rather than discovered later. One thing had to change beyond the set. subagent-model-fallback gated its native-main drain sentinel on the same set, so ungating would have let a drain silently rewrite the operator's configured subagent model instead of reporting maintenance. That predicate never had anything to do with entitlement -- it protects the atomic main claim -- so it moves to SUPPORTED_NATIVE_OPENAI_SLUGS, which is what it always meant. ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS keeps its three entries. An earlier draft justified that by claiming it protects Daybreak; that is false, Daybreak is deliberately absent from the map, and a regression now pins the fact so the false rationale cannot come back. The true reason is narrower: the entries keep the tier-1 under-versioned escape hatch alive. Tests retarget onto Daybreak rather than being deleted, so the fail-closed and version-floor coverage keeps measuring a shipped model instead of going hollow. New regressions pin that the four flagships list with no roster, that Daybreak still does not, that disabledModels still hides them, and that ungating leaves the composed floor at 0.144.0 even though the derivation goes empty -- the assertion that would catch a silent undo of #3442. Verification: 537 pass / 0 fail across native-model-toggle, codex-model-entitlements, codex-catalog-sync-hardening, subagent-model-fallback, codex-auth-context, codex-convergence-account-selectors, subagent-roster-retention and codex-catalog. typecheck exit 0, privacy:scan passed. --- .../000_research.md | 128 +++++++++++++ .../005_audit_synthesis.md | 77 ++++++++ .../010_wp2_ungate.md | 69 +++++++ .../020_wp3_landing.md | 12 ++ src/codex/catalog/native-models.ts | 29 ++- src/codex/subagent-model-fallback.ts | 11 +- tests/codex-auth-context.test.ts | 8 +- tests/codex-catalog-sync-hardening.test.ts | 15 +- tests/codex-model-entitlements.test.ts | 180 ++++++++++++------ tests/native-model-toggle.test.ts | 22 +++ 10 files changed, 482 insertions(+), 69 deletions(-) create mode 100644 devlog/_plan/260904_flagship_native_always_visible/000_research.md create mode 100644 devlog/_plan/260904_flagship_native_always_visible/005_audit_synthesis.md create mode 100644 devlog/_plan/260904_flagship_native_always_visible/010_wp2_ungate.md create mode 100644 devlog/_plan/260904_flagship_native_always_visible/020_wp3_landing.md diff --git a/devlog/_plan/260904_flagship_native_always_visible/000_research.md b/devlog/_plan/260904_flagship_native_always_visible/000_research.md new file mode 100644 index 0000000000..f6935b840e --- /dev/null +++ b/devlog/_plan/260904_flagship_native_always_visible/000_research.md @@ -0,0 +1,128 @@ +# 260904 — Flagship natives are always visible + +## Decision + +`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` and `gpt-6-astra` list unconditionally. Every +other native keeps deriving visibility from the live catalog and the authenticated roster. +`gpt-daybreak-blue-latest` stays account-gated. + +Owner decision, 2026-09-04. This is the second half of the work begun in #3442: that PR stopped +a stale client version from making discovery ask a question whose answer omits gpt-5.6. This one +removes the roster from the visibility question entirely for the flagship set. + +## Why the version fix was not enough + +#3442 guarantees we ask upstream under an adequate version. It cannot guarantee an answer. A +roster still fails to confirm when the account is unconfirmed, the fetch times out, the network +is down, or the account genuinely does not carry the slug on its shard yet. In every one of those +cases the model silently disappears from the picker, which reads to the user as "opencodex lost +my model" rather than "upstream did not confirm it". + +Live evidence from this session: two subagent dispatches against `gpt-5.6-sol` died with +`401 No eligible Codex account supports this model` from the local proxy. That string is +`src/codex/auth-context.ts` refusing before dispatch, on entitlement evidence alone. + +## Mechanism + +`ACCOUNT_GATED_NATIVE_OPENAI_MODELS` in `src/codex/catalog/native-models.ts` is the single +switch. Membership makes `nativeModelRows` and `nativeOpenAiSlugs` filter the slug out unless +`availableAccountGatedNativeModels` confirms it, and makes `auth-context.ts` refuse the request +before it is sent. + +`gpt-6-astra` was already ungated by exactly this route in `6f634eddc`, and the 5.6 trio is +already listed in `DOCUMENTED_NATIVE_OPENAI_ADDITIONS`, so the change is removing three strings +from one set. Following the existing precedent rather than inventing a mechanism is the point. + +## The four risks, settled + +**1. Authorization.** The set is the only trigger for the entitlement checks in +`auth-context.ts` (~408, ~435, ~461, ~498) and `isDirectCallerEntitledToCodexModel` returns +`true` immediately for any slug outside it. What disappears is a pre-flight roster check. What +remains: a caller-owned Direct request still dispatches on its own bearer; the admission-bearer +path still runs the drain fence, `beginCodexAccountSelection` and `claimMainProfile` before the +gated check, and its account is fixed as main by construction. No path can select a wrong +account or send one account's credential under another. Unentitled means an upstream 400, which +is the honest answer and the same posture astra ships. + +**2. Wire normalization.** `CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS` holds exactly one entry, +`gpt-daybreak-blue-latest -> gpt-5.6-sol`. The trio are the *target* of that rewrite, never a +key, and the function reads its own map rather than the gated set. The wire id for the trio is +the slug itself, before and after. No edit needed. + +**3. The floor — the one that could have undone #3442.** `deriveGatedClientVersionFloor` filters +the bundled snapshot to slugs *in the gated set*. All three carry `minimal_client_version` +`0.142.2`; Daybreak has no row. So after removal the derivation returns `null` and falls to the +`0.142.2` fallback. Measured directly against the real snapshot: + +```text +derived NOW = 0.142.2 derived AFTER = null +composed NOW = 0.144.0 composed AFTER = 0.144.0 +``` + +The floor holds, because `MEASURED_GATED_CLIENT_VERSION_MINIMUM` wins the comparison either way. +But it is now held up by that constant *alone*, with the derivation permanently inert — the +opposite of what its comment anticipates ("when a future snapshot refresh records 0.144.0 or +higher, the derivation takes over naturally"). That comment must be corrected. + +**`ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS` keeps all three entries.** +`hasUnknownGatedAbsence` iterates that map on its own, never consulting the gated set, to choose +between the 5-minute success TTL and the 15-second failure TTL. After #3442 every version-less +resolution is clamped to the floor, which equals the minimum these entries hold, so the guard is +reachable only through tier 1 — a client that self-declares an older version. The entries keep +that under-versioned escape hatch alive, which is the whole of why they stay. + +The cost, recorded rather than hidden: such a client drops the entire account roster to the 15s +failure TTL instead of 5 minutes, a 20x refetch amplification bounded to four concurrent flights +per account, and after ungating that buys nothing for the trio. Small, bounded, and only +reachable from a self-declared old client. (An earlier draft justified these entries by claiming +they protect Daybreak; that was false — Daybreak is deliberately absent from the map. See +`005_audit_synthesis.md`.) + +**4. Pool routing — a real trade, recorded rather than discovered later.** `modelEligibleAccountIds` +becomes `undefined` for the trio, so `pickCodexAccount` stops filtering candidates by grant. In a +multi-account pool where only one account owns sol, a request may now land on a non-owning +account, take a 400, and spend one blind alternate retry, where today it was routed to the owner. +Nothing unsafe: selection binds each account's own credential and `retryCodexPoolOnAlternateAccount` +refuses alternates for a fixed account. The bounded same-account 400 replay also collapses from +seven retries to one, since that ladder is gated on the same set. + +This is the honest cost of the decision. It is accepted because the failure it replaces is worse +*for the user this change exists to serve*: an owner whose roster did not confirm in time sees +the model vanish, with no error and no way to tell whether they own it. For them a visible +refusal beats a silent disappearance. + +The claim does not generalise, and the audit was right to push on it. `gpt-5.6-luna` is not just +a picker row: it is the default web-search sidecar model (`src/web-search/index.ts`) and the +shadow-call source model (`src/lib/shadow-call.ts`). A single-account user who does not own it +can now select it as a default and get recurring upstream errors where the row used to be +absent. That is the real trade, and it is the owner's call to accept it. + +## Two more readers of the gated set + +`subagentFallbackNeedsModelEntitlements` returns false for a trio-only fallback chain, so the +dispatch skips entitlement resolution entirely and `modelEligibleAccountIds` is undefined for the +whole request. And the `accountGatedModel` affinity diagnostic reclassifies the trio — telemetry +only, but the recorded semantics change. + +`subagent-model-fallback.ts` also gates `preserveDrainingMainCandidate` on the set. That one is a +genuine hazard rather than an accepted cost: ungating it would let a native-main drain silently +rewrite the operator's configured subagent model instead of returning maintenance. The predicate +moves to the native OpenAI set, which is what it always meant — the drain fence protects the +atomic main claim and has nothing to do with entitlement. See `005_audit_synthesis.md`. + +## Account-qualified clones + +`codex-/gpt-5.6-sol` will now be emitted for every configured selector, including +accounts that do not own the model, because the caller-side filter in `convergence.ts`, +`sync.ts` and `index.ts` short-circuits on `!ACCOUNT_GATED...has(slug)`. Accepted: astra already +behaves this way, and a bare row that always lists while the account-qualified row stays hidden +would be incoherent — the qualified row is the more specific selector, and it is exactly what a +multi-account user needs in order to discover which account owns the model. + +One user-visible consequence: an unentitled exact selector used to throw +"Selected Codex account does not support this model" and will now surface an upstream 400. + +## Work phases + +- `010` — ungate the trio, keep the minimums, correct the comments, retarget coverage. +- `020` — land it. diff --git a/devlog/_plan/260904_flagship_native_always_visible/005_audit_synthesis.md b/devlog/_plan/260904_flagship_native_always_visible/005_audit_synthesis.md new file mode 100644 index 0000000000..c245145d0b --- /dev/null +++ b/devlog/_plan/260904_flagship_native_always_visible/005_audit_synthesis.md @@ -0,0 +1,77 @@ +# 005 — Audit synthesis (round 1: FAIL) + +An adversarial auditor returned FAIL with four blockers. Three are accepted outright; one is +accepted with a correction to the auditor's own framing. Every claim was re-checked in-tree. + +## Accepted 1 — the reason given for keeping the minimums map was false + +`000_research.md` justified keeping the three entries in +`ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS` by claiming that emptying it would +reintroduce #3022 "against Daybreak". That is wrong. The map has only ever held the trio, and +`gpt-daybreak-blue-latest` is deliberately absent — the comment at its definition says so, and a +test pins `has(DAYBREAK) === false`. Daybreak was never protected by that map and cannot be. + +Writing that into a code comment would have been worse than leaving it out: it would have become +the load-bearing explanation for the next maintainer, and it is false. + +The honest reason to keep the entries is narrower. `hasUnknownGatedAbsence` fires only when the +asking `client_version` is below the recorded minimum, and after #3442 every version-less +resolution is clamped to the floor, which equals that minimum. So the guard is reachable only +through tier 1 — a client that self-declares an older version. For that client the entries keep +the under-versioned escape hatch alive, which is why they stay. + +The residual cost, which the plan never named: such a client drops the whole account roster to +the 15-second failure TTL instead of the 5-minute success TTL, a 20x refetch amplification, +bounded to four concurrent flights per account. After ungating, that amplification buys nothing +for the trio, because their absence no longer affects any projection. It is small, bounded, and +only reachable from a self-declared old client, so it is accepted and recorded rather than +engineered away. + +## Accepted 2 — a real subagent hazard the plan missed + +`subagent-model-fallback.ts` gates `preserveDrainingMainCandidate` on membership in the gated +set. During a native-main drain with no non-main candidate, main is currently retained as a +read-free sentinel so final auth returns a maintenance error and the atomic claim is respected. +Ungated, that predicate goes false, control reaches `return true`, the model reads as +unavailable, and the fallback chain rewrites to the next model. + +That is the operator's configured subagent model being silently swapped mid-drain — exactly the +failure mode `AGENTS.md` warns about for this chain. It is not "one 400"; it is a different model +answering than the operator chose. + +**Decision: preserve the sentinel on a predicate that is not the gated set.** The drain fence +exists to stop a routed fallback from bypassing the atomic main claim, and that reasoning has +nothing to do with entitlement. The condition becomes membership in the native OpenAI set, which +is what it always meant. A regression covers it. + +## Accepted 3 — two more readers now listed + +`subagentFallbackNeedsModelEntitlements` returns false for a trio-only chain, so the dispatch +skips entitlement resolution entirely. And the `accountGatedModel` affinity diagnostic silently +reclassifies the trio — telemetry only, but a recorded semantic change. Both are added to the +mechanism section. + +## Accepted 4 — test list extended, and the "visible refusal" claim narrowed + +Two suites added: `codex-convergence-account-selectors.test.ts` (`expectCanonicalContent` now +*requires* the trio in a rosterless fixture, inverting what it was built to prove) and +`subagent-roster-retention.test.ts`. + +The auditor is right that "a visible refusal beats a silent disappearance" was stated too +broadly. `gpt-5.6-luna` is the default web-search sidecar model and the shadow-call source +model, so for a single-account user who does not own it, an always-visible row can be selected as +a default and produce recurring upstream errors where the row used to be simply absent. + +That is not a reason to reverse the decision — the owner asked for these models to be listed +unconditionally, and the silent-disappearance failure is what prompted it. Two of this session's +own subagent dispatches died on `401 No eligible Codex account supports this model`. But the +claim in `000` is narrowed to what is actually true: a visible refusal beats a silent +disappearance *for a user who owns the model and was denied it by missing evidence*, which is the +case this change exists to fix. The default-model consequence is recorded rather than glossed. + +## Verified sound + +Wire normalization is untouched, the floor arithmetic reproduces exactly +(`derived AFTER = null`, `composed AFTER = 0.144.0`), and no catalog validation, sync or desktop +projection rejects an entitlement-unconfirmed slug, so there is no startup or convergence failure +path. diff --git a/devlog/_plan/260904_flagship_native_always_visible/010_wp2_ungate.md b/devlog/_plan/260904_flagship_native_always_visible/010_wp2_ungate.md new file mode 100644 index 0000000000..06992bc2d1 --- /dev/null +++ b/devlog/_plan/260904_flagship_native_always_visible/010_wp2_ungate.md @@ -0,0 +1,69 @@ +# 010 — wp2: ungate the flagship trio + +## Source changes + +**`src/codex/catalog/native-models.ts`** — remove `gpt-5.6-sol`, `gpt-5.6-terra` and +`gpt-5.6-luna` from `ACCOUNT_GATED_NATIVE_OPENAI_MODELS`, leaving `gpt-daybreak-blue-latest`. +Rewrite the doc comment: it currently says availability "is not static" and that Pool routing +requires the authenticated roster, which stops being true for the trio. Record the owner decision +the way the astra comment does, including the pool trade. + +**`src/codex/model-entitlements.ts`** — no behavioural change, two comment corrections: + +1. `ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS` keeps its three entries and gains a + comment saying why they outlive gating: `hasUnknownGatedAbsence` iterates this map alone, and + emptying it would make the TTL guard constant-false and reintroduce #3022 against Daybreak. +2. `MEASURED_GATED_CLIENT_VERSION_MINIMUM`'s stated exit condition can no longer occur, because + no gated slug carries a snapshot row any more. The constant is load-bearing indefinitely. + +**`src/codex/subagent-model-fallback.ts`** — `preserveDrainingMainCandidate` moves off the gated +set onto `SUPPORTED_NATIVE_OPENAI_SLUGS`. The drain sentinel exists so a routed fallback cannot +bypass the atomic main claim during a native-main drain; that reasoning never had anything to do +with entitlement, and leaving it on the gated set would let ungating silently swap the operator's +configured subagent model mid-drain. + +No change in `src/server/responses/core.ts`: wire normalization reads its own map. + +## Tests + +The contract genuinely changed, so several assertions must move. The rule applied throughout: +**retarget onto Daybreak rather than delete**, so the fail-closed and floor coverage keeps +testing something real instead of quietly going hollow. + +New, and RED before the change: + +1. With the entitlement cache reset and no confirming roster, `nativeModelRows` lists all four + flagship slugs, and `ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has("gpt-5.6-sol")` is false. This is + the assertion that pins the decision so a future sync cannot silently re-gate. +2. `disabledModels` still hides an ungated flagship — the user's lever survives. +3. `codexAccountGatedCanonicalWireModel("gpt-5.6-sol")` is `undefined`, so the wire id is still + the requested slug. +4. The composed floor is still `0.144.0` after the gated set shrinks, asserted through + `composeGatedClientVersionFloorForTests` on the real snapshot with the new set. This is the + regression that would catch a silent undo of #3442. +5. Daybreak is still filtered out without a roster, in the same test, so ungating is proven + scoped rather than global. + +Updated because the contract moved: + +- `tests/codex-catalog-sync-hardening.test.ts` "Gap B" — the three `not.toContain` assertions + flip to `toContain`; the Daybreak `not.toContain` stays so the case still proves fail-closed. +- `tests/codex-model-entitlements.test.ts` — `availableAccountGatedNativeModels` expectations + now yield `[DAYBREAK]`. The floor and TTL suite retargets onto Daybreak. +- `tests/codex-auth-context.test.ts` — the pool fail-closed cases retarget onto Daybreak. They + are the only coverage of that path and must not be deleted. +- `tests/native-model-toggle.test.ts` — add explicit "lists without any roster" assertions. +- `tests/codex-catalog.test.ts` — a comment claiming Sol is account-gated becomes false. +- `tests/codex-convergence-account-selectors.test.ts` — `expectCanonicalContent` now *requires* + the trio in a rosterless fixture, inverting what that fixture was built to prove. Verify it + passes for the right reason rather than by self-adjusting. +- `tests/subagent-roster-retention.test.ts` — touched by the gated set. + +Plus a regression pinning the drain sentinel: during a native-main drain with no non-main +candidate, an ungated flagship model must still retain main as a read-free sentinel rather than +rewriting to the next model in the chain. + +## Verification + +Focused runs on every touched suite, then the full suite, with each failure compared against a +clean-`dev` baseline before it is called a regression. diff --git a/devlog/_plan/260904_flagship_native_always_visible/020_wp3_landing.md b/devlog/_plan/260904_flagship_native_always_visible/020_wp3_landing.md new file mode 100644 index 0000000000..5d27bc6f28 --- /dev/null +++ b/devlog/_plan/260904_flagship_native_always_visible/020_wp3_landing.md @@ -0,0 +1,12 @@ +# 020 — wp3: landing + +1. `bun run typecheck` +2. `bun run privacy:scan` +3. `bun run test` — PR-ready gate; compare every failure against clean `dev` first. +4. Branch `codex/260904-flagship-native-always-visible` off current `dev`, targeting `dev`. +5. PR with `.github/PULL_REQUEST_TEMPLATE.md` fully filled, naming the pool-routing trade + explicitly so a multi-account user is not surprised by it. +6. Push `--no-verify` and merge on green CI; both owner-approved for this unit. + +`dev` moved four times during the previous unit, so rebase before each push rather than assuming +the branch point is still current. diff --git a/src/codex/catalog/native-models.ts b/src/codex/catalog/native-models.ts index 26341516f0..75c7bfe2a8 100644 --- a/src/codex/catalog/native-models.ts +++ b/src/codex/catalog/native-models.ts @@ -27,11 +27,32 @@ export const NATIVE_DAYBREAK_BLUE_MODEL = "gpt-daybreak-blue-latest"; */ export const NATIVE_GPT6_ASTRA_MODEL = "gpt-6-astra"; -/** Native ChatGPT/Codex ids whose availability is proven per authenticated account. */ +/** + * Native ChatGPT/Codex ids whose availability is proven per authenticated account. + * + * Membership is expensive: it hides the row from the catalog, `/v1/models`, the dashboard and + * the desktop projection until an authenticated `/models` roster confirms it, AND it makes + * `auth-context.ts` refuse the request before it is sent. Both halves fail closed on ABSENCE of + * evidence, not on a denial. + * + * The flagship models are deliberately NOT here (owner decision, 2026-09-04). #3442 made + * discovery ask upstream under an adequate client version, which guarantees the QUESTION is + * fair but cannot guarantee an ANSWER: an unconfirmed account, a timed-out fetch, or a shard + * that has not caught up all produce the same silent disappearance, and a model vanishing from + * the picker reads as "opencodex lost my model" rather than "upstream did not confirm it". + * Listing them unconditionally means the request dispatches and the user sees the real upstream + * status. `disabledModels` remains the visibility lever. + * + * The cost, accepted knowingly: Pool routing no longer prefers an account that owns the model, + * so a multi-account user may take one upstream 400 and one alternate retry where they used to + * be routed straight to the owner. Nothing unsafe — each account still sends its own credential + * — and `gpt-6-astra` has shipped this way since 6f634eddc. + * + * `gpt-daybreak-blue-latest` stays gated. It has no shipped catalog row anywhere, so absence is + * the only signal that exists for it, and the ungating decision was scoped to the flagships. + * Evidence: devlog/_plan/260904_flagship_native_always_visible/. + */ export const ACCOUNT_GATED_NATIVE_OPENAI_MODELS: ReadonlySet = new Set([ - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", NATIVE_DAYBREAK_BLUE_MODEL, ]); diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 27eea1e53d..e78188dacb 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -38,7 +38,7 @@ import { import { routeModel, type RouteResult } from "../router"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; import { codexAccountNamespaceForModel } from "./account-namespace-match"; -import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, SUPPORTED_NATIVE_OPENAI_SLUGS } from "./catalog/native-models"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { getUpstreamHostHealth, @@ -328,9 +328,16 @@ export function isSubagentModelUnavailable( // preserve the credential fence. If no non-main candidate can serve an unqualified // gated model, retain main only as a read-free sentinel: final auth owns the atomic // claim and returns maintenance instead of letting a routed fallback bypass it. + // + // The predicate is the SUPPORTED NATIVE set, not the account-gated one. The sentinel exists + // to protect the atomic main claim during a drain, which has nothing to do with entitlement; + // it read the gated set only because every native it applied to happened to be gated. When + // the flagships were ungated (2026-09-04) that accident would have flipped this false and let + // a drain silently rewrite the operator's configured subagent model to the next chain entry + // instead of reporting maintenance -- a different model answering than was chosen. const preserveDrainingMainCandidate = route.codexAccountId === undefined && candidateAccountUsabilityOptions?.nativeMainSelectionOnly === true - && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId); + && SUPPORTED_NATIVE_OPENAI_SLUGS.has(route.modelId); if (!preserveDrainingMainCandidate) return true; const drainingMainUsabilityOptions: CodexAccountUsabilityOptions = { ...candidateAccountUsabilityOptions, diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index 38cf7d2c8a..b002caee30 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -1168,13 +1168,17 @@ describe("Codex auth context", () => { "chatgpt-account-id": "caller-keyring-account", }), cfg, "pool", { requestScopedMainCredential: true, - modelId: "gpt-5.6-sol", + // Uses the one model still account-gated. These #3157 cases are about how a caller + // entitlement MISS interacts with the main pin, so they need a model whose entitlement is + // actually consulted; the flagships stopped being gated on 2026-09-04 and now skip the + // check entirely, which would leave directEntitlementChecks at 0 and prove nothing. + modelId: "gpt-daybreak-blue-latest", isDirectCallerEntitledToCodexModel: async () => { directEntitlementChecks += 1; return options.callerEntitled; }, resolveCodexModelEntitlements: async () => ({ - modelsByAccount: new Map([["pool-a", new Set(["gpt-5.6-sol"])]]), + modelsByAccount: new Map([["pool-a", new Set(["gpt-daybreak-blue-latest"])]]), clientVersionByAccount: new Map([["pool-a", "0.150.1"]]), confirmedAccountIds: new Set(["pool-a"]), credentialIdentities: new Map([["pool-a", "pool:1:pool-account"]]), diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index ec51fad5ba..39da672c99 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -139,11 +139,16 @@ describe("Codex catalog sync hardening", () => { expect(slugs).toContain("gpt-5.4"); expect(slugs).toContain("gpt-5.4-mini"); expect(slugs).toContain("gpt-5.3-codex-spark"); - // This isolated fixture has no authenticated ChatGPT roster, so account-gated - // native models must fail closed rather than remain selectable. - expect(slugs).not.toContain("gpt-5.6-sol"); - expect(slugs).not.toContain("gpt-5.6-terra"); - expect(slugs).not.toContain("gpt-5.6-luna"); + // This isolated fixture has no authenticated ChatGPT roster. The flagship natives list + // anyway (owner decision 2026-09-04): asking upstream under an adequate client version + // makes the question fair but cannot make an answer appear, and a model that silently + // vanishes reads as a bug rather than as missing evidence. + expect(slugs).toContain("gpt-5.6-sol"); + expect(slugs).toContain("gpt-5.6-terra"); + expect(slugs).toContain("gpt-5.6-luna"); + // Scoped, not global: Daybreak has no shipped catalog row anywhere, so absence is the only + // signal it has and it must still fail closed here. This is what keeps the case honest. + expect(slugs).not.toContain("gpt-daybreak-blue-latest"); expect(slugs).toContain("user-native"); // genuine user native preserved expect(slugs).not.toContain("gpt-5.3-codex"); // legacy dropped expect(slugs).not.toContain("gpt-5.2"); // legacy dropped diff --git a/tests/codex-model-entitlements.test.ts b/tests/codex-model-entitlements.test.ts index d23738b784..43c84e3173 100644 --- a/tests/codex-model-entitlements.test.ts +++ b/tests/codex-model-entitlements.test.ts @@ -196,10 +196,14 @@ describe("Codex account model entitlements", () => { }); expect([...entitledCodexAccountIdsForModel(snapshot, DAYBREAK)!]).toEqual(["main"]); - expect([...entitledCodexAccountIdsForModel(snapshot, SOL)!]).toEqual(["main", "secondary"]); - expect([...entitledCodexAccountIdsForModel(snapshot, TERRA)!]).toEqual(["secondary"]); - expect([...entitledCodexAccountIdsForModel(snapshot, LUNA)!]).toEqual(["main"]); - expect([...availableAccountGatedNativeModels(snapshot)]).toEqual([SOL, TERRA, LUNA, DAYBREAK]); + // The per-account roster is still recorded for the flagships -- the evidence does not stop + // being collected -- but they are no longer GATED on it, so the gated projections skip them + // entirely and return undefined rather than a scoped account set. + expect(snapshot.modelsByAccount.get("secondary")?.has(TERRA)).toBe(true); + expect(entitledCodexAccountIdsForModel(snapshot, SOL)).toBeUndefined(); + expect(entitledCodexAccountIdsForModel(snapshot, TERRA)).toBeUndefined(); + expect(entitledCodexAccountIdsForModel(snapshot, LUNA)).toBeUndefined(); + expect([...availableAccountGatedNativeModels(snapshot)]).toEqual([DAYBREAK]); }); test("fails closed when an account roster cannot be confirmed", async () => { @@ -336,6 +340,11 @@ describe("tri-state entitlement authority", () => { "chatgpt-account-id": "tri-state-account", }); + // The tri-state contract is retargeted onto DAYBREAK, the one model still account-gated after + // the flagship ungating (2026-09-04). The mechanism under test never changed; only its subject + // did. Deleting these because sol left the gated set would have removed the only coverage of + // the fail-closed path while that path still governs a shipped model. Daybreak has no recorded + // minimum, so a version-scoped case uses SOL_MINIMUM_SLUG, which stays in the minimums map. test("an omitted gated slug below its minimum is unknown and uses the failure TTL", async () => { let fetches = 0; const backend = (async () => { @@ -343,18 +352,18 @@ describe("tri-state entitlement authority", () => { return roster("gpt-5.5"); }) as typeof fetch; - expect(await isDirectCallerEntitledToCodexModel(directHeaders(), SOL, { + expect(await isDirectCallerEntitledToCodexModel(directHeaders(), DAYBREAK, { fetcher: backend, now: 1_000, clientVersion: "0.140.0", })).toBe(false); - expect(await isDirectCallerEntitledToCodexModel(directHeaders(), SOL, { + expect(await isDirectCallerEntitledToCodexModel(directHeaders(), DAYBREAK, { fetcher: backend, now: 15_999, clientVersion: "0.140.0", })).toBe(false); expect(fetches).toBe(1); - expect(await isDirectCallerEntitledToCodexModel(directHeaders(), SOL, { + expect(await isDirectCallerEntitledToCodexModel(directHeaders(), DAYBREAK, { fetcher: backend, now: 16_001, clientVersion: "0.140.0", @@ -368,9 +377,12 @@ describe("tri-state entitlement authority", () => { clientVersion: "0.140.0", }); expect(snapshot.clientVersionByAccount.get("main")).toBe("0.140.0"); + // SOL keeps a recorded minimum even though it is no longer gated, so the version-scoped + // unknown-vs-denied distinction is still observable through the raw projection. expect(projectedEntitlementState(snapshot, "main", SOL)).toBe("unknown"); - expect(entitledCodexAccountIdsForModel(snapshot, SOL)?.size).toBe(0); - expect(availableAccountGatedNativeModels(snapshot).has(SOL)).toBe(false); + // But it no longer participates in the gated projections at all. + expect(entitledCodexAccountIdsForModel(snapshot, SOL)).toBeUndefined(); + expect(availableAccountGatedNativeModels(snapshot).has(DAYBREAK)).toBe(false); }); test("an omitted gated slug at its minimum is denied", async () => { @@ -382,21 +394,22 @@ describe("tri-state entitlement authority", () => { }); expect(projectedEntitlementState(snapshot, "main", SOL)).toBe("denied"); - expect(entitledCodexAccountIdsForModel(snapshot, SOL)?.size).toBe(0); - expect(availableAccountGatedNativeModels(snapshot).has(SOL)).toBe(false); + expect(projectedEntitlementState(snapshot, "main", DAYBREAK)).toBe("denied"); + expect(entitledCodexAccountIdsForModel(snapshot, DAYBREAK)?.size).toBe(0); + expect(availableAccountGatedNativeModels(snapshot).has(DAYBREAK)).toBe(false); }); test("a present gated slug below its minimum is granted", async () => { const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { credentials: [credential("main")], - fetcher: (async () => roster("gpt-5.5", SOL)) as typeof fetch, + fetcher: (async () => roster("gpt-5.5", DAYBREAK)) as typeof fetch, now: 1_000, clientVersion: "0.140.0", }); - expect(projectedEntitlementState(snapshot, "main", SOL)).toBe("granted"); - expect([...entitledCodexAccountIdsForModel(snapshot, SOL)!]).toEqual(["main"]); - expect(availableAccountGatedNativeModels(snapshot).has(SOL)).toBe(true); + expect(projectedEntitlementState(snapshot, "main", DAYBREAK)).toBe("granted"); + expect([...entitledCodexAccountIdsForModel(snapshot, DAYBREAK)!]).toEqual(["main"]); + expect(availableAccountGatedNativeModels(snapshot).has(DAYBREAK)).toBe(true); }); test("Daybreak omission remains denied without a known minimum", async () => { @@ -423,30 +436,38 @@ describe("tri-state entitlement authority", () => { now: 1_000, clientVersion: "0.140.0", }); - expect(entitledCodexAccountIdsForModel(snapshot, SOL)?.size).toBe(0); - expect(availableAccountGatedNativeModels(snapshot).has(SOL)).toBe(false); + expect(entitledCodexAccountIdsForModel(snapshot, DAYBREAK)?.size).toBe(0); + expect(availableAccountGatedNativeModels(snapshot).has(DAYBREAK)).toBe(false); seedCodexModelEntitlementsForTests("main", ["gpt-5.5"], 1_000, "0.140.0"); - expect(cachedAvailableAccountGatedNativeModels(1_001, undefined, "0.140.0").has(SOL)) + expect(cachedAvailableAccountGatedNativeModels(1_001, undefined, "0.140.0").has(DAYBREAK)) .toBe(false); - expect(await isDirectCallerEntitledToCodexModel(directHeaders(), SOL, { + expect(await isDirectCallerEntitledToCodexModel(directHeaders(), DAYBREAK, { fetcher: (async () => roster("gpt-5.5")) as typeof fetch, now: 1_000, clientVersion: "0.140.0", })).toBe(false); + + // An ungated flagship is the opposite case and is asserted here so the two contracts stay + // visibly distinct: Direct authorization admits it without consulting any roster at all. + expect(await isDirectCallerEntitledToCodexModel(directHeaders(), SOL, { + fetcher: (async () => { throw new Error("an ungated model must not be looked up"); }) as unknown as typeof fetch, + now: 1_000, + clientVersion: "0.140.0", + })).toBe(true); }); test("CHARACTERIZATION: an unconfirmed roster cannot grant a present gated slug", () => { const snapshot = { - modelsByAccount: new Map([["main", new Set([SOL])]]), + modelsByAccount: new Map([["main", new Set([DAYBREAK])]]), clientVersionByAccount: new Map([["main", "0.140.0"]]), confirmedAccountIds: new Set(), credentialIdentities: new Map([["main", "test:main"]]), }; - expect(projectedEntitlementState(snapshot, "main", SOL)).toBe("unknown"); - expect(entitledCodexAccountIdsForModel(snapshot, SOL)?.size).toBe(0); - expect(availableAccountGatedNativeModels(snapshot).has(SOL)).toBe(false); + expect(projectedEntitlementState(snapshot, "main", DAYBREAK)).toBe("unknown"); + expect(entitledCodexAccountIdsForModel(snapshot, DAYBREAK)?.size).toBe(0); + expect(availableAccountGatedNativeModels(snapshot).has(DAYBREAK)).toBe(false); }); }); @@ -1022,8 +1043,16 @@ describe("entitlement client version (#2886)", () => { const version = url.searchParams.get("client_version") ?? ""; seen.push(version); const major = Number(version.split(".")[1] ?? "0"); - // Below the GPT-5.6 threshold upstream simply omits those rows. - return major >= 144 ? roster("gpt-5.5", SOL, TERRA, LUNA) : roster("gpt-5.5"); + // Below the GPT-5.6 threshold upstream simply omits the version-filtered rows. + // + // DAYBREAK rides along with the trio here. The flagships stopped being account-gated in + // 2026-09-04, so asserting the floor through `availableAccountGatedNativeModels` on them + // alone would be vacuous -- that projection no longer contains them. Including the one + // model still gated keeps this whole #2886/#3022 suite measuring the thing it exists for: + // that an under-reported client version does not turn into a manufactured denial. + return major >= 144 + ? roster("gpt-5.5", SOL, TERRA, LUNA, DAYBREAK) + : roster("gpt-5.5"); }) as typeof fetch; } @@ -1039,7 +1068,9 @@ describe("entitlement client version (#2886)", () => { expect(seen).toEqual(["0.146.0"]); // The wrong behavior: an entitled account classified as denying GPT-5.6 because // OpenCodex under-reported its own client version. - expect([...availableAccountGatedNativeModels(snapshot)]).toEqual([SOL, TERRA, LUNA]); + expect([...availableAccountGatedNativeModels(snapshot)]).toEqual([DAYBREAK]); + // The flagships are present in the recorded roster too; they simply no longer need to be. + expect(snapshot.modelsByAccount.get("main")?.has(SOL)).toBe(true); expect(snapshot.confirmedAccountIds.has("main")).toBe(true); }); @@ -1064,7 +1095,7 @@ describe("entitlement client version (#2886)", () => { const version = url.searchParams.get("client_version") ?? ""; seen.push(version); const minor = Number(version.split(".")[1] ?? "0"); - return minor >= 144 ? roster("gpt-5.5", SOL, TERRA, LUNA) : roster("gpt-5.5"); + return minor >= 144 ? roster("gpt-5.5", SOL, TERRA, LUNA, DAYBREAK) : roster("gpt-5.5"); }) as typeof fetch, now: 1_000, clientVersion: null, @@ -1077,7 +1108,10 @@ describe("entitlement client version (#2886)", () => { expect(snapshot.confirmedAccountIds.has("main")).toBe(true); // Read the SNAPSHOT, not the process-wide cache: another suite in the same run can leave // a confirmed entry behind, and this assertion is about what this discovery pass proved. - expect([...availableAccountGatedNativeModels(snapshot)]).toEqual([SOL, TERRA, LUNA]); + // Asserted through DAYBREAK, the one model still account-gated: the flagships no longer + // appear in this projection, so asserting them here would be vacuous rather than green. + expect([...availableAccountGatedNativeModels(snapshot)]).toEqual([DAYBREAK]); + expect(snapshot.modelsByAccount.get("main")?.has(SOL)).toBe(true); expect(snapshot.modelsByAccount.has("main")).toBe(true); }); @@ -1105,7 +1139,7 @@ describe("entitlement client version (#2886)", () => { // Gated against the floor itself rather than a hardcoded minor, so raising the floor // moves the fixture with it instead of silently mis-gating. return compareClientVersionsForTests(version, GATED_MODEL_CLIENT_VERSION_FLOOR) >= 0 - ? roster("gpt-5.5", SOL, TERRA, LUNA) + ? roster("gpt-5.5", SOL, TERRA, LUNA, DAYBREAK) : roster("gpt-5.5"); }) as typeof fetch, now: 1_000, @@ -1116,7 +1150,7 @@ describe("entitlement client version (#2886)", () => { // The stale version is never what upstream is asked. expect(seen).toEqual([GATED_MODEL_CLIENT_VERSION_FLOOR]); expect(projectedEntitlementState(snapshot, "main", SOL)).toBe("granted"); - expect([...availableAccountGatedNativeModels(snapshot)]).toEqual([SOL, TERRA, LUNA]); + expect([...availableAccountGatedNativeModels(snapshot)]).toEqual([DAYBREAK]); }); test("the floor raises a stale runtime but never lowers a current one", () => { @@ -1217,12 +1251,12 @@ describe("entitlement client version (#2886)", () => { const backend = (async () => { opened += 1; await new Promise(resolve => gate.push(resolve)); - return roster(SOL); + return roster(DAYBREAK); }) as typeof fetch; const asks = Array.from({ length: 12 }, (_, i) => isDirectCallerEntitledToCodexModel( directHeaders("tok-flights"), - SOL, + DAYBREAK, { fetcher: backend, now: 1_000, clientVersion: `0.${400 + i}.0` }, )); @@ -1346,16 +1380,17 @@ describe("entitlement client version (#2886)", () => { // models from a newer client or advertise them to an older one (#2548, inverted). The // cache holds one entry per account, so what matters is that the entry knows its own // version and the projection respects it. - seedCodexModelEntitlementsForTests("main", [SOL, TERRA, LUNA], 1_000, "0.146.0"); + // Uses DAYBREAK: this projection reads the account-gated set, which the flagships left. + seedCodexModelEntitlementsForTests("main", [DAYBREAK], 1_000, "0.146.0"); expect([...cachedAvailableAccountGatedNativeModels(1_100, undefined, "0.146.0")]) - .toEqual([SOL, TERRA, LUNA]); + .toEqual([DAYBREAK]); // A caller asking about an older client must not be handed the newer client's roster. expect([...cachedAvailableAccountGatedNativeModels(1_100, undefined, "0.140.0")]).toEqual([]); // An unusable version cannot select an entry at all, so it degrades to the unfiltered // read rather than silently matching one. expect([...cachedAvailableAccountGatedNativeModels(1_100, undefined, "0.0.0")]) - .toEqual([SOL, TERRA, LUNA]); + .toEqual([DAYBREAK]); }); // The projection test above seeds the cache directly, so it cannot see the cache-hit key or @@ -1372,22 +1407,22 @@ describe("entitlement client version (#2886)", () => { const backend = (async (input: RequestInfo | URL) => { const url = new URL(input instanceof Request ? input.url : String(input)); asked.push(url.searchParams.get("client_version") ?? ""); - return roster(SOL); + return roster(DAYBREAK); }) as typeof fetch; // Same account, same credential, same instant — only the version differs. - expect(await isDirectCallerEntitledToCodexModel(directHeaders("tok-refetch"), SOL, { + expect(await isDirectCallerEntitledToCodexModel(directHeaders("tok-refetch"), DAYBREAK, { fetcher: backend, now: 1_000, clientVersion: "0.146.0", })).toBe(true); // Second ask under the SAME version is served from cache: no new request. - expect(await isDirectCallerEntitledToCodexModel(directHeaders("tok-refetch"), SOL, { + expect(await isDirectCallerEntitledToCodexModel(directHeaders("tok-refetch"), DAYBREAK, { fetcher: backend, now: 1_000, clientVersion: "0.146.0", })).toBe(true); expect(asked).toEqual(["0.146.0"]); // A different version is a different question and must reach upstream again, even though // the entry is still well within its TTL. - expect(await isDirectCallerEntitledToCodexModel(directHeaders("tok-refetch"), SOL, { + expect(await isDirectCallerEntitledToCodexModel(directHeaders("tok-refetch"), DAYBREAK, { fetcher: backend, now: 1_000, clientVersion: "0.150.0", })).toBe(true); expect(asked).toEqual(["0.146.0", "0.150.0"]); @@ -1402,15 +1437,15 @@ describe("entitlement client version (#2886)", () => { const url = new URL(input instanceof Request ? input.url : String(input)); const version = url.searchParams.get("client_version") ?? ""; // The newer client is entitled; the older one is not. - const body = version === "0.150.0" ? roster(SOL, TERRA) : roster("gpt-5.5"); + const body = version === "0.150.0" ? roster(DAYBREAK, TERRA) : roster("gpt-5.5"); await new Promise(resolve => release.push(resolve)); return body; }) as typeof fetch; - const newer = isDirectCallerEntitledToCodexModel(directHeaders("tok-race"), SOL, { + const newer = isDirectCallerEntitledToCodexModel(directHeaders("tok-race"), DAYBREAK, { fetcher: backend, now: 1_000, clientVersion: "0.150.0", }); - const older = isDirectCallerEntitledToCodexModel(directHeaders("tok-race"), SOL, { + const older = isDirectCallerEntitledToCodexModel(directHeaders("tok-race"), DAYBREAK, { fetcher: backend, now: 1_000, clientVersion: "0.140.0", }); // Let both requests reach the backend, then complete the NEWER one first so the older, @@ -1435,13 +1470,13 @@ describe("entitlement client version (#2886)", () => { refetches += 1; const url = new URL(input instanceof Request ? input.url : String(input)); // Inverted on purpose: 0.150.0 would become denied, 0.140.0 would become entitled. - return url.searchParams.get("client_version") === "0.150.0" ? roster("gpt-5.5") : roster(SOL); + return url.searchParams.get("client_version") === "0.150.0" ? roster("gpt-5.5") : roster(DAYBREAK); }) as typeof fetch; - expect(await isDirectCallerEntitledToCodexModel(directHeaders("tok-race"), SOL, { + expect(await isDirectCallerEntitledToCodexModel(directHeaders("tok-race"), DAYBREAK, { fetcher: inverted, now: 1_000, clientVersion: "0.150.0", })).toBe(true); - expect(await isDirectCallerEntitledToCodexModel(directHeaders("tok-race"), SOL, { + expect(await isDirectCallerEntitledToCodexModel(directHeaders("tok-race"), DAYBREAK, { fetcher: inverted, now: 1_000, clientVersion: "0.140.0", })).toBe(false); expect(refetches).toBe(0); @@ -1458,10 +1493,10 @@ describe("entitlement client version (#2886)", () => { // eviction test built on it passes without ever storing an entry. (That mistake was made and // caught here: the first version of this test was vacuous for exactly that reason.) let fetches = 0; - const backend = (async () => { fetches += 1; return roster(SOL); }) as typeof fetch; + const backend = (async () => { fetches += 1; return roster(DAYBREAK); }) as typeof fetch; const ask = (token: string, version: string) => isDirectCallerEntitledToCodexModel( directHeaders(token), - SOL, + DAYBREAK, { fetcher: backend, now: 1_000, clientVersion: version }, ); @@ -1484,10 +1519,10 @@ describe("entitlement client version (#2886)", () => { // The per-account bound is what makes the class budget safe. Without it, one account's // versions grow without limit inside its own class. let fetches = 0; - const backend = (async () => { fetches += 1; return roster(SOL); }) as typeof fetch; + const backend = (async () => { fetches += 1; return roster(DAYBREAK); }) as typeof fetch; const ask = (version: string) => isDirectCallerEntitledToCodexModel( directHeaders("tok-bounded"), - SOL, + DAYBREAK, { fetcher: backend, now: 1_000, clientVersion: version }, ); @@ -1509,10 +1544,10 @@ describe("entitlement client version (#2886)", () => { // that by the per-account version bound, so a deployment well inside the intended limit would // start losing evidence: 20 accounts holding 4 versions each is 80 keys but only 20 accounts. let fetches = 0; - const backend = (async () => { fetches += 1; return roster(SOL); }) as typeof fetch; + const backend = (async () => { fetches += 1; return roster(DAYBREAK); }) as typeof fetch; const ask = (token: string, version: string) => isDirectCallerEntitledToCodexModel( directHeaders(token), - SOL, + DAYBREAK, { fetcher: backend, now: 1_000, clientVersion: version }, ); @@ -1542,6 +1577,39 @@ describe("entitlement client version (#2886)", () => { .toBeGreaterThanOrEqual(0); }); + test("ungating the 5.6 family empties the derivation without lowering the floor", () => { + // The trio carried the only snapshot rows the derivation could see: each records 0.142.2, + // and gpt-daybreak-blue-latest has no row at all. Ungating them therefore empties + // deriveGatedClientVersionFloor, which falls to the 0.142.2 fallback -- BELOW the measured + // minimum. The composed floor survives only because the measurement wins that comparison. + // + // Without this test the failure mode is silent: the floor would quietly drop to 0.142.2, + // upstream would answer without gpt-5.6 again, and #3442 would be undone by a change that + // never mentioned it. + const rows = (upstreamModelsSnapshot as { models?: Array> }).models ?? []; + const afterUngating = new Set([DAYBREAK]); + + expect(deriveGatedClientVersionFloor(rows, afterUngating)).toBeNull(); + expect(composeGatedClientVersionFloorForTests(rows, afterUngating)).toBe("0.144.0"); + // And the shipped constant agrees, so this is the live state and not a synthetic one. + expect(GATED_MODEL_CLIENT_VERSION_FLOOR).toBe("0.144.0"); + }); + + test("the client-version minimums outlive gating, and Daybreak was never in that map", () => { + // ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS keeps its three entries after the trio + // stop being gated. hasUnknownGatedAbsence iterates this map alone, and the entries keep the + // tier-1 escape hatch alive for a client that self-declares a version below the floor. + // + // The second assertion pins a fact that was got WRONG while planning this change: Daybreak + // is deliberately absent here, so this map never protected it and emptying the map would not + // have hurt it. A comment claiming otherwise would have become load-bearing and false. + for (const slug of [SOL, TERRA, LUNA]) { + expect(ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS.get(slug)).toBe("0.144.0"); + expect(ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)).toBe(false); + } + expect(ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS.has(DAYBREAK)).toBe(false); + }); + test("the floor is the higher of the derived and the measured minimum, not either alone", () => { // Tested as a COMPOSITION on synthetic inputs. Hardcoding 0.144.0 would satisfy the test // above while destroying the property that matters next: a refreshed snapshot declaring a @@ -1580,7 +1648,7 @@ describe("entitlement client version (#2886)", () => { const ask = (now: number) => isDirectCallerEntitledToCodexModel( directHeaders("tok-empty"), - SOL, + DAYBREAK, { fetcher: empty, now, clientVersion: "0.146.0" }, ); @@ -1602,10 +1670,10 @@ describe("entitlement client version (#2886)", () => { // short roster must keep confirming the account and keep granting what it lists, otherwise // the empty-roster fix would have widened into a denial of service for everyone. let fetches = 0; - const backend = (async () => { fetches += 1; return roster(SOL); }) as typeof fetch; + const backend = (async () => { fetches += 1; return roster(DAYBREAK); }) as typeof fetch; const ask = (now: number) => isDirectCallerEntitledToCodexModel( directHeaders("tok-nonempty"), - SOL, + DAYBREAK, { fetcher: backend, now, clientVersion: "0.146.0" }, ); @@ -1624,14 +1692,14 @@ describe("entitlement client version (#2886)", () => { const filtered = (async () => { fetches += 1; return Response.json({ models: [ - { slug: SOL, supported_in_api: true, visibility: "hide" }, + { slug: DAYBREAK, supported_in_api: true, visibility: "hide" }, { slug: "gpt-disabled", supported_in_api: false, visibility: "list" }, ] }); }) as typeof fetch; const ask = (now: number) => isDirectCallerEntitledToCodexModel( directHeaders("tok-filtered"), - SOL, + DAYBREAK, { fetcher: filtered, now, clientVersion: "0.146.0" }, ); diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index c17cb94475..60bc5e7ec2 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -117,6 +117,28 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(visibleNativeSlugs({ disabledModels: ["gpt-6-astra"] })).not.toContain("gpt-6-astra"); }); + test("the flagship natives list without any roster; only Daybreak still waits for one", () => { + // Owner decision (2026-09-04): gpt-5.6-sol/terra/luna join gpt-6-astra in listing on every + // install. Asking upstream under an adequate client version (#3442) guarantees the QUESTION + // is fair; it cannot guarantee an ANSWER. An unconfirmed account, a timed-out fetch or a + // shard that has not caught up all produce the same silent disappearance, which reads as + // "opencodex lost my model" rather than "upstream did not confirm it". + resetCodexModelEntitlementCacheForTests(); + const flagship = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra"]; + const slugs = nativeModelRows({ disabledModels: [] }).map(row => row.slug); + for (const slug of flagship) { + expect(ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)).toBe(false); + expect(slugs).toContain(slug); + expect(visibleNativeSlugs({ disabledModels: [] })).toContain(slug); + } + // Scoped, not global: Daybreak is a genuinely entitlement-restricted surface with no shipped + // catalog row, so it still waits for a confirming roster. If this flips, the ungating leaked. + expect(ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has("gpt-daybreak-blue-latest")).toBe(true); + expect(slugs).not.toContain("gpt-daybreak-blue-latest"); + // The user's visibility lever is untouched by any of this. + expect(visibleNativeSlugs({ disabledModels: flagship })).not.toContain("gpt-5.6-sol"); + }); + test("the 1M opt-in raises gpt-6-astra to its own 872k ceiling, not the family's 922k", () => { // The dashboard's native 1M toggle writes providerContextCaps.openai = 922_000 for the whole // group. Raising a window only happens for slugs that HAVE an opt-in ceiling, which used to From 9e835dc4ae5983f0d30081f2e61e4df5eee1bcf8 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:47:11 +0900 Subject: [PATCH 2/2] fix(codex): scope the drain sentinel and sync the docs after ungating Review findings on the flagship ungating. The native-main drain sentinel in subagent-model-fallback moved off ACCOUNT_GATED_NATIVE_OPENAI_MODELS in the previous commit, but onto SUPPORTED_NATIVE_OPENAI_SLUGS, which was too wide. That set also holds gpt-5.5, gpt-5.4, gpt-5.4-mini and gpt-5.3-codex-spark -- models this work never touched -- and retaining the sentinel for them turns "fell back and answered" into a maintenance error for the most commonly configured fallback slug in the repo. The predicate now has its own explicit set, NATIVE_MAIN_DRAIN_SENTINEL_MODELS: the account-gated natives plus the four flagships that just left that set, which is exactly what the drain behaviour was reasoned about. The predicate had no direct coverage in its own test file, which is how the widening went unnoticed. tests/subagent-model-fallback.test.ts now pins both edges: the flagships and Daybreak retain main as a read-free sentinel during a drain, while gpt-5.5 and the other non-flagship natives keep advancing the chain. Driven red by widening the set back to every native, which fails the second half. Four more suites asserted the old contract and are retargeted onto Daybreak, the one model still gated: the gated-model 400 replay ladder, the final-auth admission-release accounting, the suppressed-visibility-target case, and a catalog refresh fixture that expected sync to drop the Sol rows. A blanket rename was reverted in subagent-fallback-handle-responses because Daybreak is wire-normalized to Sol and the neighbouring fixtures depend on that; only the one affected case moved. Also drops a now-decorative SOL assertion in favour of one that measures the ungating, corrects a comment naming a symbol that never existed, and documents the behaviour in docs-site: the four flagships always list, an unentitled account sees an upstream refusal instead of an absent row, Pool no longer steers to the owning account first, and disabledModels is the lever. Verification: 81 pass / 0 fail across responses-pool-401-refresh, subagent-fallback-handle-responses, model-visibility-management-api and codex-refresh; 60 pass / 0 fail in subagent-model-fallback. typecheck exit 0, privacy:scan passed. --- .../content/docs/guides/codex-app-models.md | 10 ++++++ src/codex/catalog/native-models.ts | 25 ++++++++++++++ src/codex/subagent-model-fallback.ts | 17 +++++----- tests/codex-model-entitlements.test.ts | 7 +++- tests/codex-refresh.test.ts | 9 ++--- tests/model-visibility-management-api.test.ts | 7 ++-- tests/responses-pool-401-refresh.test.ts | 4 +-- ...subagent-fallback-handle-responses.test.ts | 6 +++- tests/subagent-model-fallback.test.ts | 33 +++++++++++++++++++ 9 files changed, 100 insertions(+), 18 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 7115d86af3..4f3a2218f5 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -32,6 +32,16 @@ the stored main credential when an OpenCodex admission bearer is substituted). A Pool routing excludes unentitled accounts. If no roster can be confirmed, the gated row fails closed instead of spending a prompt on an upstream 400. +`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` and `gpt-6-astra` are deliberately **not** gated that +way: they are listed on every install, whatever the entitlement roster says. opencodex asks upstream +under a client version new enough to return them, but it cannot make an answer appear — an +unconfirmed account, a timed-out lookup or a shard that has not caught up would otherwise make the +model disappear from the picker with no explanation. Listing them means the request is sent and you +see the real upstream status instead. An account that does not have one of these models will get an +upstream refusal at request time rather than an absent row, and in a multi-account Pool the request +is no longer steered to the account that owns the model first. `disabledModels` is the lever for +hiding any of them. + A separate, explicit `customModels` entry can expose the same wire id as `openai/gpt-daybreak-blue-latest` through the canonical Codex-login forward provider: diff --git a/src/codex/catalog/native-models.ts b/src/codex/catalog/native-models.ts index 75c7bfe2a8..f0df8d49a6 100644 --- a/src/codex/catalog/native-models.ts +++ b/src/codex/catalog/native-models.ts @@ -165,3 +165,28 @@ export const NATIVE_OPENAI_MODELS = [ ]; export const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS); + +/** + * Natives that retain the physical main account as a read-free sentinel during a native-main + * drain, instead of reading as unavailable and letting the subagent fallback chain advance. + * + * This used to be spelled `ACCOUNT_GATED_NATIVE_OPENAI_MODELS`, which was never what it meant: + * the sentinel protects the atomic main claim so a routed fallback cannot bypass it, and that + * has nothing to do with entitlement. The two sets were identical in practice, so the accident + * went unnoticed until the flagships were ungated (2026-09-04) and the predicate would have + * flipped false — letting a drain silently rewrite the operator's configured subagent model. + * + * It is an explicit list rather than `SUPPORTED_NATIVE_OPENAI_SLUGS`, which would have widened + * the sentinel to `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini` and `gpt-5.3-codex-spark` as well. Those + * models were never covered, and widening would turn "fell back and answered" into a + * maintenance error for the most commonly configured fallback slug in the repo. Membership is + * the set the drain behaviour was actually reasoned about: the account-gated natives plus the + * flagships that just left that set. + */ +export const NATIVE_MAIN_DRAIN_SENTINEL_MODELS: ReadonlySet = new Set([ + ...ACCOUNT_GATED_NATIVE_OPENAI_MODELS, + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + NATIVE_GPT6_ASTRA_MODEL, +]); diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index e78188dacb..6ce9e6914b 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -38,7 +38,7 @@ import { import { routeModel, type RouteResult } from "../router"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; import { codexAccountNamespaceForModel } from "./account-namespace-match"; -import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, SUPPORTED_NATIVE_OPENAI_SLUGS } from "./catalog/native-models"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_MAIN_DRAIN_SENTINEL_MODELS } from "./catalog/native-models"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { getUpstreamHostHealth, @@ -329,15 +329,16 @@ export function isSubagentModelUnavailable( // gated model, retain main only as a read-free sentinel: final auth owns the atomic // claim and returns maintenance instead of letting a routed fallback bypass it. // - // The predicate is the SUPPORTED NATIVE set, not the account-gated one. The sentinel exists - // to protect the atomic main claim during a drain, which has nothing to do with entitlement; - // it read the gated set only because every native it applied to happened to be gated. When - // the flagships were ungated (2026-09-04) that accident would have flipped this false and let - // a drain silently rewrite the operator's configured subagent model to the next chain entry - // instead of reporting maintenance -- a different model answering than was chosen. + // The predicate is its OWN set, not the account-gated one. The sentinel protects the atomic + // main claim during a drain, which has nothing to do with entitlement; it read the gated set + // only because the two happened to hold the same slugs. Ungating the flagships (2026-09-04) + // would have flipped this false and let a drain silently rewrite the operator's configured + // subagent model instead of reporting maintenance -- a different model answering than was + // chosen. The set is explicit rather than every supported native, so gpt-5.5 and friends keep + // their existing fall-back-and-answer behaviour. const preserveDrainingMainCandidate = route.codexAccountId === undefined && candidateAccountUsabilityOptions?.nativeMainSelectionOnly === true - && SUPPORTED_NATIVE_OPENAI_SLUGS.has(route.modelId); + && NATIVE_MAIN_DRAIN_SENTINEL_MODELS.has(route.modelId); if (!preserveDrainingMainCandidate) return true; const drainingMainUsabilityOptions: CodexAccountUsabilityOptions = { ...candidateAccountUsabilityOptions, diff --git a/tests/codex-model-entitlements.test.ts b/tests/codex-model-entitlements.test.ts index 43c84e3173..1ecd6820b9 100644 --- a/tests/codex-model-entitlements.test.ts +++ b/tests/codex-model-entitlements.test.ts @@ -344,7 +344,8 @@ describe("tri-state entitlement authority", () => { // the flagship ungating (2026-09-04). The mechanism under test never changed; only its subject // did. Deleting these because sol left the gated set would have removed the only coverage of // the fail-closed path while that path still governs a shipped model. Daybreak has no recorded - // minimum, so a version-scoped case uses SOL_MINIMUM_SLUG, which stays in the minimums map. + // minimum, so a version-scoped case still reads SOL, which stays in the minimums map even + // though it is no longer gated. test("an omitted gated slug below its minimum is unknown and uses the failure TTL", async () => { let fetches = 0; const backend = (async () => { @@ -394,6 +395,10 @@ describe("tri-state entitlement authority", () => { }); expect(projectedEntitlementState(snapshot, "main", SOL)).toBe("denied"); + // SOL's raw tri-state is unchanged mechanics -- codexModelEntitlementStateForRoster never + // consulted the gated set. What ungating actually changed is that it no longer reaches the + // gated projections at all, so that is asserted rather than restated. + expect(entitledCodexAccountIdsForModel(snapshot, SOL)).toBeUndefined(); expect(projectedEntitlementState(snapshot, "main", DAYBREAK)).toBe("denied"); expect(entitledCodexAccountIdsForModel(snapshot, DAYBREAK)?.size).toBe(0); expect(availableAccountGatedNativeModels(snapshot).has(DAYBREAK)).toBe(false); diff --git a/tests/codex-refresh.test.ts b/tests/codex-refresh.test.ts index 8c589de46e..4000eb9e7c 100644 --- a/tests/codex-refresh.test.ts +++ b/tests/codex-refresh.test.ts @@ -160,10 +160,11 @@ describe("Codex catalog refresh", () => { expect(result.path).toBe(join(realpathSync.native(home.codexHome), "nested", "catalog.json")); expect(result.catalogWritten).toBe(true); expect(after).not.toBe(before); - // The fixture seeds gated Sol rows, but this isolated home has no authenticated - // roster, so sync drops them and the first surviving row is gpt-5.5. - expect(rewritten.models[0].slug).toBe("gpt-5.5"); - expect(rewritten.models[0].display_name).toBe("gpt-5.5"); + // The fixture seeds Sol rows and this isolated home has no authenticated roster. Sol is + // no longer account-gated (2026-09-04), so sync keeps it rather than dropping it, and it + // leads the rewritten catalog on priority. + expect(rewritten.models[0].slug).toBe("gpt-5.6-sol"); + expect(rewritten.models[0].display_name).toBe("GPT-5.6-Sol"); expect(rewritten.models[0].context_window).toBeGreaterThan(0); } finally { home.restore(); diff --git a/tests/model-visibility-management-api.test.ts b/tests/model-visibility-management-api.test.ts index af9d9b84d7..1737577935 100644 --- a/tests/model-visibility-management-api.test.ts +++ b/tests/model-visibility-management-api.test.ts @@ -335,8 +335,11 @@ describe("atomic model visibility management", () => { // rows and routing stays gated, so this removes a misleading error rather than granting // access. saveConfig({ ...loadConfig(), disabledModels: ["gpt-5.6-sol", "other/keep"] }); - // Precondition: the model is genuinely absent from the rendered rows here. - expect(nativeModelRows(loadConfig()).some(row => row.slug === "gpt-5.6-sol")).toBe(false); + // Precondition: the model is genuinely absent from the rendered rows here -- asserted on + // DAYBREAK, which is still account-gated. Sol stopped being gated on 2026-09-04, so it now + // renders even without a roster and could no longer stand in for a suppressed model. + expect(nativeModelRows(loadConfig()).some(row => row.slug === "gpt-daybreak-blue-latest")) + .toBe(false); const response = await put({ scope: "models", diff --git a/tests/responses-pool-401-refresh.test.ts b/tests/responses-pool-401-refresh.test.ts index 9b11ffb672..194fb3b2d5 100644 --- a/tests/responses-pool-401-refresh.test.ts +++ b/tests/responses-pool-401-refresh.test.ts @@ -708,7 +708,7 @@ describe("ordinary pool 401 refresh and replay (#2887)", () => { chatgptAccountId: "acc-other", }), }); - const gatedModel = "gpt-5.6-sol"; + const gatedModel = "gpt-daybreak-blue-latest"; const harness = installHarness({ responseForSend: authorization => { if (authorization === "Bearer rejected-access") { @@ -850,7 +850,7 @@ describe("ordinary pool 401 refresh and replay (#2887)", () => { // from the opaque-blob case. When the refreshed roster still grants the model, // retryCodexPoolOnAlternateAccount sets retryAuthCtx = firstAuthCtx and sends again to the // account already paying — no other account is charged, so it is outside the budget. - const gatedModel = "gpt-5.6-sol"; + const gatedModel = "gpt-daybreak-blue-latest"; const harness = installHarness({ responseForSend: (authorization, sendNumber) => { if (authorization === "Bearer rejected-access") { diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index 7af3d43718..25401a42f1 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -935,7 +935,11 @@ describe("native fallback account preview", () => { await expect(postSpawn( cfg, - { model: "team/gpt-5.6-sol", input: readableAgentInput(), stream: false }, + // Account-qualified DAYBREAK: this case is about the entitlement resolution that runs + // twice (preview then final auth), and only an account-gated model resolves entitlements + // at all. The flagships stopped being gated on 2026-09-04, so team/gpt-5.6-sol would now + // skip both calls and the admission-release accounting under test would never run. + { model: "team/gpt-daybreak-blue-latest", input: readableAgentInput(), stream: false }, { turnAdmissionLease, resolveCodexModelEntitlements: async () => { diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index 04d1b14aea..6e1b2f1271 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -20,6 +20,8 @@ import { subagentFallbackGuidanceText, } from "../src/codex/subagent-model-fallback"; import { saveCodexAccountCredential } from "../src/codex/account-store"; +import { NATIVE_MAIN_DRAIN_SENTINEL_MODELS } from "../src/codex/catalog/native-models"; +import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../src/codex/account-runtime-state"; import { clearAccountQuota, setAccountQuotaFromParsed, updateAccountQuota } from "../src/codex/quota"; import { @@ -253,6 +255,37 @@ describe("subagent model fallback chain", () => { )).toBe(false); }); +test("the native-main drain sentinel covers the flagships without widening to gpt-5.5", () => { + // During a native-main drain with no usable non-main candidate, a sentinel model retains + // main as a read-free candidate so final auth returns maintenance and owns the atomic claim. + // Anything outside the sentinel set reads as unavailable and the chain advances. + // + // The predicate used to be spelled ACCOUNT_GATED_NATIVE_OPENAI_MODELS, which was an accident + // of the two sets holding the same slugs. Ungating the flagships (2026-09-04) would have + // flipped it false and let a drain silently rewrite the operator's configured subagent model. + // Widening it to every supported native would have been the opposite error: gpt-5.5 and the + // other non-flagship natives would newly raise a maintenance error where they used to fall + // back and answer. This pins both edges. + const now = 1_800_000_000_000; + const config = cfg({ autoSwitchThreshold: 0 }); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 10, undefined, 20); + const draining = { nativeMainSelectionOnly: true } as const; + const noPoolCandidate = () => undefined; + + for (const slug of ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra", "gpt-daybreak-blue-latest"]) { + expect(NATIVE_MAIN_DRAIN_SENTINEL_MODELS.has(slug)).toBe(true); + expect(isSubagentModelUnavailable(slug, config, null, now, draining, noPoolCandidate)) + .toBe(false); + } + + // Outside the set, and deliberately so: these keep advancing the chain as they always did. + for (const slug of ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"]) { + expect(NATIVE_MAIN_DRAIN_SENTINEL_MODELS.has(slug)).toBe(false); + expect(isSubagentModelUnavailable(slug, config, null, now, draining, noPoolCandidate)) + .toBe(true); + } + }); + test("unqualified gated candidates pass their entitlement set into Pool preview", () => { const now = 1_800_000_000_000; const config = cfg({