From 8fbf48387e0d9e8aa31ab3de10004ce26caf5fe9 Mon Sep 17 00:00:00 2001 From: Rahul Sharma Date: Sun, 21 Jun 2026 19:34:17 +0530 Subject: [PATCH] feat(models): qualify duplicate model names with provider prefix When multiple providers serve the same model (e.g. gh/gpt-5.5, cx/gpt-5.5, opencode-zen/gpt-5.5), the /v1/models catalog emits identical name fields ("GPT-5.5") for every entry. Clients that render the name field -- such as opencode-omniroute-auth plugin (src/plugin.ts: name: model.name || model.id) -- show the same display name for all variants, giving users no way to know which provider they selected. This is a known pain point for OpenCode users. Fix: add disambiguateCatalogModelNames() to modelMetadataRegistry.ts. After enrichment, it scans the catalog for display-name collisions across two or more distinct provider prefixes. For each ambiguous name it rewrites the name to prefix/BaseName (e.g. "gh/GPT-5.5", "cx/GPT-5.5"). Unique names are untouched -- the clean display is preserved for the common case. The change is purely cosmetic: only the catalog name field is affected. Model IDs, routing, and request parsing are unchanged. Tests: 6 new unit tests for disambiguateCatalogModelNames (pure function, no DB needed). All 48 existing catalog + low-noise tests pass. --- src/app/api/v1/models/catalog.ts | 34 ++++---- src/lib/modelMetadataRegistry.ts | 51 ++++++++++++ .../model-catalog-name-disambiguation.test.ts | 80 +++++++++++++++++++ 3 files changed, 145 insertions(+), 20 deletions(-) create mode 100644 tests/unit/model-catalog-name-disambiguation.test.ts diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 418fc54bda62..09e0582659d2 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -34,6 +34,7 @@ import { enrichCatalogModelEntry, getCanonicalModelMetadata, getCatalogDiagnosticsHeaders, + disambiguateCatalogModelNames, } from "@/lib/modelMetadataRegistry"; import { getSyncedCapability } from "@/lib/modelsDevSync"; import { getModelSpec } from "@/shared/constants/modelSpecs"; @@ -743,8 +744,6 @@ export async function getUnifiedModelsResponse( }); } - // Resolve synced available models (from auto-sync) — used to skip static - // PROVIDER_MODELS entries for providers that have a live, API-fresh list. let syncedModelsByProvider: Record = {}; try { syncedModelsByProvider = await getAllSyncedAvailableModels(); @@ -776,8 +775,6 @@ export async function getUnifiedModelsResponse( continue; } - // Skip static models for providers that have synced available models - // (auto-sync provides the authoritative, up-to-date list from the API). if (providersWithSyncedModels.has(canonicalProviderId)) continue; for (const model of providerModels) { @@ -850,8 +847,6 @@ export async function getUnifiedModelsResponse( } try { - // Data already loaded above into syncedModelsByProvider; the try block - // here protects the for-loop / model processing from unexpected errors. for (const [providerId, syncedModels] of Object.entries(syncedModelsByProvider)) { if (!Array.isArray(syncedModels) || syncedModels.length === 0) continue; if (blockedProviders.has(providerId)) continue; @@ -875,8 +870,6 @@ export async function getUnifiedModelsResponse( if (!providerSupportsModel(canonicalProviderId, sm.id)) continue; if (getModelIsHidden(providerId, sm.id)) continue; - // Strip modelIdPrefix (e.g. "accounts/fireworks/models/") from display ID - // so synced model IDs match the short IDs from static registry. const registryEntry = REGISTRY[providerId]; const displayModelId = registryEntry?.modelIdPrefix && sm.id.startsWith(registryEntry.modelIdPrefix) @@ -1423,18 +1416,19 @@ export async function getUnifiedModelsResponse( }; const includeModelNames = isModelCatalogNamesEnabled(); - const enrichedModels = finalModels.map((model) => { - if (model.owned_by === "combo") { - return maybeOmitCatalogModelName(model, includeModelNames); - } - const enriched = enrichCatalogModelEntry(model); - const fallbackContextLength = getDefaultContextFallback(enriched); - const listedModel = fallbackContextLength - ? { ...enriched, context_length: fallbackContextLength } - : enriched; - return maybeOmitCatalogModelName(listedModel, includeModelNames); - }); - + const enrichedModels = disambiguateCatalogModelNames( + finalModels.map((model) => { + if (model.owned_by === "combo") { + return maybeOmitCatalogModelName(model, includeModelNames); + } + const enriched = enrichCatalogModelEntry(model); + const fallbackContextLength = getDefaultContextFallback(enriched); + const listedModel = fallbackContextLength + ? { ...enriched, context_length: fallbackContextLength } + : enriched; + return maybeOmitCatalogModelName(listedModel, includeModelNames); + }) + ); // Codex CLI compatibility: its model-catalog refresh (codex_models_manager) does // GET /v1/models?client_version= and decodes a JSON object with a TOP-LEVEL // `models` array, so the OpenAI-standard `{object,data}` shape makes it fail with diff --git a/src/lib/modelMetadataRegistry.ts b/src/lib/modelMetadataRegistry.ts index ea69ecd478e0..8e9ae8133ccc 100644 --- a/src/lib/modelMetadataRegistry.ts +++ b/src/lib/modelMetadataRegistry.ts @@ -500,3 +500,54 @@ export async function resolveModelAliasLookup( }, }; } + +/** + * Qualify duplicate model display names with a provider prefix so users can + * distinguish between providers that serve the same model. + * + * Problem: when multiple providers (e.g. `gh`, `cx`, `opencode-zen`) all serve + * `gpt-5.5`, each catalog entry gets `name: "GPT-5.5"`. A client like OpenCode + * that renders the `name` field (src/plugin.ts: `name: model.name || model.id`) + * shows an identical "GPT-5.5" for every provider variant, giving the user no + * way to know which provider they are selecting. + * + * Fix: make a single O(n) pass over the enriched catalog. For any base name that + * appears on entries from two or more distinct providers, replace each entry's + * `name` with `/` (e.g. "gh/GPT-5.5", "cx/GPT-5.5"). + * Entries with a unique name are left untouched, preserving the clean display for + * the common single-provider case. The provider prefix is the first segment of + * the model id (e.g. `gh` from `gh/gpt-5.5`). + * + * The modification is purely cosmetic — it only affects the catalog `name` field + * used for display. Model IDs, routing, and request parsing are unchanged. + */ +export function disambiguateCatalogModelNames(models: T[]): T[] { + // Count how many distinct provider prefixes use each display name. + const nameToProviders = new Map>(); + for (const model of models) { + const name = asNonEmptyString(model.name); + if (!name) continue; + const id = asNonEmptyString(model.id); + const prefix = id && id.includes("/") ? id.slice(0, id.indexOf("/")) : null; + if (!prefix) continue; + const existing = nameToProviders.get(name) || new Set(); + existing.add(prefix); + nameToProviders.set(name, existing); + } + + // Only rewrite names that are shared across 2+ providers. + const ambiguous = new Set(); + for (const [name, providers] of nameToProviders) { + if (providers.size > 1) ambiguous.add(name); + } + if (ambiguous.size === 0) return models; + + return models.map((model) => { + const name = asNonEmptyString(model.name); + if (!name || !ambiguous.has(name)) return model; + const id = asNonEmptyString(model.id); + const prefix = id && id.includes("/") ? id.slice(0, id.indexOf("/")) : null; + if (!prefix) return model; + return { ...model, name: `${prefix}/${name}` }; + }); +} diff --git a/tests/unit/model-catalog-name-disambiguation.test.ts b/tests/unit/model-catalog-name-disambiguation.test.ts new file mode 100644 index 000000000000..bc8e495788fa --- /dev/null +++ b/tests/unit/model-catalog-name-disambiguation.test.ts @@ -0,0 +1,80 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Pure function — importable without DB setup. +const { disambiguateCatalogModelNames } = await import("../../src/lib/modelMetadataRegistry.ts"); + +type CatalogEntry = { id: string; name?: string; owned_by?: string; [key: string]: unknown }; + +test("leaves names untouched when each name appears under only one provider", () => { + const models: CatalogEntry[] = [ + { id: "gh/gpt-5.5", name: "GPT-5.5", owned_by: "github" }, + { id: "cc/claude-sonnet-4-6", name: "Claude Sonnet 4.6", owned_by: "claude" }, + { id: "ds-web/deepseek-r2", name: "DeepSeek R2", owned_by: "deepseek-web" }, + ]; + const result = disambiguateCatalogModelNames(models); + assert.equal(result[0].name, "GPT-5.5", "unique name should be unchanged"); + assert.equal(result[1].name, "Claude Sonnet 4.6", "unique name should be unchanged"); + assert.equal(result[2].name, "DeepSeek R2", "unique name should be unchanged"); +}); + +test("qualifies names shared across multiple providers with their prefix", () => { + const models: CatalogEntry[] = [ + { id: "gh/gpt-5.5", name: "GPT-5.5", owned_by: "github" }, + { id: "cx/gpt-5.5", name: "GPT-5.5", owned_by: "codex" }, + { id: "opencode-zen/gpt-5.5", name: "GPT-5.5", owned_by: "opencode-zen" }, + { id: "cc/claude-sonnet-4-6", name: "Claude Sonnet 4.6", owned_by: "claude" }, + ]; + const result = disambiguateCatalogModelNames(models); + assert.equal(result[0].name, "gh/GPT-5.5", "ambiguous name should gain gh/ prefix"); + assert.equal(result[1].name, "cx/GPT-5.5", "ambiguous name should gain cx/ prefix"); + assert.equal( + result[2].name, + "opencode-zen/GPT-5.5", + "ambiguous name should gain opencode-zen/ prefix" + ); + assert.equal(result[3].name, "Claude Sonnet 4.6", "unique name should remain unqualified"); +}); + +test("skips entries with no name field", () => { + const models: CatalogEntry[] = [ + { id: "gh/gpt-5.5", owned_by: "github" }, + { id: "cx/gpt-5.5", owned_by: "codex" }, + ]; + const result = disambiguateCatalogModelNames(models); + assert.equal(result[0].name, undefined, "nameless entry should stay nameless"); + assert.equal(result[1].name, undefined, "nameless entry should stay nameless"); +}); + +test("skips entries with no provider prefix in id", () => { + const models: CatalogEntry[] = [ + { id: "gpt-5.5", name: "GPT-5.5" }, + { id: "gpt-5.5-mini", name: "GPT-5.5" }, + ]; + const result = disambiguateCatalogModelNames(models); + // No prefix extractable → counts as same prefix bucket, treated as same source + // Both names should remain because disambiguation needs 2+ distinct prefixes. + assert.equal(result[0].name, "GPT-5.5"); + assert.equal(result[1].name, "GPT-5.5"); +}); + +test("does not mutate the original model objects", () => { + const original: CatalogEntry = { id: "gh/gpt-5.5", name: "GPT-5.5", owned_by: "github" }; + const dup: CatalogEntry = { id: "cx/gpt-5.5", name: "GPT-5.5", owned_by: "codex" }; + const models = [original, dup]; + disambiguateCatalogModelNames(models); + assert.equal(original.name, "GPT-5.5", "original object must not be mutated"); + assert.equal(dup.name, "GPT-5.5", "original object must not be mutated"); +}); + +test("handles models with no name-conflicts among combo/unprefixed entries gracefully", () => { + const models: CatalogEntry[] = [ + { id: "auto/best", owned_by: "combo" }, + { id: "my-combo", owned_by: "combo" }, + { id: "gh/gpt-4o", name: "GPT-4o", owned_by: "github" }, + { id: "cx/gpt-4o", name: "GPT-4o", owned_by: "codex" }, + ]; + const result = disambiguateCatalogModelNames(models); + assert.equal(result[2].name, "gh/GPT-4o"); + assert.equal(result[3].name, "cx/GPT-4o"); +});