diff --git a/changelog.d/fixes/6975-6957-combo-builder-models.md b/changelog.d/fixes/6975-6957-combo-builder-models.md new file mode 100644 index 0000000000..46f0154967 --- /dev/null +++ b/changelog.d/fixes/6975-6957-combo-builder-models.md @@ -0,0 +1,2 @@ +- **fix(combos):** embeddings-only and rerank-only models (e.g. JinaAI, Gemini auto-imported, OpenRouter custom, reranker models) no longer disappear from the combo builder's model picker — the leftover chat-only `isChatCapable` gate in `addModelOption()` has been removed (#6975). +- **fix(combos):** when 2+ distinct model ids from the same provider would render an identical display name in the combo builder picker (e.g. Mistral's `codestral-latest`/`codestral-2508` aliases sharing one upstream catalog name), each colliding entry now falls back to its own id as the display label so every row stays visually distinguishable and findable (#6957). diff --git a/src/lib/combos/builderOptions.ts b/src/lib/combos/builderOptions.ts index 4ce26daa93..5b2ac572ac 100644 --- a/src/lib/combos/builderOptions.ts +++ b/src/lib/combos/builderOptions.ts @@ -155,11 +155,6 @@ function toStringArray(value: unknown): string[] | undefined { return normalized.length > 0 ? normalized : undefined; } -function isChatCapable(supportedEndpoints: string[] | undefined): boolean { - if (!supportedEndpoints || supportedEndpoints.length === 0) return true; - return supportedEndpoints.includes("chat"); -} - function getSourcePriority(source: BuilderModelSource): number { switch (source) { case "imported": @@ -305,7 +300,6 @@ function addModelOption( const modelId = toStringOrNull(input.id); if (!modelId) return; if (getModelIsHidden(providerId, modelId)) return; - if (!isChatCapable(input.supportedEndpoints)) return; const nextSourcePriority = getSourcePriority(input.source); const existing = modelMap.get(modelId); @@ -449,9 +443,36 @@ function buildModelOptions( } } + disambiguateCollidingModelNames(modelMap); return modelMap; } +/** + * #6957: some providers' own catalogs assign the identical display `name` to + * several distinct model ids (e.g. Mistral's "codestral-latest" alias renders + * under the same upstream name as its base "codestral-2508" model). Since the + * builder picker renders `model.name` as the visible option text, two colliding + * names make genuinely different models look like duplicates and hide aliases. + * Run this after all merge loops have populated `modelMap`: for any name shared + * by 2+ distinct ids, fall back every entry in that group to its own `id` as the + * display name (display-only — `id`/`qualifiedModel` used for routing untouched). + */ +function disambiguateCollidingModelNames(modelMap: Map): void { + const idsByName = new Map(); + for (const option of modelMap.values()) { + const bucket = idsByName.get(option.name) || []; + bucket.push(option.id); + idsByName.set(option.name, bucket); + } + for (const [name, ids] of idsByName) { + if (ids.length < 2) continue; + for (const id of ids) { + const option = modelMap.get(id); + if (option && option.name === name) option.name = option.id; + } + } +} + function compareConnections( left: ComboBuilderConnectionOption, right: ComboBuilderConnectionOption diff --git a/tests/unit/combo-builder-options-route.test.ts b/tests/unit/combo-builder-options-route.test.ts index 704e4009f4..9d05767e14 100644 --- a/tests/unit/combo-builder-options-route.test.ts +++ b/tests/unit/combo-builder-options-route.test.ts @@ -103,10 +103,12 @@ test("combo builder options route aggregates providers, connections, models and }); await modelsDb.addCustomModel("openai", "custom-ops", "Custom Ops"); + // #6975: embeddings-only models (supportedEndpoints without "chat") are no longer + // dropped from the combo builder — they must appear like any other model. await modelsDb.addCustomModel( "openai", - "text-embedding-hidden", - "Hidden Embedding", + "text-embedding-visible", + "Text Embedding", "manual", "chat-completions", ["embeddings"] @@ -148,9 +150,10 @@ test("combo builder options route aggregates providers, connections, models and false ); assert.ok(openai.models.some((model) => model.id === "custom-ops")); + // #6975: embeddings-only models must now appear in the combo builder output. assert.equal( - openai.models.some((model) => model.id === "text-embedding-hidden"), - false + openai.models.some((model) => model.id === "text-embedding-visible"), + true ); assert.deepEqual( openai.connections.map((connection) => ({ diff --git a/tests/unit/repro-6957.test.ts b/tests/unit/repro-6957.test.ts new file mode 100644 index 0000000000..696dfada3d --- /dev/null +++ b/tests/unit/repro-6957.test.ts @@ -0,0 +1,130 @@ +/** + * Repro for #6957 — combo builder "2. Model" dropdown shows visually duplicated + * "imported" rows and appears to be missing the "-latest" aliases for a native + * Mistral provider with 2 API-key connections. + * + * Root cause (confirmed against the reporter's actual `GET /api/combos/builder/options` + * payload, issue #6957): there is NO literal `model.id` collision — every synced + * model id is already unique after `buildModelOptions()`/`getAllSyncedAvailableModels()` + * dedup. The bug is that `ComboBuilderModelOption.name` (the text rendered in the + * `