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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 13:48:53 -07:00
parent 0e90578122
commit f9307904c2
8 changed files with 479 additions and 1 deletions

View File

@@ -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.

View File

@@ -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. 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 ### Planning model
1. Per-task `planningModelProvider` + `planningModelId` 1. Per-task `planningModelProvider` + `planningModelId`

View File

@@ -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<string, unknown>();
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<string, { models: unknown[] }>([
[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<string, unknown>();
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<string, unknown>();
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);
});
});

View File

@@ -67,6 +67,15 @@ export {
mergeSupplementalAnthropicModels, mergeSupplementalAnthropicModels,
} from "./anthropic-models.js"; } from "./anthropic-models.js";
export type { AnthropicProviderRegistration } 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 { detectImageMimeFromBytes } from "./image-mime.js";
export type { DetectedImageMime } from "./image-mime.js"; export type { DetectedImageMime } from "./image-mime.js";
export { redactSecrets } from "./redact-secrets.js"; export { redactSecrets } from "./redact-secrets.js";

View File

@@ -23,6 +23,15 @@ export {
mergeSupplementalAnthropicModels, mergeSupplementalAnthropicModels,
} from "./anthropic-models.js"; } from "./anthropic-models.js";
export type { AnthropicProviderRegistration } 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 { detectImageMimeFromBytes } from "./image-mime.js";
export type { DetectedImageMime } from "./image-mime.js"; export type { DetectedImageMime } from "./image-mime.js";
export { redactSecrets } from "./redact-secrets.js"; export { redactSecrets } from "./redact-secrets.js";

View File

@@ -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<string, unknown>;
}
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<Omit<OpenAiCodexModelRegistration, "name" | "compat">> & {
id: string;
name?: unknown;
provider?: string;
compat?: unknown;
};
interface OpenAiCodexModelRegistryLike {
registerProvider(providerName: string, config: OpenAiCodexProviderRegistration): void;
getAll?: () => OpenAiCodexModelLike[];
}
type RegistryWithProviderState = OpenAiCodexModelRegistryLike & {
registeredProviders?: Map<string, Partial<OpenAiCodexProviderRegistration>>;
};
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<string, unknown>) }
: 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}`);
}
}

View File

@@ -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<string, { name?: string; baseUrl?: string; api?: string; apiKey?: string; models: FakeOpenAiCodexModel[] }>();
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<typeof createFakeModelRegistry>, options: { openAiCodexConfigured: boolean }) {
const getHandlers = new Map<string, (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>>();
const router = {
get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>) => {
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<void>) {
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);
});
});

View File

@@ -1,7 +1,7 @@
import { access, readFile } from "node:fs/promises"; import { access, readFile } from "node:fs/promises";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; 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 type { CustomProvider } from "@fusion/core";
import { ApiError } from "../api-error.js"; import { ApiError } from "../api-error.js";
import { getCursorPickerModels, CURSOR_PICKER_PROVIDER_ID } from "../cursor-model-cache.js"; import { getCursorPickerModels, CURSOR_PICKER_PROVIDER_ID } from "../cursor-model-cache.js";
@@ -245,6 +245,14 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
options.modelRegistry.refresh(); options.modelRegistry.refresh();
if (options.modelRegistry.registerProvider) { if (options.modelRegistry.registerProvider) {
mergeSupplementalAnthropicModels(options.modelRegistry as Parameters<typeof mergeSupplementalAnthropicModels>[0], (message) => runtimeLogger.child("models").warn(message)); mergeSupplementalAnthropicModels(options.modelRegistry as Parameters<typeof mergeSupplementalAnthropicModels>[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<typeof mergeSupplementalOpenAiCodexModels>[0], (message) => runtimeLogger.child("models").warn(message));
} }
let models = options.modelRegistry.getAvailable().map((m) => ({ let models = options.modelRegistry.getAvailable().map((m) => ({
provider: m.provider, provider: m.provider,