From 51fd17a8c329aeb8b90b61327ba19a6e39dfd5d2 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Tue, 18 Aug 2026 16:23:00 -0400 Subject: [PATCH] fix(catalog): register plain Codex route for worker `-wm` SKUs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex backend discovery advertises worker-mode SKUs under a `-wm` suffix (gpt-5.6-luna-wm). Authoritative discovery replaced the bundled catalog and kept those slugs verbatim, so a configured `openai-codex/gpt-5.6-luna` vanished from the resolved catalog and the resolver's fuzzy fallback selected `-wm` instead — a route some ChatGPT accounts reject. Model discovery now recognizes the `-wm` suffix: when the bundled Codex catalog ships the plain SKU, the `-wm` row is also registered under its plain id (re-derived so the 1M-window floor and daybreak pricing keyed on that slug still apply). Unknown `-wm` SKUs keep their authoritative verbatim slug and non-worker models are untouched, so distinct models and other providers are unaffected. --- packages/catalog/src/discovery/codex.ts | 114 +++++++++++--- packages/catalog/test/codex-discovery.test.ts | 144 ++++++++++++++++++ 2 files changed, 236 insertions(+), 22 deletions(-) diff --git a/packages/catalog/src/discovery/codex.ts b/packages/catalog/src/discovery/codex.ts index 9c950ee14..68d5f1f95 100644 --- a/packages/catalog/src/discovery/codex.ts +++ b/packages/catalog/src/discovery/codex.ts @@ -1,5 +1,6 @@ import { type } from "@oh-my-pi/omptype"; import { parseKnownModel, semverEqual } from "../identity/classify"; +import { getBundledModels } from "../models"; import { resolveOpenAIDaybreakStandardCost } from "../openai-pricing"; import type { FetchImpl, ModelSpec } from "../types"; import { discoveryFetch } from "../utils"; @@ -22,6 +23,21 @@ const GPT_5_6_CONTEXT_WINDOW = 372_000; */ const GPT_5_6_1M_CONTEXT_WINDOW = 1_000_000; const CODEX_GPT_5_6_1M_SLUGS: ReadonlySet = new Set(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"]); +/** + * Codex advertises worker-mode SKUs under a `-wm` suffix (`gpt-5.6-luna-wm`). + * + * Those rows route through the same Codex backend as their plain SKU, but an + * authoritative discovery list that only advertises the `-wm` slug prunes the + * bundled plain model, leaving a configured `openai-codex/gpt-5.6-luna` + * unresolvable except via fuzzy fallback onto the `-wm` row — which this user's + * ChatGPT account rejects. The compatibility rule, scoped to Codex discovery: + * a `-wm` slug whose plain counterpart exists in the bundled Codex catalog is + * ALSO registered under its plain id (re-derived, not cloned, so the 1M-window + * floor and daybreak pricing keyed on the plain slug still apply). Models + * without a bundled plain counterpart stay verbatim, preserving authoritative + * discovery for genuinely distinct `-wm` SKUs. + */ +const CODEX_WORKER_SUFFIX = "-wm"; const CODEX_REMOTE_COMPACTION = { enabled: true, api: "openai-codex-responses", @@ -194,11 +210,26 @@ function normalizeCodexModels(payload: unknown, baseUrl: string): ModelSpec<"ope } const entries = parsedResponse.models ?? parsedResponse.data ?? []; - const normalized: NormalizedCodexModel[] = []; + const parsedEntries: ParsedCodexModelEntry[] = []; for (const entry of entries) { - const model = normalizeCodexModelEntry(entry, baseUrl); - if (model) { - normalized.push(model); + const parsed = parseCodexModelEntry(entry); + if (parsed) { + parsedEntries.push(parsed); + } + } + + // A worker `-wm` slug gets an extra plain-id route only when the bundled + // catalog ships the plain SKU (the "safe" precondition); the backend's own + // plain slug wins over any synthesized clone, and unknown `-wm` SKUs stay + // verbatim. + const advertisedSlugs = new Set(parsedEntries.map(parsed => parsed.slug)); + const bundledCodexModelIds = getBundledCodexModelIds(); + const normalized: NormalizedCodexModel[] = []; + for (const parsed of parsedEntries) { + normalized.push(buildNormalizedCodexModel(parsed, parsed.slug, baseUrl)); + const plainSlug = plainCounterpartForWorkerSlug(parsed.slug, bundledCodexModelIds); + if (plainSlug && !advertisedSlugs.has(plainSlug)) { + normalized.push(buildNormalizedCodexModel(parsed, plainSlug, baseUrl)); } } @@ -212,7 +243,37 @@ function normalizeCodexModels(payload: unknown, baseUrl: string): ModelSpec<"ope return normalized.map(item => item.model); } -function normalizeCodexModelEntry(entry: unknown, baseUrl: string): NormalizedCodexModel | null { +/** Ids of the bundled Codex catalog, consulted once per discovery run. */ +function getBundledCodexModelIds(): ReadonlySet { + const ids = new Set(getBundledModels("openai-codex").map(model => model.id)); + return ids; +} + +/** + * Map a Codex worker `-wm` slug to its plain counterpart when the bundled + * catalog registers that plain SKU. Returns `null` for non-worker slugs and + * for `-wm` slugs without a safe plain counterpart. + */ +function plainCounterpartForWorkerSlug(slug: string, bundledCodexModelIds: ReadonlySet): string | null { + if (!slug.endsWith(CODEX_WORKER_SUFFIX)) { + return null; + } + const plain = slug.slice(0, -CODEX_WORKER_SUFFIX.length); + return plain.length > 0 && bundledCodexModelIds.has(plain) ? plain : null; +} + +interface ParsedCodexModelEntry { + slug: string; + name: string; + contextWindow: number | null; + reasoning: boolean; + input: ("text" | "image")[]; + preferWebsockets: boolean; + useResponsesLite: boolean; + priority: number; +} + +function parseCodexModelEntry(entry: unknown): ParsedCodexModelEntry | null { const parsedEntry = codexModelEntrySchema(entry); if (parsedEntry instanceof type.errors) { return null; @@ -229,44 +290,53 @@ function normalizeCodexModelEntry(entry: unknown, baseUrl: string): NormalizedCo return null; } - const name = toNonEmptyString(payload.display_name) ?? slug; + return { + slug, + name: toNonEmptyString(payload.display_name) ?? slug, + contextWindow: toPositiveInt(payload.context_window), + reasoning: supportsReasoning(payload.default_reasoning_level, payload.supported_reasoning_levels), + input: normalizeInputModalities(payload.input_modalities), + preferWebsockets: toBoolean(payload.prefer_websockets) === true, + useResponsesLite: toBoolean(payload.use_responses_lite) === true, + priority: toFiniteNumber(payload.priority) ?? Number.MAX_SAFE_INTEGER, + }; +} + +function buildNormalizedCodexModel(parsed: ParsedCodexModelEntry, slug: string, baseUrl: string): NormalizedCodexModel { // Codex discovery historically omitted `context_window` for GPT-5.6-family // SKUs (#5705); luna/sol/terra additionally floor the reported value because - // the registry still declares the pre-1M 272000 window. - const parsed = parseKnownModel(slug); + // the registry still declares the pre-1M 272000 window. Re-derived from the + // effective `slug` so a synthesized plain route gets the same treatment as + // one actually advertised under that id. + const parsedKnown = parseKnownModel(slug); const fallbackContextWindow = - parsed.family === "openai" && semverEqual(parsed.version, "5.6") + parsedKnown.family === "openai" && semverEqual(parsedKnown.version, "5.6") ? GPT_5_6_CONTEXT_WINDOW : DEFAULT_CONTEXT_WINDOW; - const reportedContextWindow = toPositiveInt(payload.context_window) ?? fallbackContextWindow; + const reportedContextWindow = parsed.contextWindow ?? fallbackContextWindow; const contextWindow = CODEX_GPT_5_6_1M_SLUGS.has(slug) ? Math.max(reportedContextWindow, GPT_5_6_1M_CONTEXT_WINDOW) : reportedContextWindow; const maxTokens = Math.min(DEFAULT_MAX_TOKENS, contextWindow); - const reasoning = supportsReasoning(payload.default_reasoning_level, payload.supported_reasoning_levels); - const input = normalizeInputModalities(payload.input_modalities); - const preferWebsockets = toBoolean(payload.prefer_websockets) === true; - const useResponsesLite = toBoolean(payload.use_responses_lite) === true; - const priority = toFiniteNumber(payload.priority) ?? Number.MAX_SAFE_INTEGER; const daybreakCost = resolveOpenAIDaybreakStandardCost(slug); return { - priority, + priority: parsed.priority, model: { id: slug, - name, + name: parsed.name, api: "openai-codex-responses", provider: "openai-codex", baseUrl, - reasoning, - input, + reasoning: parsed.reasoning, + input: parsed.input, cost: daybreakCost ? { ...daybreakCost } : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, remoteCompaction: CODEX_REMOTE_COMPACTION, contextWindow, maxTokens, - ...(preferWebsockets ? { preferWebsockets: true } : {}), - ...(useResponsesLite ? { useResponsesLite: true } : {}), - ...(priority !== Number.MAX_SAFE_INTEGER ? { priority } : {}), + ...(parsed.preferWebsockets ? { preferWebsockets: true } : {}), + ...(parsed.useResponsesLite ? { useResponsesLite: true } : {}), + ...(parsed.priority !== Number.MAX_SAFE_INTEGER ? { priority: parsed.priority } : {}), }, }; } diff --git a/packages/catalog/test/codex-discovery.test.ts b/packages/catalog/test/codex-discovery.test.ts index 7cf08217e..a05165c65 100644 --- a/packages/catalog/test/codex-discovery.test.ts +++ b/packages/catalog/test/codex-discovery.test.ts @@ -538,4 +538,148 @@ describe("Codex model discovery", () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it("registers a plain route when the backend advertises only the worker `-wm` slug", async () => { + const fetchFn: typeof fetch = Object.assign( + async () => + new Response( + JSON.stringify({ + models: [ + { + slug: "gpt-5.6-luna-wm", + display_name: "GPT-5.6 Luna", + context_window: 272_000, + default_reasoning_level: "medium", + supported_reasoning_levels: ["low", "medium", "high"], + input_modalities: ["text", "image"], + supported_in_api: true, + }, + ], + }), + ), + { preconnect() {} }, + ); + const result = await fetchCodexModels({ + accessToken: "test-token", + baseUrl: "https://codex.example/backend-api", + clientVersion: "0.99.0", + fetchFn, + }); + + // The authoritative `-wm` row stays surfaced verbatim… + expect(result?.models.some(model => model.id === "gpt-5.6-luna-wm")).toBe(true); + // …and the configured plain slug must also resolve to a real route. + const plainModel = result?.models.find(model => model.id === "gpt-5.6-luna"); + expect(plainModel).toBeDefined(); + // The plain route is re-derived for the plain slug, so the 1M floor applies. + expect(plainModel?.contextWindow).toBe(1_000_000); + expect(plainModel?.provider).toBe("openai-codex"); + }); + + it("keeps the plain route through authoritative discovery that advertises only the `-wm` slug", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-catalog-codex-luna-wm-")); + const bundledLuna: ModelSpec<"openai-codex-responses"> = { + id: "gpt-5.6-luna", + name: "GPT-5.6 Luna", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 128_000, + }; + const fetchFn: typeof fetch = Object.assign( + async () => + new Response( + JSON.stringify({ + models: [ + { + slug: "gpt-5.6-luna-wm", + display_name: "GPT-5.6 Luna", + default_reasoning_level: "medium", + supported_reasoning_levels: ["low", "medium", "high"], + input_modalities: ["text", "image"], + supported_in_api: true, + }, + ], + }), + ), + { preconnect() {} }, + ); + try { + const options = openaiCodexModelManagerOptions({ + resolveAccounts: async () => [{ accessToken: "test-token" }], + fetch: fetchFn, + }); + const result = await resolveProviderModels( + { ...options, staticModels: [bundledLuna], cacheDbPath: path.join(tempDir, "models.db") }, + "online", + ); + + const ids = result.models.map(model => model.id); + // The exact-id resolution the resolver performs for + // `openai-codex/gpt-5.6-luna` needs this row present. + expect(ids).toContain("gpt-5.6-luna"); + expect(ids).toContain("gpt-5.6-luna-wm"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("keeps a `-wm` slug verbatim when it has no bundled plain counterpart", async () => { + const fetchFn: typeof fetch = Object.assign( + async () => + new Response( + JSON.stringify({ + models: [ + { + slug: "gpt-9.9-mystery-wm", + display_name: "GPT-9.9 Mystery (worker)", + input_modalities: ["text"], + supported_in_api: true, + }, + ], + }), + ), + { preconnect() {} }, + ); + const result = await fetchCodexModels({ + accessToken: "test-token", + baseUrl: "https://codex.example/backend-api", + clientVersion: "0.99.0", + fetchFn, + }); + // No bundled `gpt-9.9-mystery` entry, so no phantom plain route is made up. + expect(result?.models.map(model => model.id)).toEqual(["gpt-9.9-mystery-wm"]); + }); + + it("leaves a non-worker slug untouched by the worker-mapping rule", async () => { + const fetchFn: typeof fetch = Object.assign( + async () => + new Response( + JSON.stringify({ + models: [ + { + slug: "gpt-5.6-luna", + display_name: "GPT-5.6 Luna", + default_reasoning_level: "medium", + supported_reasoning_levels: ["low", "medium", "high"], + input_modalities: ["text", "image"], + supported_in_api: true, + }, + ], + }), + ), + { preconnect() {} }, + ); + const result = await fetchCodexModels({ + accessToken: "test-token", + baseUrl: "https://codex.example/backend-api", + clientVersion: "0.99.0", + fetchFn, + }); + expect(result?.models.map(model => model.id)).toEqual(["gpt-5.6-luna"]); + }); });