diff --git a/packages/catalog/src/discovery/codex.ts b/packages/catalog/src/discovery/codex.ts index 9c950ee14..049c3b92f 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,29 @@ 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. Both listings derive their base-model + * metadata (1M-window floor, daybreak pricing, context fallback) from the + * canonical plain slug — the suffix is a routing variant, not a different + * model, so the `-wm` row no longer keeps stale backend-parsed capability + * values while its plain listing is enriched. + * + * Deliberate boundary: the "safe" gate is the bundled Codex catalog. A `-wm` + * slug whose plain counterpart is only a user-local models.yml entry (not + * bundled) stays verbatim — authoritative discovery for genuinely distinct + * `-wm` SKUs is preserved, and a hidden plain backend entry can be re-surfaced + * through its advertised `-wm` row because the configured plain slug must + * resolve. + */ +const CODEX_WORKER_SUFFIX = "-wm"; const CODEX_REMOTE_COMPACTION = { enabled: true, api: "openai-codex-responses", @@ -194,11 +218,30 @@ 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. Both listings of a safe `-wm` model carry the same base-model + // metadata (context-window floor, daybreak pricing) derived from the + // canonical plain slug — the suffix is a routing variant, not a different + // model. + const advertisedSlugs = new Set(parsedEntries.map(parsed => parsed.slug)); + const bundledCodexModelIds = getBundledCodexModelIds(); + const normalized: NormalizedCodexModel[] = []; + for (const parsed of parsedEntries) { + const canonicalSlug = plainCounterpartForWorkerSlug(parsed.slug, bundledCodexModelIds) ?? parsed.slug; + normalized.push(buildNormalizedCodexModel(parsed, parsed.slug, canonicalSlug, baseUrl)); + const plainSlug = canonicalSlug !== parsed.slug ? canonicalSlug : null; + if (plainSlug && !advertisedSlugs.has(plainSlug)) { + normalized.push(buildNormalizedCodexModel(parsed, plainSlug, canonicalSlug, baseUrl)); } } @@ -212,7 +255,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 +302,65 @@ 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, + }; +} + +/** + * Build a normalized Codex model spec. `slug` is the registered id (either the + * advertised slug or a synthesized plain counterpart); `canonicalSlug` names + * the model's bundled SKU (`slug` itself for plain/unknown rows, the plain + * counterpart for a safe `-wm` row) and owns the base-model metadata derivation + * so both listings of a model report the same context window and pricing. + */ +function buildNormalizedCodexModel( + parsed: ParsedCodexModelEntry, + slug: string, + canonicalSlug: 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. Keyed on the + // canonical slug so a safe `gpt-5.6-luna-wm` row gets the same floor as its + // plain listing. + const parsedKnown = parseKnownModel(canonicalSlug); 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 contextWindow = CODEX_GPT_5_6_1M_SLUGS.has(slug) + const reportedContextWindow = parsed.contextWindow ?? fallbackContextWindow; + const contextWindow = CODEX_GPT_5_6_1M_SLUGS.has(canonicalSlug) ? 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); + const daybreakCost = resolveOpenAIDaybreakStandardCost(canonicalSlug); 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..9415be50d 100644 --- a/packages/catalog/test/codex-discovery.test.ts +++ b/packages/catalog/test/codex-discovery.test.ts @@ -11,6 +11,7 @@ import { resolveProviderModels } from "@oh-my-pi/pi-catalog/model-manager"; import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; import { openaiCodexModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/special"; import type { ModelSpec } from "@oh-my-pi/pi-catalog/types"; +import { resolveProviderModelReference } from "@oh-my-pi/pi-coding-agent/config/model-resolver"; describe("Codex model discovery", () => { it("marks discovered models for provider-native V2 compaction", async () => { @@ -538,4 +539,149 @@ 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… + const workerModel = result?.models.find(model => model.id === "gpt-5.6-luna-wm"); + expect(workerModel).toBeDefined(); + // …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(); + expect(plainModel?.provider).toBe("openai-codex"); + // Both rows are the same model: the worker variant shares the plain + // SKU's base metadata, so the 1M window floor applies to both. + expect(workerModel?.contextWindow).toBe(1_000_000); + expect(plainModel?.contextWindow).toBe(1_000_000); + }); + + 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 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, + }); + // No artificial static input: the bundled Codex catalog is the real + // gate that licenses the plain-route synthesis. + const result = await resolveProviderModels( + { ...options, cacheDbPath: path.join(tempDir, "models.db") }, + "online", + ); + + const ids = result.models.map(model => model.id); + expect(ids).toContain("gpt-5.6-luna"); + expect(ids).toContain("gpt-5.6-luna-wm"); + + // Same engine the runtime uses: resolving the configured + // `openai-codex/gpt-5.6-luna` must bind to the plain route by exact + // id, not fall through to the `-wm` fuzzy match. + const resolved = resolveProviderModelReference("openai-codex", "gpt-5.6-luna", result.models); + expect(resolved?.id).toBe("gpt-5.6-luna"); + expect(resolved?.provider).toBe("openai-codex"); + // An explicitly configured worker slug still resolves verbatim. + const resolvedWm = resolveProviderModelReference("openai-codex", "gpt-5.6-luna-wm", result.models); + expect(resolvedWm?.id).toBe("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"]); + }); });