diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 877c18471..3189c1e73 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -329,9 +329,11 @@ export function mergeCatalogEntriesForSync( multiAgentMode: MultiAgentMode = "default", exactComboSlugs: ReadonlySet = new Set(), hasPhysicalComboProvider = false, + includeNativeOpenAi = true, ): RawEntry[] { const rank = new Map(featured.map((slug, i) => [slug, i] as const)); - const native = catalogModels + const native = includeNativeOpenAi + ? catalogModels .filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.owned_by !== COMBO_NAMESPACE @@ -367,11 +369,14 @@ export function mergeCatalogEntriesForSync( // for subagent max spawns; wire-clamped to the model's real top rung). if (!isGpt56NativeSlug(slug)) ensureUltraReasoningLevel(preserved); return preserved; - }); + }) + : []; // Backfill any native OpenAI slug that the on-disk catalog is missing (e.g. gpt-5.5), so a // routed provider exposing the same id can never delete the native OpenAI/Codex base row. + // Skip when no enabled canonical openai provider exists (#636) — bare gpt-* would 404. const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : [])); + if (includeNativeOpenAi) { for (const slug of nativeOpenAiSlugs()) { if (nativeSlugs.has(slug)) continue; nativeSlugs.add(slug); @@ -382,6 +387,7 @@ export function mergeCatalogEntriesForSync( : 9; native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority)); } + } const freshSlugs = new Set( routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []), @@ -497,7 +503,16 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a // native template can never leak supports_websockets while the flag is off. const wsEnabled = websocketsEnabled(config); - catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider); + const enabledProviders = Object.entries(config.providers ?? {}) + .filter(([, prov]) => prov.disabled !== true); + const hasCanonicalOpenai = enabledProviders.some(([name, prov]) => + name === "openai" && isCanonicalOpenAiForwardProvider(prov), + ); + // #636: when the user only configured non-OpenAI providers (e.g. kimi), do not advertise + // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no + // providers are configured yet (fresh install / catalog bootstrap tests). + const includeNativeOpenAi = enabledProviders.length === 0 || hasCanonicalOpenai; + catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi); clampCatalogModelsToCodexSupport(catalog.models); atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n"); diff --git a/src/router.ts b/src/router.ts index 6dc331f4d..b20ef7f12 100644 --- a/src/router.ts +++ b/src/router.ts @@ -293,7 +293,10 @@ function activeProviderEntries(config: OcxConfig): [string, OcxProviderConfig][] export class NoEnabledOpenAiProviderError extends Error { constructor(modelId: string) { - super(`No enabled OpenAI provider for model: ${modelId}. Run 'ocx init' to configure a provider, or check that your config has an enabled 'openai' provider.`); + super( + `Model ${modelId} requires the canonical openai provider. ` + + `Run: ocx provider add openai && ocx sync && ocx restart`, + ); this.name = "NoEnabledOpenAiProviderError"; } } diff --git a/tests/router.test.ts b/tests/router.test.ts index 82cf6cb5b..ac6fe6d7e 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -142,9 +142,9 @@ describe("routeModel registry effort defaults", () => { expect(routeModel({ ...base, providers: { ...base.providers, openai: { ...forward, codexAccountMode: "direct" } } }, "gpt-5.5")) .toMatchObject({ providerName: "openai", codexAccountMode: "direct" }); expect(() => routeModel({ ...base, providers: { ...base.providers, openai: { ...forward, disabled: true } } }, "gpt-5.5")) - .toThrow(NoEnabledOpenAiProviderError); + .toThrow(/requires the canonical openai provider/); const unavailable = { ...base, providers: { "openai-proxy": base.providers["openai-proxy"] } }; - expect(() => routeModel(unavailable, "gpt-5.5")).toThrow(NoEnabledOpenAiProviderError); + expect(() => routeModel(unavailable, "gpt-5.5")).toThrow(/ocx provider add openai/); }); test("rejects legacy chatgpt namespaces even when configured", () => {