diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 5422e1db5..e35b68b59 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -780,26 +780,26 @@ async function gatherRoutedModelsUncached( replaceLastComboCatalogOmissions(localOmissions); all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider))); // 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. + // custom rows get the same capability hints as discovered rows. const enrichedByName = new Map(activeProviders); const customModels = (config.customModels ?? []).map(cm => { const rawProvider = config.providers[cm.provider]; - const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId); + const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider; + const providerCap = cm.contextWindow === undefined ? providerContextCap(config, cm.provider) : undefined; + const hints = enrichedProvider + ? catalogHintsFromProviderConfig(cm.provider, enrichedProvider, cm.modelId, providerCap) + : {}; const base: CatalogModel = { + ...hints, id: cm.modelId, provider: cm.provider, // Display-only label: never feeds routing (customModels are keyed by routedSlug below). ...(cm.displayName ? { displayName: cm.displayName } : {}), ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}), ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}), - ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), }; - // 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; + // Explicit custom modalities win over hints, but noVisionModels still needs to append image + // so the Codex app permits attachments to reach the proxy's vision sidecar. if (enrichedProvider && modelInList(enrichedProvider.noVisionModels, base.id)) { const current = base.inputModalities ?? ["text"]; if (!current.includes("image")) { diff --git a/tests/catalog-custom-model-metadata.test.ts b/tests/catalog-custom-model-metadata.test.ts new file mode 100644 index 000000000..e20207110 --- /dev/null +++ b/tests/catalog-custom-model-metadata.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; +import { buildCatalogEntries, gatherRoutedModels } from "../src/codex/catalog"; + +test("custom rows retain declared metadata while inheriting provider capability hints", async () => { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "ollama", + providers: { + ollama: { + adapter: "openai-chat", + baseUrl: "http://127.0.0.1:11434/v1", + liveModels: false, + models: ["qwen-coder-3b"], + modelContextWindows: { "qwen-coder-3b": 32_768 }, + modelInputModalities: { "qwen-coder-3b": ["text", "image"] }, + modelReasoningEfforts: { "qwen-coder-3b": [] }, + }, + }, + customModels: [{ + id: "qwen-coder-3b-custom", + provider: "ollama", + modelId: "qwen-coder-3b", + displayName: "Qwen Coder 3B", + contextWindow: 65_536, + inputModalities: ["text"], + addedAt: "2026-08-03T00:00:00.000Z", + }], + }); + + const custom = models.filter(model => model.provider === "ollama" && model.id === "qwen-coder-3b"); + expect(custom).toHaveLength(1); + expect(custom[0]).toMatchObject({ + displayName: "Qwen Coder 3B", + contextWindow: 65_536, + inputModalities: ["text"], + reasoningEfforts: [], + }); + + const entry = buildCatalogEntries(null, [], models).find(candidate => candidate.slug === "ollama/qwen-coder-3b"); + expect(entry?.supported_reasoning_levels).toEqual([]); + expect(entry).not.toHaveProperty("default_reasoning_level"); +}); diff --git a/tests/catalog-vision-sidecar-modalities.test.ts b/tests/catalog-vision-sidecar-modalities.test.ts index 27de9d504..1d9408978 100644 --- a/tests/catalog-vision-sidecar-modalities.test.ts +++ b/tests/catalog-vision-sidecar-modalities.test.ts @@ -56,10 +56,7 @@ describe("vision-sidecar custom-model override (#349/#344)", () => { // vision sidecar could run. The image augmentation must come from the REGISTRY-enriched clone, // so we deliberately do NOT set noVisionModels on the persisted provider — opencode-go's // registry entry classifies glm-5.2 as text-only, and enrichment must supply it. - const originalFetch = globalThis.fetch; - globalThis.fetch = (() => { throw new Error("fetch should not be called"); }) as typeof fetch; - try { - const models = await gatherRoutedModels({ + const models = await gatherRoutedModels({ port: 10100, defaultProvider: "opencode-go", providers: { @@ -74,20 +71,14 @@ describe("vision-sidecar custom-model override (#349/#344)", () => { customModels: [ { id: "cm-1", provider: "opencode-go", modelId: "glm-5.2", displayName: "GLM 5.2", addedAt: "2026-01-01T00:00:00.000Z" }, ], - }); - const custom = models.find(m => m.provider === "opencode-go" && m.id === "glm-5.2"); - expect(custom).toBeDefined(); - expect(custom?.inputModalities).toEqual(["text", "image"]); - } finally { - globalThis.fetch = originalFetch; - } + }); + const custom = models.find(m => m.provider === "opencode-go" && m.id === "glm-5.2"); + expect(custom).toBeDefined(); + expect(custom?.inputModalities).toEqual(["text", "image"]); }); test("a custom row NOT in noVisionModels keeps its declared modalities untouched", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (() => { throw new Error("fetch should not be called"); }) as typeof fetch; - try { - const models = await gatherRoutedModels({ + const models = await gatherRoutedModels({ port: 10100, defaultProvider: "opencode-go", providers: { @@ -103,22 +94,13 @@ describe("vision-sidecar custom-model override (#349/#344)", () => { customModels: [ { id: "cm-2", provider: "opencode-go", modelId: "kimi-text", inputModalities: ["text"], addedAt: "2026-01-01T00:00:00.000Z" }, ], - }); - const custom = models.find(m => m.provider === "opencode-go" && m.id === "kimi-text"); - expect(custom?.inputModalities).toEqual(["text"]); - } finally { - globalThis.fetch = originalFetch; - } + }); + const custom = models.find(m => m.provider === "opencode-go" && m.id === "kimi-text"); + expect(custom?.inputModalities).toEqual(["text"]); }); - test("the image augmentation does NOT overwrite a custom row's explicit context/modalities/reasoning", async () => { - // The augmentation must be narrow: for a noVisionModels custom row we only ADD image; every - // other explicitly configured custom field (contextWindow, extra modalities) stays verbatim, - // and no registry reasoning metadata leaks onto the user override. - const originalFetch = globalThis.fetch; - globalThis.fetch = (() => { throw new Error("fetch should not be called"); }) as typeof fetch; - try { - const models = await gatherRoutedModels({ + test("the image augmentation preserves explicit custom fields and canonical provider reasoning", async () => { + const models = await gatherRoutedModels({ port: 10100, defaultProvider: "opencode-go", providers: { @@ -133,17 +115,14 @@ describe("vision-sidecar custom-model override (#349/#344)", () => { customModels: [ { id: "cm-3", provider: "opencode-go", modelId: "glm-5.2", contextWindow: 2_000_000, inputModalities: ["text", "video"], addedAt: "2026-01-01T00:00:00.000Z" }, ], - }); - const custom = models.find(m => m.provider === "opencode-go" && m.id === "glm-5.2"); - // image is appended to the user's declared modalities (not replaced), context is preserved, - // and no registry reasoning fields were injected onto the custom override. - expect(custom?.inputModalities).toEqual(["text", "video", "image"]); - expect(custom?.contextWindow).toBe(2_000_000); - expect(custom?.reasoningEfforts).toBeUndefined(); - expect(custom?.defaultReasoningEffort).toBeUndefined(); - } finally { - globalThis.fetch = originalFetch; - } + }); + const custom = models.find(m => m.provider === "opencode-go" && m.id === "glm-5.2"); + // image is appended to the user's declared modalities (not replaced), custom context is + // preserved, and the registry-enriched provider supplies its canonical reasoning metadata. + expect(custom?.inputModalities).toEqual(["text", "video", "image"]); + expect(custom?.contextWindow).toBe(2_000_000); + expect(custom?.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(custom?.defaultReasoningEffort).toBeUndefined(); }); });