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:
7
.changeset/fn-7745-gpt-5-6-codenamed-model-selector.md
Normal file
7
.changeset/fn-7745-gpt-5-6-codenamed-model-selector.md
Normal 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.
|
||||
@@ -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`
|
||||
|
||||
134
packages/core/src/__tests__/openai-models.test.ts
Normal file
134
packages/core/src/__tests__/openai-models.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
166
packages/core/src/openai-models.ts
Normal file
166
packages/core/src/openai-models.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<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) => ({
|
||||
provider: m.provider,
|
||||
|
||||
Reference in New Issue
Block a user