From f9307904c29108ad6675a203c6f87d190588422e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 9 Jul 2026 13:48:53 -0700 Subject: [PATCH] FN-7745: add GPT-5.6 codenamed model variants to model selector Registers the three GPT-5.6 codenamed OpenAI Codex model variants (luna, sol, terra) so they appear in the model picker, since pricing alone did not make them selectable. - Add packages/core/src/openai-models.ts with SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION and mergeSupplementalOpenAiCodexModels(), mirroring the existing Anthropic supplemental-merge seam; additive and dedupe-safe against the pinned pi-ai catalog - Wire mergeSupplementalOpenAiCodexModels into GET /api/models via packages/dashboard/src/routes/register-model-routes.ts, alongside the existing Anthropic supplemental merge - Export new symbols from packages/core/src/index.ts and packages/core/src/index.gate.ts - Add unit tests for the merge helper (packages/core/src/__tests__/openai-models.test.ts) and the route wiring (packages/dashboard/src/__tests__/register-model-routes-openai-codex-supplemental.test.ts) - Document the new supplemental catalog entries in docs/settings-reference.md - Add changeset .changeset/fn-7745-gpt-5-6-codenamed-model-selector.md (minor, @runfusion/fusion) Files changed: .../fn-7745-gpt-5-6-codenamed-model-selector.md | 7 + docs/settings-reference.md | 2 + packages/core/src/__tests__/openai-models.test.ts | 134 +++++++++++++++++ packages/core/src/index.gate.ts | 9 ++ packages/core/src/index.ts | 9 ++ packages/core/src/openai-models.ts | 166 +++++++++++++++++++++ ...-model-routes-openai-codex-supplemental.test.ts | 143 ++++++++++++++++++ .../dashboard/src/routes/register-model-routes.ts | 10 +- 8 files changed, 479 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-7745 Fusion-Task-Lineage: 74c83182-df81-4606-a4dc-0da3ee4cae83 Co-authored-by: Fusion (runfusion.ai) --- ...n-7745-gpt-5-6-codenamed-model-selector.md | 7 + docs/settings-reference.md | 2 + .../core/src/__tests__/openai-models.test.ts | 134 ++++++++++++++ packages/core/src/index.gate.ts | 9 + packages/core/src/index.ts | 9 + packages/core/src/openai-models.ts | 166 ++++++++++++++++++ ...l-routes-openai-codex-supplemental.test.ts | 143 +++++++++++++++ .../src/routes/register-model-routes.ts | 10 +- 8 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-7745-gpt-5-6-codenamed-model-selector.md create mode 100644 packages/core/src/__tests__/openai-models.test.ts create mode 100644 packages/core/src/openai-models.ts create mode 100644 packages/dashboard/src/__tests__/register-model-routes-openai-codex-supplemental.test.ts diff --git a/.changeset/fn-7745-gpt-5-6-codenamed-model-selector.md b/.changeset/fn-7745-gpt-5-6-codenamed-model-selector.md new file mode 100644 index 0000000000..e46f20aec8 --- /dev/null +++ b/.changeset/fn-7745-gpt-5-6-codenamed-model-selector.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: GPT-5.6 codenamed models (luna, sol, terra) are now selectable in the model picker. +category: feature +dev: Adds mergeSupplementalOpenAiCodexModels in @fusion/core, invoked from GET /api/models alongside the Anthropic supplemental merge; additive and deduped against the pinned pi-ai catalog, gated by the configured openai-codex provider. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index fc503148b3..adc3b4b802 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -977,6 +977,8 @@ When the Cursor Runtime plugin (`fusion-plugin-cursor-runtime`) is installed and When the Grok Runtime plugin (`fusion-plugin-grok-runtime`) is installed and the `useGrokCli` toggle is enabled (Settings → Authentication), Grok CLI-discovered models (`grok models`) are surfaced additively in `/api/models` under the `grok-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `grok` on every request; a missing/failed/unavailable Grok CLI binary simply yields zero `grok-cli` rows without affecting other providers. Disabling `useGrokCli` hides all `grok-cli` rows. Unlike Cursor (OAuth/session auth), Grok is API-key auth: the Settings card's status text guides operators to `GROK_API_KEY` or `~/.grok/user-settings.json` when the binary is available but no key is configured. +The three GPT-5.6 codenamed OpenAI Codex variants (`gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) are additively surfaced under the `openai-codex` provider (FN-7745, mirroring the Anthropic/Z.ai supplemental-merge pattern above) so they appear in `/api/models` whenever `openai-codex` is configured — deduped against any pinned pi-ai catalog row that already carries one of the ids. + ### Planning model 1. Per-task `planningModelProvider` + `planningModelId` diff --git a/packages/core/src/__tests__/openai-models.test.ts b/packages/core/src/__tests__/openai-models.test.ts new file mode 100644 index 0000000000..86768d4a6a --- /dev/null +++ b/packages/core/src/__tests__/openai-models.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { + GPT_5_6_LUNA_MODEL_ID, + GPT_5_6_SOL_MODEL_ID, + GPT_5_6_TERRA_MODEL_ID, + OPENAI_CODEX_PROVIDER_ID, + SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION, + mergeSupplementalOpenAiCodexModels, +} from "../openai-models.js"; + +const EXPECTED_IDS = [GPT_5_6_LUNA_MODEL_ID, GPT_5_6_SOL_MODEL_ID, GPT_5_6_TERRA_MODEL_ID]; + +describe("SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION", () => { + it("targets the openai-codex-responses API and ChatGPT backend baseUrl", () => { + expect(OPENAI_CODEX_PROVIDER_ID).toBe("openai-codex"); + expect(SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION).toMatchObject({ + name: "OpenAI Codex", + baseUrl: "https://chatgpt.com/backend-api", + api: "openai-codex-responses", + }); + }); + + it("carries exactly the three GPT-5.6 codenamed variant ids", () => { + const modelIds = SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION.models.map((model) => model.id); + expect(modelIds).toEqual(EXPECTED_IDS); + }); +}); + +describe("mergeSupplementalOpenAiCodexModels", () => { + it("adds all three ids when the registry lacks them", () => { + const registeredProviders = new Map(); + const registry = { + registeredProviders, + registerProvider(providerName: string, config: unknown) { + registeredProviders.set(providerName, config); + }, + }; + + mergeSupplementalOpenAiCodexModels(registry); + + const registered = registeredProviders.get(OPENAI_CODEX_PROVIDER_ID) as + typeof SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION; + expect(registered).toBeDefined(); + expect(registered.models.map((model) => model.id)).toEqual(expect.arrayContaining(EXPECTED_IDS)); + }); + + it("does not duplicate an id the registry already registers — existing row wins", () => { + const existingLunaRow = { + id: GPT_5_6_LUNA_MODEL_ID, + name: "GPT-5.6 Luna (pinned catalog)", + provider: OPENAI_CODEX_PROVIDER_ID, + reasoning: true, + input: ["text"], + cost: { input: 999, output: 999, cacheRead: 999, cacheWrite: 999 }, + contextWindow: 128_000, + maxTokens: 64_000, + }; + const registeredProviders = new Map([ + [OPENAI_CODEX_PROVIDER_ID, { models: [existingLunaRow] }], + ]); + const registry = { + registeredProviders, + registerProvider(providerName: string, config: { models: unknown[] }) { + registeredProviders.set(providerName, config); + }, + }; + + mergeSupplementalOpenAiCodexModels(registry); + + const registered = registeredProviders.get(OPENAI_CODEX_PROVIDER_ID) as + typeof SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION; + const lunaRows = registered.models.filter((model) => model.id === GPT_5_6_LUNA_MODEL_ID); + expect(lunaRows).toHaveLength(1); + expect(lunaRows[0].name).toBe("GPT-5.6 Luna (pinned catalog)"); + // sol and terra were still missing, so they must have been added. + const allIds = registered.models.map((model) => model.id); + expect(allIds).toEqual(expect.arrayContaining([GPT_5_6_SOL_MODEL_ID, GPT_5_6_TERRA_MODEL_ID])); + }); + + it("is a no-op when all three ids are already present", () => { + const registeredProviders = new Map(); + const registry = { + registeredProviders, + registerProvider(providerName: string, config: unknown) { + registeredProviders.set(providerName, config); + }, + }; + + mergeSupplementalOpenAiCodexModels(registry); + const afterFirstMerge = JSON.stringify(registeredProviders.get(OPENAI_CODEX_PROVIDER_ID)); + + mergeSupplementalOpenAiCodexModels(registry); + const afterSecondMerge = JSON.stringify(registeredProviders.get(OPENAI_CODEX_PROVIDER_ID)); + + expect(afterSecondMerge).toBe(afterFirstMerge); + }); + + it("falls back to getAll() filtered by provider when registeredProviders state is absent", () => { + const registry = { + registerProvider() { + throw new Error("registerProvider should not be called when all ids already present via getAll()"); + }, + getAll() { + return EXPECTED_IDS.map((id) => ({ id, provider: OPENAI_CODEX_PROVIDER_ID })); + }, + }; + + expect(() => mergeSupplementalOpenAiCodexModels(registry)).not.toThrow(); + }); + + it("never throws when registerProvider throws", () => { + const registry = { + registerProvider() { + throw new Error("boom"); + }, + }; + const warnings: string[] = []; + expect(() => mergeSupplementalOpenAiCodexModels(registry, (message) => warnings.push(message))).not.toThrow(); + expect(warnings[0]).toContain("Failed to merge supplemental openai-codex models"); + }); + + it("never throws when getAll is missing entirely (registry only has registerProvider)", () => { + const registeredProviders = new Map(); + const registry = { + registeredProviders, + registerProvider(providerName: string, config: unknown) { + registeredProviders.set(providerName, config); + }, + }; + + expect(() => mergeSupplementalOpenAiCodexModels(registry)).not.toThrow(); + expect(registeredProviders.has(OPENAI_CODEX_PROVIDER_ID)).toBe(true); + }); +}); diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 528356576a..8f3e61c88e 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -67,6 +67,15 @@ export { mergeSupplementalAnthropicModels, } from "./anthropic-models.js"; export type { AnthropicProviderRegistration } from "./anthropic-models.js"; +export { + OPENAI_CODEX_PROVIDER_ID, + GPT_5_6_LUNA_MODEL_ID, + GPT_5_6_SOL_MODEL_ID, + GPT_5_6_TERRA_MODEL_ID, + SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION, + mergeSupplementalOpenAiCodexModels, +} from "./openai-models.js"; +export type { OpenAiCodexProviderRegistration } from "./openai-models.js"; export { detectImageMimeFromBytes } from "./image-mime.js"; export type { DetectedImageMime } from "./image-mime.js"; export { redactSecrets } from "./redact-secrets.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6e2362d35d..584861dd58 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,15 @@ export { mergeSupplementalAnthropicModels, } from "./anthropic-models.js"; export type { AnthropicProviderRegistration } from "./anthropic-models.js"; +export { + OPENAI_CODEX_PROVIDER_ID, + GPT_5_6_LUNA_MODEL_ID, + GPT_5_6_SOL_MODEL_ID, + GPT_5_6_TERRA_MODEL_ID, + SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION, + mergeSupplementalOpenAiCodexModels, +} from "./openai-models.js"; +export type { OpenAiCodexProviderRegistration } from "./openai-models.js"; export { detectImageMimeFromBytes } from "./image-mime.js"; export type { DetectedImageMime } from "./image-mime.js"; export { redactSecrets } from "./redact-secrets.js"; diff --git a/packages/core/src/openai-models.ts b/packages/core/src/openai-models.ts new file mode 100644 index 0000000000..6755b9bb1d --- /dev/null +++ b/packages/core/src/openai-models.ts @@ -0,0 +1,166 @@ +type OpenAiCodexModelInput = "text" | "image"; + +export const OPENAI_CODEX_PROVIDER_ID = "openai-codex"; +export const GPT_5_6_LUNA_MODEL_ID = "gpt-5.6-luna"; +export const GPT_5_6_SOL_MODEL_ID = "gpt-5.6-sol"; +export const GPT_5_6_TERRA_MODEL_ID = "gpt-5.6-terra"; + +interface OpenAiCodexModelRegistration { + id: string; + name: string; + reasoning: boolean; + input: OpenAiCodexModelInput[]; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; + contextWindow: number; + maxTokens: number; + compat?: Record; +} + +export interface OpenAiCodexProviderRegistration { + name: string; + baseUrl: string; + apiKey?: string; + api: "openai-codex-responses"; + models: OpenAiCodexModelRegistration[]; +} + +/* + * FNXC:ModelCatalog 2026-07-09-12:30: + * FN-7745: FN-7742 already priced the three GPT-5.6 codenamed OpenAI Codex variants + * (gpt-5.6-luna/sol/terra) in model-pricing.ts, but pricing does not make a model + * selectable — the /api/models picker sources rows from the pinned pi-ai + * ModelRegistry.getAvailable() catalog. At spec time (pi-ai 0.80.3) that pinned catalog + * did not carry the three GPT-5.6 codenamed ids under "openai-codex", so no picker + * surfaced them. Mirror the SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION seam + * (anthropic-models.ts) to additively register them: if a later pi-ai bump already + * carries an id (confirmed true as of the pinned 0.80.5 used by this task), the merge + * below is a dedupe-safe no-op — the existing catalog row always wins, never displaced + * or duplicated. Field shape (api/baseUrl) copied verbatim from the pinned catalog's + * openai-codex.models.js entries; apiKey is intentionally omitted because the real + * "openai-codex" provider authenticates via ChatGPT Plus/Pro OAuth, not an env-var API + * key — the merge preserves whatever auth the provider was already registered with + * (see mergeSupplementalOpenAiCodexModels's `...registeredProvider` override below). + */ +export const SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION: OpenAiCodexProviderRegistration = { + name: "OpenAI Codex", + baseUrl: "https://chatgpt.com/backend-api", + api: "openai-codex-responses", + models: [ + { + id: GPT_5_6_LUNA_MODEL_ID, + name: "GPT-5.6 Luna", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 1.25, + }, + contextWindow: 272_000, + maxTokens: 128_000, + }, + { + id: GPT_5_6_SOL_MODEL_ID, + name: "GPT-5.6 Sol", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 1.25, + }, + contextWindow: 272_000, + maxTokens: 128_000, + }, + { + id: GPT_5_6_TERRA_MODEL_ID, + name: "GPT-5.6 Terra", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 1.25, + }, + contextWindow: 272_000, + maxTokens: 128_000, + }, + ], +}; + +type OpenAiCodexModelLike = Partial> & { + id: string; + name?: unknown; + provider?: string; + compat?: unknown; +}; + +interface OpenAiCodexModelRegistryLike { + registerProvider(providerName: string, config: OpenAiCodexProviderRegistration): void; + getAll?: () => OpenAiCodexModelLike[]; +} + +type RegistryWithProviderState = OpenAiCodexModelRegistryLike & { + registeredProviders?: Map>; +}; + +function toOpenAiCodexModelRegistration(model: OpenAiCodexModelLike): OpenAiCodexModelRegistration { + const supplemental = SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION.models.find((entry) => entry.id === model.id); + return { + id: model.id, + name: String(model.name ?? supplemental?.name ?? model.id), + reasoning: model.reasoning ?? supplemental?.reasoning ?? false, + input: Array.isArray(model.input) ? model.input as OpenAiCodexModelInput[] : supplemental?.input ?? ["text"], + cost: model.cost ?? supplemental?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: Number(model.contextWindow ?? supplemental?.contextWindow ?? 0), + maxTokens: Number(model.maxTokens ?? supplemental?.maxTokens ?? 0), + compat: typeof model.compat === "object" && model.compat !== null + ? { ...(model.compat as Record) } + : supplemental?.compat ? { ...supplemental.compat } : undefined, + }; +} + +function cloneOpenAiCodexProviderRegistration(config: OpenAiCodexProviderRegistration): OpenAiCodexProviderRegistration { + return { + ...config, + models: config.models.map((model) => toOpenAiCodexModelRegistration(model)), + }; +} + +export function mergeSupplementalOpenAiCodexModels( + modelRegistry: OpenAiCodexModelRegistryLike, + logWarning: (message: string) => void = () => {}, +): void { + try { + const registryWithState = modelRegistry as RegistryWithProviderState; + const registeredProvider = registryWithState.registeredProviders?.get(OPENAI_CODEX_PROVIDER_ID); + const registeredModels = registeredProvider?.models?.map((model) => toOpenAiCodexModelRegistration(model)) ?? []; + const currentModels = registeredModels.length > 0 + ? registeredModels + : modelRegistry.getAll?.() + .filter((model) => model.provider === OPENAI_CODEX_PROVIDER_ID) + .map((model) => toOpenAiCodexModelRegistration(model)) ?? []; + const currentModelIds = new Set(currentModels.map((model) => model.id)); + const missingModels = SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION.models + .filter((model) => !currentModelIds.has(model.id)); + + if (missingModels.length === 0) return; + + modelRegistry.registerProvider(OPENAI_CODEX_PROVIDER_ID, { + ...cloneOpenAiCodexProviderRegistration(SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION), + ...registeredProvider, + models: [...currentModels, ...missingModels.map((model) => toOpenAiCodexModelRegistration(model))], + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logWarning(`Failed to merge supplemental ${OPENAI_CODEX_PROVIDER_ID} models: ${message}`); + } +} diff --git a/packages/dashboard/src/__tests__/register-model-routes-openai-codex-supplemental.test.ts b/packages/dashboard/src/__tests__/register-model-routes-openai-codex-supplemental.test.ts new file mode 100644 index 0000000000..5de080886d --- /dev/null +++ b/packages/dashboard/src/__tests__/register-model-routes-openai-codex-supplemental.test.ts @@ -0,0 +1,143 @@ +/* +FNXC:ModelCatalog 2026-07-09-12:30: +FN-7745 symptom verification: `/api/models` must surface the three GPT-5.6 codenamed +OpenAI Codex variants (gpt-5.6-luna/sol/terra) under provider "openai-codex" once that +provider is configured, additively and deduped against any pinned-catalog row that +already carries one of the ids — mirroring the mergeSupplementalAnthropicModels seam. +Pre-fix (before mergeSupplementalOpenAiCodexModels was wired into the route), a mocked +registry lacking these ids would never surface them even with openai-codex configured; +this suite encodes that failing-before/passing-after contract plus dedupe and the +configuredProviders allow-list gate. +*/ +import type { Router } from "express"; +import { describe, expect, it, vi } from "vitest"; +import { registerModelRoutes } from "../routes/register-model-routes.js"; + +const GPT_5_6_IDS = ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"]; + +interface FakeOpenAiCodexModel { + id: string; + name: string; + reasoning: boolean; + input?: string[]; + cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; + contextWindow: number; + maxTokens: number; + compat?: unknown; +} + +function createFakeModelRegistry(initialOpenAiCodexModels: FakeOpenAiCodexModel[]) { + const registeredProviders = new Map(); + if (initialOpenAiCodexModels.length > 0) { + registeredProviders.set("openai-codex", { models: initialOpenAiCodexModels }); + } + + return { + refresh: vi.fn(), + registeredProviders, + registerProvider: vi.fn((providerName: string, config: { models: FakeOpenAiCodexModel[] }) => { + registeredProviders.set(providerName, { ...registeredProviders.get(providerName), ...config }); + }), + getAll: vi.fn(() => { + const rows: Array<{ id: string; provider: string }> = []; + for (const [providerName, config] of registeredProviders) { + for (const model of config.models) { + rows.push({ id: model.id, provider: providerName }); + } + } + return rows; + }), + getAvailable: vi.fn(() => { + const rows: Array<{ provider: string; id: string; name: string; reasoning: boolean; contextWindow: number }> = [ + { provider: "openai", id: "gpt-5", name: "GPT-5", reasoning: true, contextWindow: 128000 }, + ]; + for (const [providerName, config] of registeredProviders) { + for (const model of config.models) { + rows.push({ provider: providerName, id: model.id, name: model.name, reasoning: model.reasoning, contextWindow: model.contextWindow }); + } + } + return rows; + }), + }; +} + +function createRouterHarness(modelRegistry: ReturnType, options: { openAiCodexConfigured: boolean }) { + const getHandlers = new Map void }) => Promise>(); + const router = { + get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise) => { + getHandlers.set(path, handler); + }), + } as unknown as Router; + + const store = { + getGlobalSettingsStore: () => ({ getSettings: vi.fn().mockResolvedValue({}) }), + getSettingsFast: vi.fn().mockResolvedValue({}), + }; + + const runtimeLogger = { child: vi.fn(() => ({ warn: vi.fn() })) }; + + const authStorage = { + reload: vi.fn(), + getOAuthProviders: vi.fn(() => (options.openAiCodexConfigured ? [{ id: "openai-codex", name: "OpenAI Codex" }] : [])), + hasAuth: vi.fn((providerId: string) => options.openAiCodexConfigured && providerId === "openai-codex"), + }; + + registerModelRoutes({ + router, + store: store as never, + runtimeLogger: runtimeLogger as never, + options: { modelRegistry, authStorage } as never, + } as never); + + return getHandlers.get("/models")!; +} + +async function callModels(handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise) { + const json = vi.fn(); + await handler({}, { json }); + return json.mock.calls[0][0] as { models: Array<{ provider: string; id: string }> }; +} + +describe("FN-7745: GPT-5.6 codenamed OpenAI Codex variants — /api/models", () => { + it("surfaces all three ids under openai-codex when the pinned catalog lacks them and the provider is configured", async () => { + const modelRegistry = createFakeModelRegistry([]); + const handler = createRouterHarness(modelRegistry, { openAiCodexConfigured: true }); + + // Pre-fix failing assertion: without the merge wired in, none of the ids + // would be present. Post-fix, all three must surface. + const response = await callModels(handler); + const codexIds = response.models.filter((m) => m.provider === "openai-codex").map((m) => m.id); + expect(codexIds).toEqual(expect.arrayContaining(GPT_5_6_IDS)); + }); + + it("does not duplicate an id already present in the pinned catalog — existing row wins", async () => { + const modelRegistry = createFakeModelRegistry([ + { id: "gpt-5.6-luna", name: "GPT-5.6 Luna (pinned catalog)", reasoning: true, contextWindow: 272000, maxTokens: 128000 }, + ]); + const handler = createRouterHarness(modelRegistry, { openAiCodexConfigured: true }); + + const response = await callModels(handler); + const codexRows = response.models.filter((m) => m.provider === "openai-codex"); + const lunaRows = codexRows.filter((m) => m.id === "gpt-5.6-luna"); + + // Exactly one row for the pre-existing id — no duplicate provider/id key. + expect(lunaRows).toHaveLength(1); + expect(lunaRows[0]!.name).toBe("GPT-5.6 Luna (pinned catalog)"); + + // The two still-missing ids must have been additively merged in. + const allCodexIds = codexRows.map((m) => m.id); + expect(allCodexIds).toEqual(expect.arrayContaining(["gpt-5.6-sol", "gpt-5.6-terra"])); + + // No double-listing of any provider/id key anywhere in the response. + const keys = response.models.map((m) => `${m.provider}/${m.id}`); + expect(new Set(keys).size).toBe(keys.length); + }); + + it("does not surface the rows when openai-codex is not among the configured providers", async () => { + const modelRegistry = createFakeModelRegistry([]); + const handler = createRouterHarness(modelRegistry, { openAiCodexConfigured: false }); + + const response = await callModels(handler); + expect(response.models.some((m) => m.provider === "openai-codex")).toBe(false); + }); +}); diff --git a/packages/dashboard/src/routes/register-model-routes.ts b/packages/dashboard/src/routes/register-model-routes.ts index cefc783237..ce89d6a773 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -1,7 +1,7 @@ import { access, readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -import { customProviderRegistryKey, mergeSupplementalAnthropicModels, resolvePlanningSettingsModel } from "@fusion/core"; +import { customProviderRegistryKey, mergeSupplementalAnthropicModels, mergeSupplementalOpenAiCodexModels, resolvePlanningSettingsModel } from "@fusion/core"; import type { CustomProvider } from "@fusion/core"; import { ApiError } from "../api-error.js"; import { getCursorPickerModels, CURSOR_PICKER_PROVIDER_ID } from "../cursor-model-cache.js"; @@ -245,6 +245,14 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { options.modelRegistry.refresh(); if (options.modelRegistry.registerProvider) { mergeSupplementalAnthropicModels(options.modelRegistry as Parameters[0], (message) => runtimeLogger.child("models").warn(message)); + /* + * FNXC:ModelCatalog 2026-07-09-12:30: + * FN-7745: additively merge the GPT-5.6 codenamed OpenAI Codex variants + * (gpt-5.6-luna/sol/terra), mirroring the mergeSupplementalAnthropicModels call + * above. Strictly additive/dedupe-safe — an existing pinned-catalog row for any + * of the three ids always wins, no row is displaced or duplicated. + */ + mergeSupplementalOpenAiCodexModels(options.modelRegistry as unknown as Parameters[0], (message) => runtimeLogger.child("models").warn(message)); } let models = options.modelRegistry.getAvailable().map((m) => ({ provider: m.provider,