diff --git a/packages/first-class-providers/src/providers.ts b/packages/first-class-providers/src/providers.ts index 48406ce04..a42cd1e92 100644 --- a/packages/first-class-providers/src/providers.ts +++ b/packages/first-class-providers/src/providers.ts @@ -55,10 +55,9 @@ export const OPENAI_API_MAX_COMPLETION_TOKENS_MODELS: readonly string[] = [ * Tier A order: dual-path OpenAI, OAuth xAI, Go/Zen, Z.AI, big three, Custom. * * Canonical static registry for provider identity: base URLs, model lists, - * and defaults live here. The Codex/xAI OAuth fallbacks stay separate — they - * back live catalog calls, not static identity — but must agree with this - * registry; src/provider/identity-divergence.test.ts pins that. Do not add - * another edge list: reference this registry or the fallbacks. + * and defaults live here. The Codex/xAI OAuth model lists stay separate, since + * those sign-ins serve their own model sets and defaults. Do not add another + * edge list: reference this registry or those lists. */ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [ { diff --git a/src/auth/codex/constants.test.ts b/src/auth/codex/constants.test.ts new file mode 100644 index 000000000..40f482485 --- /dev/null +++ b/src/auth/codex/constants.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; + +import { CODEX_DEFAULT_MODELS } from "./constants.js"; + +describe("CODEX_DEFAULT_MODELS", () => { + // Recorded from GET /codex/models for a ChatGPT Plus account (client_version + // 0.165.0, 2026-11-01). Nothing else pins the order or the default: the other + // tests read the constant. + test("follows the order the Codex backend listed the models in", () => { + expect([...CODEX_DEFAULT_MODELS]).toEqual([ + "gpt-6.1-sol", + "gpt-6-astra", + "gpt-6.1-terra", + "gpt-6-sol", + "gpt-6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + ]); + }); + + test("leaves out the models OpenAI retired for ChatGPT sign-in", () => { + for (const retired of ["gpt-5.4", "gpt-5.4-mini", "gpt-5.5"]) { + expect(CODEX_DEFAULT_MODELS as readonly string[]).not.toContain(retired); + } + }); +}); diff --git a/src/auth/codex/constants.ts b/src/auth/codex/constants.ts index b81fab440..8381f17e9 100644 --- a/src/auth/codex/constants.ts +++ b/src/auth/codex/constants.ts @@ -21,19 +21,20 @@ export const CODEX_CLIENT_VERSION = "0.50.0"; // the public Codex CLI originator. export const CODEX_ORIGINATOR = "codex_cli_rs"; -// Fallback model list, used only when the live catalog (GET /codex/models) is -// unavailable — rate-limited it returns an empty list. The backend rotates its -// serving set, so the live fetch is authoritative and these are just a -// current-generation default so the picker is never empty. The first entry -// doubles as the ChatGPT-OAuth default: it must stay the model shared with the -// OpenAI API-key path's default in FIRST_CLASS_PROVIDERS (gpt-5.4) so both -// auth paths serving OpenAI agree. identity-divergence.test.ts pins this. +// The models a ChatGPT sign-in offers, in the order GET /codex/models listed +// them for a ChatGPT Plus account (client_version 0.165.0, 2026-11-01); the +// first entry is the ChatGPT default. Availability varies by plan and rollout, +// so other accounts may be served a different set. gpt-5.4, gpt-5.4-mini and +// gpt-5.5 are left out because OpenAI retired them from Codex with ChatGPT +// sign-in. API-key sessions are not affected, so the API-key path keeps its +// own list and default. export const CODEX_DEFAULT_MODELS = [ - "gpt-5.4", - "gpt-5.5", + "gpt-6.1-sol", "gpt-6-astra", + "gpt-6.1-terra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", - "gpt-5.4-mini", ] as const; diff --git a/src/config/codex-providers.test.ts b/src/config/codex-providers.test.ts index 27a3d4205..87226391f 100644 --- a/src/config/codex-providers.test.ts +++ b/src/config/codex-providers.test.ts @@ -22,8 +22,8 @@ describe("codex provider naming", () => { }); }); -// The default-model agreement is pinned without literals in -// provider/identity-divergence.test.ts — no second literal re-pin here. +// The projected models and default are pinned against CODEX_DEFAULT_MODELS in +// provider/identity-divergence.test.ts; no literal re-pin here. describe("codexProvidersAsSettings", () => { test("projects profiles into provider settings seeded with the access token", () => { const profiles: CodexProfile[] = [ diff --git a/src/provider/identity-divergence.test.ts b/src/provider/identity-divergence.test.ts index e8935ebaa..707f1977f 100644 --- a/src/provider/identity-divergence.test.ts +++ b/src/provider/identity-divergence.test.ts @@ -56,20 +56,6 @@ describe("provider identity divergence", () => { resetGoModelDiscoveryForTests(); }); - test("OpenAI API-key default and ChatGPT-OAuth fallback default agree", () => { - const apiPath = firstClassProviderById("openai")?.paths?.find( - (p) => p.id === "api", - ); - if (apiPath?.defaultModel === undefined) - throw new Error("Expected the OpenAI API path to declare a defaultModel"); - // Both auth paths serve OpenAI models, so the shared model must lead both - // lists — the fallback default is models[0] by construction. - const codexFallbacks: readonly string[] = CODEX_DEFAULT_MODELS; - const codexDefault: string | undefined = codexFallbacks[0]; - expect(codexDefault).toBe(apiPath.defaultModel); - expect(codexFallbacks).toContain(apiPath.defaultModel); - }); - test("OpenAI API-key entry is self-consistent", () => { const apiPath = firstClassProviderById("openai")?.paths?.find( (p) => p.id === "api", diff --git a/src/provider/reasoning-effort.test.ts b/src/provider/reasoning-effort.test.ts index 9e7c9a5fe..cf25c48f4 100644 --- a/src/provider/reasoning-effort.test.ts +++ b/src/provider/reasoning-effort.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, test, expect } from "bun:test"; +import { CODEX_DEFAULT_MODELS } from "../auth/codex/constants.js"; import { isReasoningEffort, normalizeProviderEfforts, @@ -54,7 +55,7 @@ describe("supportedEfforts", () => { ]); }); - test("gpt-6-astra takes low through max on both API and Codex paths", () => { + test("gpt-6-astra takes low through max on the API path", () => { expect(supportedEfforts("gpt-6-astra")).toEqual([ "low", "medium", @@ -62,24 +63,45 @@ describe("supportedEfforts", () => { "xhigh", "max", ]); - expect(supportedEfforts("gpt-6-astra", undefined, true)).toEqual([ - "low", - "medium", - "high", - "xhigh", - "max", - ]); }); - test("gpt-5.6 family additionally takes max and ultra on the codex backend", () => { - expect(supportedEfforts("gpt-5.6-sol", undefined, true)).toEqual([ - "low", - "medium", - "high", - "xhigh", - "max", - "ultra", - ]); + test("ChatGPT models the backend serves up to ultra offer max and ultra", () => { + for (const model of [ + "gpt-6.1-sol", + "gpt-6-astra", + "gpt-6.1-terra", + "gpt-6-sol", + "gpt-5.6-sol", + "gpt-5.6-terra", + ]) { + expect(supportedEfforts(model, undefined, true)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", + ]); + } + }); + + test("ChatGPT Luna models stop at max", () => { + for (const model of ["gpt-6-luna", "gpt-5.6-luna"]) { + const efforts = supportedEfforts(model, undefined, true); + expect(efforts).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(efforts).not.toContain("ultra"); + } + }); + + test("every built-in ChatGPT model has an assigned effort ladder", () => { + // A model added to the fallback list without a ladder would silently + // stop at xhigh. A model the backend really serves only up to xhigh + // belongs here, so adding one is a decision rather than an accident. + const servedOnlyUpToXhigh: readonly string[] = []; + for (const model of CODEX_DEFAULT_MODELS) { + if (servedOnlyUpToXhigh.includes(model)) continue; + expect(supportedEfforts(model, undefined, true)).toContain("max"); + } }); test("the model name alone does not imply codex levels", () => { diff --git a/src/provider/reasoning-effort.ts b/src/provider/reasoning-effort.ts index e87e019d2..79ae76d06 100644 --- a/src/provider/reasoning-effort.ts +++ b/src/provider/reasoning-effort.ts @@ -56,7 +56,8 @@ const FULL_EFFORT_MODELS: readonly string[] = [ "gpt-5.1-codex-max", ]; -// Codex backend models take low/medium/high/xhigh — no `minimal`, no `none`. +// ChatGPT (Codex backend) models not listed below: low/medium/high/xhigh, +// no `minimal`, no `none`. const CODEX_EFFORTS: readonly ReasoningEffort[] = [ "low", "medium", @@ -64,11 +65,21 @@ const CODEX_EFFORTS: readonly ReasoningEffort[] = [ "xhigh", ]; -// gpt-5.6 family adds `max` and `ultra`; older Codex models do not support -// them. -const MAX_EFFORT_CODEX_MODELS: readonly string[] = [ +// Per-model levels the Codex backend lists (`supported_reasoning_levels` in +// GET /codex/models, client_version 0.165.0, 2026-10-28). Exact ids rather +// than a name rule: an id not seen yet gets the conservative fallback above +// instead of a guessed tier, because the adapter does not clamp and a level +// the backend does not serve breaks the turn. +const ULTRA_EFFORT_CODEX_MODELS: readonly string[] = [ + "gpt-6.1-sol", + "gpt-6-astra", + "gpt-6.1-terra", + "gpt-6-sol", "gpt-5.6-sol", "gpt-5.6-terra", +]; +const MAX_EFFORT_CODEX_MODELS: readonly string[] = [ + "gpt-6-luna", "gpt-5.6-luna", ]; @@ -96,8 +107,8 @@ function isMuseSparkModel(model: string): boolean { const GROK_46_AND_47_EFFORTS: readonly ReasoningEffort[] = CODEX_EFFORTS; const GROK_46_AND_47_MODELS: readonly string[] = ["grok-4.6", "grok-4.7"]; -// GPT-6 Astra accepts low–max on both surfaces; not ultra (gpt-5.6 -// Codex-only), minimal, or none. +// GPT-6 Astra on the API path accepts low–max; not ultra, minimal, or none. +// On ChatGPT sign-in it follows the served-levels lists above. const GPT6_ASTRA_EFFORTS: readonly ReasoningEffort[] = [ "low", "medium", @@ -152,14 +163,17 @@ export function supportedEfforts( if (providerEfforts.length > 0) { return [...providerEfforts]; } - if (model === "gpt-6-astra") { - return [...GPT6_ASTRA_EFFORTS]; - } if (isCodex) { + if (ULTRA_EFFORT_CODEX_MODELS.includes(model)) { + return [...CODEX_EFFORTS, "max", "ultra"]; + } return MAX_EFFORT_CODEX_MODELS.includes(model) - ? [...CODEX_EFFORTS, "max", "ultra"] + ? [...CODEX_EFFORTS, "max"] : [...CODEX_EFFORTS]; } + if (model === "gpt-6-astra") { + return [...GPT6_ASTRA_EFFORTS]; + } if (FULL_EFFORT_MODELS.includes(model)) { return ["none", ...DEFAULT_EFFORTS, "xhigh"]; }