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/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 26341516f0..f0df8d49a6 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, ]); @@ -144,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 27eea1e53d..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 } 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, @@ -328,9 +328,17 @@ 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 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 - && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId); + && NATIVE_MAIN_DRAIN_SENTINEL_MODELS.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..1ecd6820b9 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,12 @@ 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 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 () => { @@ -343,18 +353,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 +378,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 +395,26 @@ 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); + // 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); }); 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 +441,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 +1048,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 +1073,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 +1100,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 +1113,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 +1144,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 +1155,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 +1256,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 +1385,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 +1412,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 +1442,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 +1475,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 +1498,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 +1524,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 +1549,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 +1582,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 +1653,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 +1675,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 +1697,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/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/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 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({