-
Notifications
You must be signed in to change notification settings - Fork 1k
feat(codex): list the flagship natives regardless of the entitlement roster #3460
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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-<account>/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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
|
Comment on lines
+20
to
+23
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Name the actual drain sentinel set. The implementation uses 🤖 Prompt for AI Agents |
||
|
|
||
| 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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Comment on lines
+35
to
+36
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Qualify the unconditional visibility claim.
As per path instructions, public documentation must stay synchronized with actual CLI/API behavior. 🤖 Prompt for AI AgentsSource: Path instructions |
||
| 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: | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Correct the drain-sentinel decision.
Lines 42-45 say that preservation uses membership in the native OpenAI set. The final contract uses
NATIVE_MAIN_DRAIN_SENTINEL_MODELS, which preserves only the intended flagships and gated models. Keeping all native models here would incorrectly retain non-sentinel candidates during a drain and prevent their fallback chain from advancing. Update this decision and its regression description to name the explicit sentinel set.🤖 Prompt for AI Agents