diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index ac89dab06a..519dd3ae08 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -16,36 +16,152 @@ 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. + * + * CONFIRMED REJECTED, so deliberately absent — each returned + * ``400 {"detail":"The '' model is not supported when using Codex with a + * 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. + * + * 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 + * 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. + * + * 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 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([ - "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) } +/** 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 @@ -429,9 +545,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 @@ -439,8 +556,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 52ef8c7f72..f5f477bd4d 100644 --- a/packages/opencode/test/plugin/codex-allowlist.test.ts +++ b/packages/opencode/test/plugin/codex-allowlist.test.ts @@ -2,9 +2,37 @@ // 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 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: +// * 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 +// (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 @@ -15,57 +43,56 @@ // 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" - -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) - }) +import { OAUTH_ALLOWED_MODELS, disallowedOAuthModelKeys, shouldAllowOAuthModel } from "../../src/plugin/codex" - test("gpt-5.5 is present (added alongside 5.6 for parity)", () => { - expect(OAUTH_ALLOWED_MODELS.has("gpt-5.5")).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("prior non-codex generations stay allowlisted (no accidental removal)", () => { - for (const id of ["gpt-5.2", "gpt-5.4", "gpt-5.4-mini"]) { - expect(OAUTH_ALLOWED_MODELS.has(id)).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("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) +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("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,57 +100,125 @@ 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) } }) }) + +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([]) + }) + + 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()) + }) +})