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.
|
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`
|
||||||
|
|||||||
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,
|
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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
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 { 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user