From 71f25bd1af0796a99e6007918b38e5099fe9aac5 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 10:56:54 -0700 Subject: [PATCH 1/4] fix: correct the ChatGPT-subscription model allowlist against the live backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth model filter was built from the models.dev catalog rather than from what the Codex endpoint actually serves, so it was wrong in both directions. Verified every gpt-5.x id against `POST /backend-api/codex/responses` on a ChatGPT Pro credential and rebuilt the allowlist from the results. Removed (offered in the picker, rejected by the backend with HTTP 400): - `gpt-5.2` and `gpt-5.6`, both explicitly allowlisted - `gpt-5.3-codex`, admitted by the `modelId.includes("codex")` auto-allow Added (served by the backend, previously hidden from the picker): - `gpt-5.6-sol`, `gpt-5.6-luna`, `gpt-5.6-terra` — the current flagship subscription models, excluded on an untested assumption that the sol/luna/terra variants were API-tier-only Dropped the `includes("codex")` substring auto-allow. It cannot express the real policy: the tier accepts `gpt-5.3-codex-spark` but rejects `gpt-5.3-codex`, and accepts `gpt-5.6-sol` but rejects `gpt-5.6`. Membership is now exact-match only. Transport was never involved. Plain HTTP with our own `originator: altimate` identity reaches every working model; no websocket, `openai-beta`, or `x-codex-beta-features` header changes any outcome. Rewrote `codex-allowlist.test.ts` around the verified truth table so both failure directions are guarded, plus a regression barrier against reintroducing a substring rule. Closes #1178 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ --- packages/opencode/src/plugin/codex.ts | 55 +++++-- .../test/plugin/codex-allowlist.test.ts | 155 +++++++++--------- 2 files changed, 111 insertions(+), 99 deletions(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index ac89dab06a..75463fe702 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -16,33 +16,52 @@ const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 -/** Non-codex ChatGPT-subscription (OAuth) allowlist. Any modelId - * containing "codex" is auto-allowed by ``shouldAllowOAuthModel`` below, - * so this set only enumerates the plain non-codex main/mini variants - * OpenAI exposes on Codex-tier accounts. Bump whenever a new gpt-5.N - * is generally available on the subscription. Exported for unit-test - * coverage — see test/plugin/codex-allowlist.test.ts. */ +/** Exact set of model ids the ChatGPT-subscription (Codex) tier accepts. + * + * Every entry was verified against the live backend + * (POST https://chatgpt.com/backend-api/codex/responses) on a ChatGPT Pro + * credential: entries here returned HTTP 200, and every other gpt-5.x id in + * the models.dev catalog returned + * ``400 {"detail":"The '' model is not supported when using Codex with a + * ChatGPT account."}``. + * + * Confirmed rejected, so deliberately absent: gpt-5, gpt-5.1, gpt-5.2, + * gpt-5.2-pro, gpt-5.3-chat-latest, gpt-5.3-codex, gpt-5.4-nano, gpt-5.4-pro, + * gpt-5.5-pro, gpt-5.6. + * + * There is no derivable rule here — the tier accepts ``gpt-5.3-codex-spark`` + * but rejects ``gpt-5.3-codex``, and accepts the gpt-5.6 sol/luna/terra + * variants but rejects plain ``gpt-5.6``. So this is an exact-match list by + * necessity, not by preference. Only add an id after confirming a 200 from the + * endpoint above on a subscription credential; guessing puts a model in the + * picker that then fails at request time. + * + * Exported for unit-test coverage — see test/plugin/codex-allowlist.test.ts. */ export const OAUTH_ALLOWED_MODELS = new Set([ - "gpt-5.2", + "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini", "gpt-5.5", - "gpt-5.6", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", ]) /** OAuth (ChatGPT-subscription) model-filter policy for the ACTIVE plugin - * (this file — wired via plugin/index.ts). A model is kept if either - * (a) its id contains ``"codex"`` (all codex variants ship on the - * subscription), or (b) its id is an exact member of - * ``OAUTH_ALLOWED_MODELS`` (the curated non-codex releases). + * (this file — wired via plugin/index.ts). Exact membership in + * ``OAUTH_ALLOWED_MODELS`` — no substring heuristics. + * + * This previously auto-allowed any id containing ``"codex"``, which admitted + * ``gpt-5.3-codex``. The backend rejects that id, so it reached the picker and + * then failed at request time with an HTTP 400. Substring matching cannot + * express the real policy (``gpt-5.3-codex-spark`` is accepted while + * ``gpt-5.3-codex`` is not), so the heuristic is gone. * - * The sibling file plugin/openai/codex.ts (an in-progress refactor, - * currently NOT wired) has its own separate filter with a - * ``parseFloat(match[1]) > 5.4`` fallback. Adopting this helper is - * followup work on that refactor — do NOT assume the two files share - * this policy today. */ + * The sibling file plugin/openai/codex.ts (an in-progress refactor, currently + * NOT wired) has its own separate filter with a ``parseFloat(match[1]) > 5.4`` + * fallback. Adopting this helper is followup work on that refactor — do NOT + * assume the two files share this policy today. */ export function shouldAllowOAuthModel(modelId: string): boolean { - if (modelId.includes("codex")) return true return OAUTH_ALLOWED_MODELS.has(modelId) } diff --git a/packages/opencode/test/plugin/codex-allowlist.test.ts b/packages/opencode/test/plugin/codex-allowlist.test.ts index 52ef8c7f72..87f612d70f 100644 --- a/packages/opencode/test/plugin/codex-allowlist.test.ts +++ b/packages/opencode/test/plugin/codex-allowlist.test.ts @@ -2,9 +2,24 @@ // in packages/opencode/src/plugin/codex.ts — the ACTIVE plugin wired // via plugin/index.ts. // -// Filed as issue #1132: GPT 5.6 was released but the allowlist stopped -// at 5.4, so users on ChatGPT Pro/Plus (Codex tier) couldn't pick it in -// the model picker even though the underlying models.dev catalog had it. +// The allowlist is a verified truth table, not a guess. Every id asserted +// below was probed against the live backend +// (POST https://chatgpt.com/backend-api/codex/responses) on a ChatGPT Pro +// credential, using our own `originator: altimate` client identity over plain +// HTTP. Accepted ids returned HTTP 200; rejected ids returned +// 400 {"detail":"The '' model is not supported when using Codex with a +// ChatGPT account."} +// +// Two directions of breakage this file guards: +// * false positives — an id offered in the picker that 400s at request time +// (previously gpt-5.2, gpt-5.6, gpt-5.3-codex) +// * false negatives — a working subscription model hidden from the picker +// (previously the gpt-5.6 sol/luna/terra variants, i.e. the current +// flagship models) +// +// Supersedes the narrower framing of issue #1132, which added plain gpt-5.6 to +// the allowlist on catalog presence alone. The backend rejects that id; only +// its sol/luna/terra variants are actually served on the subscription. // // Sibling file plugin/openai/codex.ts is an in-progress refactor of the // same plugin, currently NOT wired via plugin/index.ts, and NOT covered @@ -17,55 +32,54 @@ import { describe, expect, test } from "bun:test" import { OAUTH_ALLOWED_MODELS, shouldAllowOAuthModel } from "../../src/plugin/codex" -describe("OAUTH_ALLOWED_MODELS — subscription model picker regression barrier", () => { - test("issue #1132: gpt-5.6 is present", () => { - // If this fails again, someone dropped gpt-5.6 from the non-codex - // allowlist without moving forward to a newer generation — rejecting - // a shipped OpenAI model users have subscription access to. - expect(OAUTH_ALLOWED_MODELS.has("gpt-5.6")).toBe(true) - }) +/** Verified HTTP 200 on a ChatGPT Pro subscription credential. */ +const VERIFIED_ACCEPTED = [ + "gpt-5.3-codex-spark", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.5", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", +] - test("gpt-5.5 is present (added alongside 5.6 for parity)", () => { - expect(OAUTH_ALLOWED_MODELS.has("gpt-5.5")).toBe(true) - }) +/** Verified HTTP 400 "not supported when using Codex with a ChatGPT account". */ +const VERIFIED_REJECTED = [ + "gpt-5", + "gpt-5.1", + "gpt-5.2", + "gpt-5.2-pro", + "gpt-5.3-chat-latest", + "gpt-5.3-codex", + "gpt-5.4-nano", + "gpt-5.4-pro", + "gpt-5.5-pro", + "gpt-5.6", +] - test("prior non-codex generations stay allowlisted (no accidental removal)", () => { - for (const id of ["gpt-5.2", "gpt-5.4", "gpt-5.4-mini"]) { +describe("OAUTH_ALLOWED_MODELS — verified subscription truth table", () => { + test("every id verified as accepted is allowlisted", () => { + // False negatives hide working models from the picker. The sol/luna/terra + // variants are the current flagship subscription models — a previous + // revision excluded them on the untested assumption that they were + // API-tier-only, which cost users access to models they already pay for. + for (const id of VERIFIED_ACCEPTED) { expect(OAUTH_ALLOWED_MODELS.has(id)).toBe(true) } }) - test("codex-tagged variants are NOT in the non-codex set (they're auto-allowed instead)", () => { - // The non-codex set is deliberately minimal — every codex-tagged id - // is auto-allowed by shouldAllowOAuthModel's `includes("codex")` check - // below, so listing them here would be redundant + a maintenance trap. - for (const id of ["gpt-5.1-codex", "gpt-5.2-codex", "gpt-5.3-codex"]) { - expect(OAUTH_ALLOWED_MODELS.has(id)).toBe(false) - } - }) - - test("allowlist does NOT include API-tier-only variants (defensive)", () => { - // Pro / luna / sol / terra variants ship on models.dev but aren't - // confirmed available on the ChatGPT-subscription (Codex) tier — - // showing them in the picker would surface a request-time failure. - // If OpenAI extends subscription coverage to them, add them here - // deliberately (with a link to the announcement). - for (const id of [ - "gpt-5.4-pro", - "gpt-5.5-pro", - "gpt-5.6-luna", - "gpt-5.6-sol", - "gpt-5.6-terra", - ]) { + test("every id verified as rejected stays out of the allowlist", () => { + // False positives are worse than a missing model: the id shows up in the + // picker, the user selects it, and the request dies with an opaque 400. + for (const id of VERIFIED_REJECTED) { expect(OAUTH_ALLOWED_MODELS.has(id)).toBe(false) } }) - test("allowlist size never regresses below current baseline", () => { - // Trip-wire: if someone truncates the allowlist by mistake (or in - // a bad rebase), the count drops and this test catches it before - // shipping. Bump when a real new addition lands. - expect(OAUTH_ALLOWED_MODELS.size).toBeGreaterThanOrEqual(5) + test("the allowlist contains nothing beyond the verified-accepted set", () => { + // Trip-wire against speculative additions. To add an id here, probe it + // against the live endpoint first and land it in VERIFIED_ACCEPTED too. + expect([...OAUTH_ALLOWED_MODELS].sort()).toEqual([...VERIFIED_ACCEPTED].sort()) }) }) @@ -73,56 +87,35 @@ describe("shouldAllowOAuthModel — behavior of the filter itself", () => { // Behavior-level coverage: even if a refactor stops passing // OAUTH_ALLOWED_MODELS through, the filter function is what the // loader actually calls, so this catches breakage the constant-only - // tests above would miss. (cubic P3 catch.) + // tests above would miss. - test("allowlist members pass (spot-check each generation)", () => { - for (const id of ["gpt-5.2", "gpt-5.4", "gpt-5.4-mini", "gpt-5.5", "gpt-5.6"]) { + test("verified-accepted ids pass", () => { + for (const id of VERIFIED_ACCEPTED) { expect(shouldAllowOAuthModel(id)).toBe(true) } }) - test("codex-tagged ids pass regardless of exact allowlist membership", () => { - // Any id containing "codex" auto-passes — covers gpt-5.1-codex, - // gpt-5.3-codex-spark, plus any future codex variant OpenAI ships. - for (const id of [ - "gpt-5.1-codex", - "gpt-5.1-codex-max", - "gpt-5.1-codex-mini", - "gpt-5.2-codex", - "gpt-5.3-codex", - "gpt-5.3-codex-spark", - "gpt-5.3-codex-xhigh", - "codex-hypothetical-future-name", - ]) { - expect(shouldAllowOAuthModel(id)).toBe(true) + test("verified-rejected ids are filtered out", () => { + for (const id of VERIFIED_REJECTED) { + expect(shouldAllowOAuthModel(id)).toBe(false) } }) - test("API-tier-only variants are rejected (the whole point of the filter)", () => { - // These are the models the previous parseFloat > 5.4 fallback in - // plugin/openai/codex.ts was incorrectly admitting; the shared - // filter must reject them so the picker stays honest about what - // the subscription actually accepts. - for (const id of [ - "gpt-5.4-pro", - "gpt-5.4-nano", - "gpt-5.5-pro", - "gpt-5.6-luna", - "gpt-5.6-sol", - "gpt-5.6-terra", - ]) { - expect(shouldAllowOAuthModel(id)).toBe(false) - } + test("a 'codex' substring does not grant access on its own", () => { + // Regression barrier for the removed `modelId.includes("codex")` + // auto-allow. The backend accepts gpt-5.3-codex-spark but rejects + // gpt-5.3-codex, so no substring rule can express the real policy — + // reintroducing one puts broken ids back in the picker. + expect(shouldAllowOAuthModel("gpt-5.3-codex")).toBe(false) + expect(shouldAllowOAuthModel("gpt-5.1-codex")).toBe(false) + expect(shouldAllowOAuthModel("gpt-5.1-codex-max")).toBe(false) + expect(shouldAllowOAuthModel("codex-hypothetical-future-name")).toBe(false) + // ...while the one codex id that IS accepted still passes, by exact match. + expect(shouldAllowOAuthModel("gpt-5.3-codex-spark")).toBe(true) }) test("completely unrelated ids are rejected", () => { - for (const id of [ - "claude-3.5-sonnet", - "gemini-2.5-pro", - "gpt-4o", - "gpt-4-turbo", - "", - ]) { + for (const id of ["claude-3.5-sonnet", "gemini-2.5-pro", "gpt-4o", "gpt-4-turbo", ""]) { expect(shouldAllowOAuthModel(id)).toBe(false) } }) From a279b9d1c439167f31f3d4d4fd5886529d87d287 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 11:10:21 -0700 Subject: [PATCH 2/4] docs: record why the Codex model allowlist is static, not discovered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The obvious objection to the allowlist this PR corrects is that any static list drifts. There is an authoritative per-account endpoint that would let us derive it instead — `GET /backend-api/codex/models?client_version=` — so I probed it rather than leave the objection unanswered. It works, and it accepts our own identity: `originator: altimate` plus our own User-Agent, no impersonation. Its `visibility: "list"` slugs are exactly the seven ids in `OAUTH_ALLOWED_MODELS`, which independently confirms the table built by request-level probing. It is still unusable as a runtime source of truth. `client_version` is mandatory and is gated against each model's `minimal_client_version`, today 0.98.0 through 0.144.0 — Codex CLI release numbers, a line we are not on. Our versions sit below all of them and the gate fails silently: our published `0.9.7` returns `HTTP 200 {"models":[]}`, and a dev build's `local` returns `400 {"detail":"Invalid client_version format"}`. Deriving the list would mean asserting a Codex CLI version we are not, so the list stays static. Comment-only; no behavior change. Records the endpoint, the corroboration, the blocker, and the drop-in shape should a legitimate client_version ever exist, so the next person does not repeat the investigation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ --- packages/opencode/src/plugin/codex.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 75463fe702..673df92f0c 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -36,6 +36,27 @@ const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 * endpoint above on a subscription credential; guessing puts a model in the * picker that then fails at request time. * + * WHY THIS IS STATIC AND NOT DISCOVERED. There is an authoritative per-account + * endpoint — ``GET https://chatgpt.com/backend-api/codex/models?client_version=`` + * — and it accepts our own identity (``originator: altimate`` plus our + * User-Agent; no impersonation needed) and returns exactly the set above, each + * entry carrying ``slug``, ``visibility`` and ``minimal_client_version``. Its + * ``visibility: "list"`` slugs corroborate this list precisely, which is why + * that list is reproduced here rather than derived at runtime: + * + * ``client_version`` is mandatory (omitted or unparseable ⇒ HTTP 400) and is + * gated against each model's ``minimal_client_version``, which today ranges + * from 0.98.0 to 0.144.0. Those are Codex CLI release numbers, a numbering line + * we are not on. Our own versions sit below all of them, and the gate fails + * SILENTLY — ``client_version=0.9.7`` (our published version) returns + * ``HTTP 200 {"models":[]}``, and a dev build's ``local`` returns + * ``400 {"detail":"Invalid client_version format"}``. So discovery yields + * nothing usable unless we assert a Codex CLI version we are not, which would + * be impersonating the first-party client. We do not do that, so the list stays + * static. If we ever have a legitimate client_version to send, the mechanism is + * a small drop-in: fetch, keep ``visibility === "list"``, and fall back to this + * set whenever the response is empty or the call fails. + * * Exported for unit-test coverage — see test/plugin/codex-allowlist.test.ts. */ export const OAUTH_ALLOWED_MODELS = new Set([ "gpt-5.3-codex-spark", From bbc2a2b5c2623d39e1f3e45e65e663f20a1fb4f6 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 16:30:38 -0700 Subject: [PATCH 3/4] fix: match the OAuth model filter on api.id, not the config map key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review on #1179. `shouldAllowOAuthModel` was called with the model map KEY. For every models.dev catalog entry the key equals `api.id`, so this was invisible — but a user can alias a model in config: provider.openai.models.fast-spark.id = "gpt-5.3-codex-spark" Config models are folded into the provider database ("extend database from config" in `Provider.state`) BEFORE auth loaders run, so that entry reaches this loader keyed `fast-spark` with the real id on `api.id`. Matching the key deleted it, hiding a model the backend actually serves. The sibling `plugin/openai/codex.ts` filter already matches on `model.api.id`. Extracted `disallowedOAuthModelKeys`, which resolves `api.id ?? key` — the fallback matters because the database only backfills `model.api.id ?? model.id ?? modelID` after this hook runs, so the field is not guaranteed populated despite the type. Behaviour is unchanged for every catalog model (key === api.id verified against models.dev/api.json); only aliases move, and an alias of a rejected id is still deleted. Comment corrections, no behaviour change: - The allowlist claimed every other gpt-5.x catalog id had been probed. It had not: gpt-5-mini, gpt-5-nano, gpt-5-pro and gpt-5.2-chat-latest are in the catalog and in neither list. Recorded as unprobed and fail-closed rather than left as an overclaim. - Recorded that the truth table is one Pro account's entitlements and has not been checked against a Plus credential. - Recorded that the codex-tagged ids reviewers keep asking about (gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, gpt-5.2-codex, gpt-5.3-codex-xhigh) are absent from the models.dev catalog entirely, so no filter here can hide them. - Disambiguated the two version numbers. What reaches the wire is `Installation.VERSION` (the npm-published 0.9.7, or `local` for a dev build), not the 1.17.9 in packages/opencode/package.json, which is inherited from upstream and never sent. Also stated that the blocker is not arithmetic: bumping our number would not lift it, because the field asserts which Codex CLI release we are. Six new tests cover the alias fix in both directions, the missing-api.id fallback, and that the catalog set resolves identically either way. The allowlist membership is byte-for-byte unchanged, as is the `originator: "altimate"` identity. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ --- packages/opencode/src/plugin/codex.ts | 99 ++++++++++++++----- .../test/plugin/codex-allowlist.test.ts | 85 ++++++++++++++-- 2 files changed, 156 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 673df92f0c..49518ece22 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -20,14 +20,35 @@ const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 * * Every entry was verified against the live backend * (POST https://chatgpt.com/backend-api/codex/responses) on a ChatGPT Pro - * credential: entries here returned HTTP 200, and every other gpt-5.x id in - * the models.dev catalog returned + * credential: entries here returned HTTP 200. + * + * CONFIRMED REJECTED, so deliberately absent — each returned * ``400 {"detail":"The '' model is not supported when using Codex with a - * ChatGPT account."}``. + * ChatGPT account."}``: gpt-5, gpt-5.1, gpt-5.2, gpt-5.2-pro, + * gpt-5.3-chat-latest, gpt-5.3-codex, gpt-5.4-nano, gpt-5.4-pro, gpt-5.5-pro, + * gpt-5.6. + * + * NOT PROBED, and excluded by default because this list is fail-closed: + * gpt-5-mini, gpt-5-nano, gpt-5-pro, gpt-5.2-chat-latest. They are in the + * models.dev catalog but are not plausible Codex-tier models, and the + * discovery endpoint below does not list them either. An earlier revision of + * this comment claimed every other catalog id had been probed; that overstated + * the evidence, and these four are the exception. + * + * TIER SCOPE. All of the above is what ONE ChatGPT Pro account was served. + * It has not been verified against a Plus credential, so it is possible Plus + * is entitled to a narrower (or wider) set. If a Plus subscriber reports a + * model missing from the picker that the official client offers them, that is + * the likely cause and the fix is a per-account list, not another id here. * - * Confirmed rejected, so deliberately absent: gpt-5, gpt-5.1, gpt-5.2, - * gpt-5.2-pro, gpt-5.3-chat-latest, gpt-5.3-codex, gpt-5.4-nano, gpt-5.4-pro, - * gpt-5.5-pro, gpt-5.6. + * NOT A SILENT DROP. Reviewers have twice asked why codex-tagged ids such as + * gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, gpt-5.2-codex and + * gpt-5.3-codex-xhigh appear in neither list. They are not in the models.dev + * catalog at all (checked against https://models.dev/api.json — the only codex + * ids it carries are gpt-5.3-codex and gpt-5.3-codex-spark), so no filter here + * can hide them: they never reach this loader. The removed + * ``includes("codex")`` rule would have admitted them had they existed, which + * is why the old tests named them, but nothing in the picker changed for them. * * There is no derivable rule here — the tier accepts ``gpt-5.3-codex-spark`` * but rejects ``gpt-5.3-codex``, and accepts the gpt-5.6 sol/luna/terra @@ -45,17 +66,28 @@ const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 * that list is reproduced here rather than derived at runtime: * * ``client_version`` is mandatory (omitted or unparseable ⇒ HTTP 400) and is - * gated against each model's ``minimal_client_version``, which today ranges - * from 0.98.0 to 0.144.0. Those are Codex CLI release numbers, a numbering line - * we are not on. Our own versions sit below all of them, and the gate fails - * SILENTLY — ``client_version=0.9.7`` (our published version) returns - * ``HTTP 200 {"models":[]}``, and a dev build's ``local`` returns - * ``400 {"detail":"Invalid client_version format"}``. So discovery yields - * nothing usable unless we assert a Codex CLI version we are not, which would - * be impersonating the first-party client. We do not do that, so the list stays - * static. If we ever have a legitimate client_version to send, the mechanism is - * a small drop-in: fetch, keep ``visibility === "list"``, and fall back to this - * set whenever the response is empty or the call fails. + * gated against each model's ``minimal_client_version``, which at the time of + * probing ranged from 0.98.0 to 0.144.0. Those are Codex CLI release numbers — + * a numbering line we are not on, so comparing our number against theirs is + * meaningless regardless of which way it happens to sort. + * + * Two different version numbers exist in this repo and it matters which one + * reaches the wire. What we would send is ``Installation.VERSION``, i.e. the + * build-injected ``OPENCODE_VERSION`` — the NPM-published version, 0.9.7 at + * the time of probing, or the literal ``local`` for a dev build. It is NOT the + * 1.17.9 in packages/opencode/package.json, which is inherited from the + * upstream numbering line and never sent. Both observed values fail, and the + * failure is SILENT for one of them: ``client_version=0.9.7`` returns + * ``HTTP 200 {"models":[]}`` — no error to debug, just an empty picker — while + * a dev build's ``local`` returns + * ``400 {"detail":"Invalid client_version format"}``. + * + * The blocker is not arithmetic, so bumping our version would not lift it: the + * field means "which Codex CLI release am I", and answering it is impersonating + * the first-party client whatever number we put there. We do not do that, so + * the list stays static. If we ever have a legitimate client_version to send, + * the mechanism is a small drop-in: fetch, keep ``visibility === "list"``, and + * fall back to this set whenever the response is empty or the call fails. * * Exported for unit-test coverage — see test/plugin/codex-allowlist.test.ts. */ export const OAUTH_ALLOWED_MODELS = new Set([ @@ -86,6 +118,28 @@ export function shouldAllowOAuthModel(modelId: string): boolean { return OAUTH_ALLOWED_MODELS.has(modelId) } +/** Map keys to delete from an OAuth (subscription) provider's model record. + * + * Matches on the UPSTREAM api id, not the map key. The two are equal for every + * models.dev catalog entry, but a user can alias a model in config — + * ``provider.openai.models.fast-spark.id = "gpt-5.3-codex-spark"`` — which + * produces an entry keyed ``fast-spark`` whose ``api.id`` carries the real id. + * Config models are folded into the provider database (Provider.state, "extend + * database from config") BEFORE auth loaders run, so they do reach this filter; + * matching the key would delete a model the backend actually serves. + * + * Falls back to the key when ``api.id`` is absent: the database only backfills + * ``model.api.id ?? model.id ?? modelID`` after this hook has run, so the field + * is not guaranteed populated at this point even though the type says so. + * + * Returns keys rather than mutating, so the caller keeps the in-place delete on + * the shared database object and this stays unit-testable. */ +export function disallowedOAuthModelKeys(models: Record): string[] { + return Object.entries(models) + .filter(([key, model]) => !shouldAllowOAuthModel(model?.api?.id ?? key)) + .map(([key]) => key) +} + interface PkceCodes { verifier: string challenge: string @@ -469,9 +523,10 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { if (auth.type !== "oauth") return {} // Filter models to only those the ChatGPT-subscription (Codex) tier - // accepts. Delegates to ``shouldAllowOAuthModel`` (module-level, - // above). See OAUTH_ALLOWED_MODELS + shouldAllowOAuthModel for the - // criteria + how to add new gpt-5.N releases. + // accepts. Delegates to ``disallowedOAuthModelKeys`` (module-level, + // above), which matches on each model's upstream ``api.id`` so a + // config alias of a supported model survives. See OAUTH_ALLOWED_MODELS + // for the criteria + how to add new gpt-5.N releases. // // NOTE: this file is the ACTIVE plugin (wired via plugin/index.ts). // The sibling plugin/openai/codex.ts is an unwired in-progress @@ -479,8 +534,8 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { // fallback — this filter does NOT share a source of truth with it. // Adopting shouldAllowOAuthModel there is followup on that refactor. // (Closes #1132 — GPT 5.6 missing from picker.) - for (const modelId of Object.keys(provider.models)) { - if (!shouldAllowOAuthModel(modelId)) delete provider.models[modelId] + for (const modelId of disallowedOAuthModelKeys(provider.models)) { + delete provider.models[modelId] } // Zero out costs for Codex (included with ChatGPT subscription) diff --git a/packages/opencode/test/plugin/codex-allowlist.test.ts b/packages/opencode/test/plugin/codex-allowlist.test.ts index 87f612d70f..60fef2908d 100644 --- a/packages/opencode/test/plugin/codex-allowlist.test.ts +++ b/packages/opencode/test/plugin/codex-allowlist.test.ts @@ -2,14 +2,24 @@ // in packages/opencode/src/plugin/codex.ts — the ACTIVE plugin wired // via plugin/index.ts. // -// The allowlist is a verified truth table, not a guess. Every id asserted -// below was probed against the live backend -// (POST https://chatgpt.com/backend-api/codex/responses) on a ChatGPT Pro -// credential, using our own `originator: altimate` client identity over plain -// HTTP. Accepted ids returned HTTP 200; rejected ids returned +// The allowlist is a verified truth table, not a guess. Every id in +// VERIFIED_ACCEPTED and VERIFIED_REJECTED below was probed against the live +// backend (POST https://chatgpt.com/backend-api/codex/responses) on a ChatGPT +// Pro credential, using our own `originator: altimate` client identity over +// plain HTTP. Accepted ids returned HTTP 200; rejected ids returned // 400 {"detail":"The '' model is not supported when using Codex with a // ChatGPT account."} // +// Ids asserted OUTSIDE those two lists were NOT probed and are not claimed to +// have been. They fall in two groups, both deliberate: +// * ids absent from the models.dev catalog (gpt-5.1-codex, gpt-5.1-codex-max, +// codex-hypothetical-future-name, …). These cannot reach the loader at all, +// so probing them would be meaningless; they are here purely as shape +// assertions against a substring rule creeping back in. +// * obviously-unrelated ids (claude-*, gemini-*, gpt-4o), same reason. +// The probe scope, and the four catalog ids left unprobed, are recorded on +// OAUTH_ALLOWED_MODELS in src/plugin/codex.ts. +// // Two directions of breakage this file guards: // * false positives — an id offered in the picker that 400s at request time // (previously gpt-5.2, gpt-5.6, gpt-5.3-codex) @@ -30,7 +40,7 @@ // adopting ``shouldAllowOAuthModel`` here (and expanding this file's // coverage to the newly-active filter) is followup work. import { describe, expect, test } from "bun:test" -import { OAUTH_ALLOWED_MODELS, shouldAllowOAuthModel } from "../../src/plugin/codex" +import { OAUTH_ALLOWED_MODELS, disallowedOAuthModelKeys, shouldAllowOAuthModel } from "../../src/plugin/codex" /** Verified HTTP 200 on a ChatGPT Pro subscription credential. */ const VERIFIED_ACCEPTED = [ @@ -120,3 +130,66 @@ describe("shouldAllowOAuthModel — behavior of the filter itself", () => { } }) }) + +describe("disallowedOAuthModelKeys — what the loader actually deletes", () => { + const model = (apiId?: string) => (apiId === undefined ? {} : { api: { id: apiId } }) + + test("a config alias of a supported model is kept, keyed by its alias", () => { + // The bug this guards: the loader used to match the MAP KEY. Config models + // are folded into the provider database before auth loaders run, so + // `provider.openai.models.fast-spark.id = "gpt-5.3-codex-spark"` arrives + // keyed `fast-spark` with the real id on `api.id`. Matching the key deleted + // a model the backend serves. + const models = { + "fast-spark": model("gpt-5.3-codex-spark"), + "my-flagship": model("gpt-5.6-sol"), + } + expect(disallowedOAuthModelKeys(models)).toEqual([]) + }) + + test("an alias of an unsupported model is still deleted", () => { + // The alias must not become a way to smuggle a rejected id past the filter + // — it is the api.id that reaches the backend, so that is what is judged. + const models = { + "totally-fine-name": model("gpt-5.6"), + "gpt-5.6-sol": model("gpt-5.6-sol"), + } + expect(disallowedOAuthModelKeys(models)).toEqual(["totally-fine-name"]) + }) + + test("api.id wins over the key in both directions", () => { + const models = { + // key allowed, api.id rejected -> delete + "gpt-5.6-sol": model("gpt-5.6"), + // key rejected, api.id allowed -> keep + "gpt-5.6": model("gpt-5.6-sol"), + } + expect(disallowedOAuthModelKeys(models)).toEqual(["gpt-5.6-sol"]) + }) + + test("falls back to the key when api.id is absent", () => { + // The database backfills api.id only AFTER this hook runs, so the field can + // be missing despite the type. Behaviour must degrade to the old key match, + // not throw and not allow everything through. + const models = { + "gpt-5.6-sol": model(undefined), + "gpt-5.6": model(undefined), + "gpt-5.4": { api: {} }, + "gpt-5.2": { api: {} }, + } + expect(disallowedOAuthModelKeys(models).sort()).toEqual(["gpt-5.2", "gpt-5.6"]) + }) + + test("the catalog set resolves identically whether matched by key or api.id", () => { + // Every models.dev catalog entry has key === api.id (checked against + // https://models.dev/api.json), so this change is behaviour-preserving for + // catalog models — only aliases move. Guards against a future refactor + // quietly changing which flagship models the picker offers. + const catalog = Object.fromEntries([...VERIFIED_ACCEPTED, ...VERIFIED_REJECTED].map((id) => [id, model(id)])) + expect(disallowedOAuthModelKeys(catalog).sort()).toEqual([...VERIFIED_REJECTED].sort()) + }) + + test("an empty model map yields nothing to delete", () => { + expect(disallowedOAuthModelKeys({})).toEqual([]) + }) +}) From a8508e2efab9110371e43db7fc8c3aea4e22bb4a Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 16:41:37 -0700 Subject: [PATCH 4/4] docs: correct a false catalog claim, and record the cold-cache caveat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My previous commit asserted that gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, gpt-5.2-codex and gpt-5.3-codex-xhigh "are not in the models.dev catalog at all", and concluded that no filter here could hide them. That was wrong, and the review that caught it was right. I had checked live https://models.dev/api.json and stopped there. There is a second catalog: the bundled provider/models-snapshot.ts. ModelsDev.Data resolves disk cache -> bundled snapshot -> fetch, so on a fresh install or cold cache the snapshot IS the catalog. Its `openai` provider still carries gpt-5-codex, gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini and gpt-5.2-codex. Those five did reach the loader, the removed includes("codex") rule did offer them, and exact matching does now delete them. None was individually probed. They stay excluded, on the same fail-closed reasoning applied to gpt-5.2: an unverified inclusion fails opaquely mid-request, an unverified exclusion fails visibly at selection. But that is a decision, and it is now recorded as one rather than dressed up as a discovery. The same staleness cuts the other way and is also now recorded: the bundled snapshot contains no gpt-5.6 variant at all, so on a cold cache sol/luna/terra are absent from the catalog and this allowlist cannot conjure them — the filter only deletes. The allowlist is necessary for them to appear but, on a cold cache, not sufficient; they surface once the catalog refreshes from models.dev or the snapshot is regenerated at the next release build. Adds a test pinning the five unprobed snapshot codex ids as excluded-by- decision, asserting they are in neither verified list, so a future probe has an obvious place to land. Comments and tests only. The allowlist membership is unchanged, as is request-time behaviour. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ --- packages/opencode/src/plugin/codex.ts | 38 ++++++++++++---- .../test/plugin/codex-allowlist.test.ts | 45 +++++++++++++++---- 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 49518ece22..519dd3ae08 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -41,14 +41,36 @@ const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 * model missing from the picker that the official client offers them, that is * the likely cause and the fix is a per-account list, not another id here. * - * NOT A SILENT DROP. Reviewers have twice asked why codex-tagged ids such as - * gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, gpt-5.2-codex and - * gpt-5.3-codex-xhigh appear in neither list. They are not in the models.dev - * catalog at all (checked against https://models.dev/api.json — the only codex - * ids it carries are gpt-5.3-codex and gpt-5.3-codex-spark), so no filter here - * can hide them: they never reach this loader. The removed - * ``includes("codex")`` rule would have admitted them had they existed, which - * is why the old tests named them, but nothing in the picker changed for them. + * OLDER CODEX IDS ARE DROPPED, AND THAT DROP IS UNVERIFIED. Reviewers keep + * asking why codex-tagged ids appear in neither list above. The honest answer + * depends on WHICH catalog is live, because the two disagree: + * + * * live https://models.dev/api.json carries exactly two codex ids — + * gpt-5.3-codex and gpt-5.3-codex-spark — both accounted for above. + * * the BUNDLED snapshot (provider/models-snapshot.ts) is staler and still + * carries gpt-5-codex, gpt-5.1-codex, gpt-5.1-codex-max, + * gpt-5.1-codex-mini and gpt-5.2-codex under ``openai``. + * + * ModelsDev.Data resolves disk cache -> bundled snapshot -> fetch, so on a + * fresh install or cold cache the snapshot IS the catalog, and those five ids + * do reach this loader. The removed ``includes("codex")`` rule offered them; + * exact matching now deletes them. None was individually probed. They are + * absent from the discovery endpoint's nine slugs (a Pro account), which is + * evidence but not a probe, and consistent with older codex models having been + * retired. + * + * They stay out on the same fail-closed reasoning as the ids above: an + * unverified inclusion fails opaquely mid-request, an unverified exclusion + * fails visibly at selection with a "did you mean" list. Probe one and move it + * into the right list to settle it. + * + * COLD-CACHE CAVEAT. That same snapshot staleness cuts the other way for the + * models this list adds: it contains NO gpt-5.6 variant, so on a cold cache + * sol/luna/terra are absent from the catalog entirely and this allowlist cannot + * conjure them — the filter only ever deletes. They become selectable once the + * catalog refreshes from models.dev (or the bundled snapshot is regenerated at + * the next release build). This allowlist is necessary for them to appear, but + * on a cold cache it is not sufficient. * * There is no derivable rule here — the tier accepts ``gpt-5.3-codex-spark`` * but rejects ``gpt-5.3-codex``, and accepts the gpt-5.6 sol/luna/terra diff --git a/packages/opencode/test/plugin/codex-allowlist.test.ts b/packages/opencode/test/plugin/codex-allowlist.test.ts index 60fef2908d..f5f477bd4d 100644 --- a/packages/opencode/test/plugin/codex-allowlist.test.ts +++ b/packages/opencode/test/plugin/codex-allowlist.test.ts @@ -11,14 +11,17 @@ // ChatGPT account."} // // Ids asserted OUTSIDE those two lists were NOT probed and are not claimed to -// have been. They fall in two groups, both deliberate: -// * ids absent from the models.dev catalog (gpt-5.1-codex, gpt-5.1-codex-max, -// codex-hypothetical-future-name, …). These cannot reach the loader at all, -// so probing them would be meaningless; they are here purely as shape -// assertions against a substring rule creeping back in. -// * obviously-unrelated ids (claude-*, gemini-*, gpt-4o), same reason. -// The probe scope, and the four catalog ids left unprobed, are recorded on -// OAUTH_ALLOWED_MODELS in src/plugin/codex.ts. +// have been: +// * older codex ids (gpt-5.1-codex, gpt-5.1-codex-max, …). These ARE present +// in the bundled provider/models-snapshot.ts, so on a cold cache they do +// reach the loader and this filter does drop them. That drop is deliberate +// and fail-closed, not verified — see the "OLDER CODEX IDS" note on +// OAUTH_ALLOWED_MODELS. Asserting them here pins the current behaviour; +// it does not claim the backend rejects them. +// * obviously-unrelated ids (claude-*, gemini-*, gpt-4o) and invented names, +// which are pure shape assertions against a substring rule creeping back. +// The probe scope, the unprobed catalog ids, and the cold-cache caveat are all +// recorded on OAUTH_ALLOWED_MODELS in src/plugin/codex.ts. // // Two directions of breakage this file guards: // * false positives — an id offered in the picker that 400s at request time @@ -192,4 +195,30 @@ describe("disallowedOAuthModelKeys — what the loader actually deletes", () => test("an empty model map yields nothing to delete", () => { expect(disallowedOAuthModelKeys({})).toEqual([]) }) + + test("older codex ids from the bundled snapshot are dropped, deliberately", () => { + // These five are absent from live models.dev but ARE in the bundled + // provider/models-snapshot.ts, which is the catalog on a cold cache + // (ModelsDev.Data resolves disk cache -> snapshot -> fetch). So they do + // reach this filter and the removed includes("codex") rule used to offer + // them. None was probed; they are excluded fail-closed. + // + // This test pins that as a DECISION, not a discovery. If any of them is + // ever probed, move it into VERIFIED_ACCEPTED or VERIFIED_REJECTED and + // delete it from here. + const unprobedSnapshotCodexIds = [ + "gpt-5-codex", + "gpt-5.1-codex", + "gpt-5.1-codex-max", + "gpt-5.1-codex-mini", + "gpt-5.2-codex", + ] + for (const id of unprobedSnapshotCodexIds) { + expect(OAUTH_ALLOWED_MODELS.has(id)).toBe(false) + expect(VERIFIED_ACCEPTED).not.toContain(id) + expect(VERIFIED_REJECTED).not.toContain(id) + } + const models = Object.fromEntries(unprobedSnapshotCodexIds.map((id) => [id, model(id)])) + expect(disallowedOAuthModelKeys(models).sort()).toEqual([...unprobedSnapshotCodexIds].sort()) + }) })