From c5565d055cff7342f92c21e565addb513b18bc25 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Tue, 4 Aug 2026 04:13:58 +0800 Subject: [PATCH] fix(catalog): custom model rows inherit provider reasoning metadata --- src/codex/catalog/provider-fetch.ts | 30 +++++++++++++--- tests/codex-catalog.test.ts | 56 +++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 5422e1db5..71bc27227 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -782,6 +782,9 @@ async function gatherRoutedModelsUncached( // Enriched (registry-hydrated) provider clones, keyed by name — the same view used above so // custom rows get the same noVisionModels / inputModalities treatment as discovered rows. const enrichedByName = new Map(activeProviders); + // Provider-derived rows keyed by their Codex-facing slug: a custom override replaces the row + // with the same slug below, so that row's provider capability metadata is the inheritance source. + const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model])); const customModels = (config.customModels ?? []).map(cm => { const rawProvider = config.providers[cm.provider]; const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId); @@ -794,19 +797,38 @@ async function gatherRoutedModelsUncached( ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), }; + // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that + // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls, + // context, ...) so the generated catalog keeps advertising what the router actually provides. + // Explicit custom fields win by construction; this only fills gaps. Without it a + // noReasoningModels model loses its empty ladder and the catalog synthesizes the generic one, + // which Codex then rejects for spawn_agent with effort "none". + const replaced = replacedByRoutedSlug.get(routedSlug(cm.provider, cm.modelId)); + const merged: CatalogModel = replaced ? { + ...base, + ...(base.contextWindow === undefined && replaced.contextWindow !== undefined ? { contextWindow: replaced.contextWindow } : {}), + ...(base.maxInputTokens === undefined && replaced.maxInputTokens !== undefined ? { maxInputTokens: replaced.maxInputTokens } : {}), + ...(base.inputModalities === undefined && replaced.inputModalities !== undefined ? { inputModalities: replaced.inputModalities } : {}), + ...(base.reasoningEfforts === undefined && replaced.reasoningEfforts !== undefined ? { reasoningEfforts: replaced.reasoningEfforts } : {}), + ...(base.defaultReasoningEffort === undefined && replaced.defaultReasoningEffort !== undefined ? { defaultReasoningEffort: replaced.defaultReasoningEffort } : {}), + ...(base.parallelToolCalls === undefined && replaced.parallelToolCalls !== undefined ? { parallelToolCalls: replaced.parallelToolCalls } : {}), + ...(base.supportsVerbosity === undefined && replaced.supportsVerbosity !== undefined ? { supportsVerbosity: replaced.supportsVerbosity } : {}), + ...(base.supportsReasoningSummaries === undefined && replaced.supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: replaced.supportsReasoningSummaries } : {}), + ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}), + } : base; // Vision-sidecar coverage ONLY: if the custom model is in the enriched provider's // noVisionModels, advertise image input so the Codex app lets images reach the sidecar // (#349/#344). Deliberately NOT the full applyProviderConfigHints pass — custom rows are a // user override, so their explicit contextWindow / inputModalities / reasoning fields must be // preserved verbatim (the hint pass would cap context and overwrite modalities from registry). const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider; - if (enrichedProvider && modelInList(enrichedProvider.noVisionModels, base.id)) { - const current = base.inputModalities ?? ["text"]; + if (enrichedProvider && modelInList(enrichedProvider.noVisionModels, merged.id)) { + const current = merged.inputModalities ?? ["text"]; if (!current.includes("image")) { - return { ...base, inputModalities: [...current, "image"] }; + return { ...merged, inputModalities: [...current, "image"] }; } } - return base; + return merged; }); // Custom rows override discovered rows that encode to the same Codex-facing slug. const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id))); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 3a00105a6..2debabd4d 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -848,6 +848,62 @@ describe("configured CatalogModel displayName -> catalog display_name", () => { }); }); +test("a custom row inherits provider reasoning metadata from the provider-derived row it replaces (#962)", async () => { + clearModelCache("ollama"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { throw new Error("fetch should not be called"); }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "ollama", + providers: { + ollama: { + baseUrl: "http://localhost:11434/v1", + adapter: "openai-chat", + authMode: "key", + liveModels: false, + models: ["qwen-coder-3b"], + selectedModels: ["qwen-coder-3b"], + noReasoningModels: ["qwen-coder-3b"], + modelReasoningEfforts: { "qwen-coder-3b": [] }, + }, + }, + customModels: [ + { + id: "cm-962", + provider: "ollama", + modelId: "qwen-coder-3b", + displayName: "Qwen Coder 3B (local)", + contextWindow: 32768, + inputModalities: ["text"], + addedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + + // Explicit custom fields stay verbatim; provider capability metadata is inherited from the + // replaced provider-derived row (noReasoningModels -> empty reasoning ladder, openai-chat + // adapter -> parallel tool calls). + const custom = models.find(m => m.provider === "ollama" && m.id === "qwen-coder-3b"); + expect(custom?.displayName).toBe("Qwen Coder 3B (local)"); + expect(custom?.contextWindow).toBe(32768); + expect(custom?.inputModalities).toEqual(["text"]); + expect(custom?.reasoningEfforts).toEqual([]); + expect(custom?.parallelToolCalls).toBe(true); + + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const row = entries.find(e => e.slug === "ollama/qwen-coder-3b"); + expect(row?.display_name).toBe("Qwen Coder 3B (local)"); + // The catalog must expose no reasoning levels and no default reasoning level for this model; + // the generic low..ultra ladder and the medium default must not be synthesized. + expect(row?.supported_reasoning_levels).toEqual([]); + expect(row?.default_reasoning_level).toBeUndefined(); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("ollama"); + } +}); + function openAiApiCatalogConfig(overrides: Record = {}): OcxConfig { return { port: 10100,