Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 14 additions & 20 deletions src/app/api/v1/models/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
enrichCatalogModelEntry,
getCanonicalModelMetadata,
getCatalogDiagnosticsHeaders,
disambiguateCatalogModelNames,
} from "@/lib/modelMetadataRegistry";
import { getSyncedCapability } from "@/lib/modelsDevSync";
import { getModelSpec } from "@/shared/constants/modelSpecs";
Expand Down Expand Up @@ -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<string, SyncedAvailableModel[]> = {};
try {
syncedModelsByProvider = await getAllSyncedAvailableModels();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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=<v> and decodes a JSON object with a TOP-LEVEL
// `models` array, so the OpenAI-standard `{object,data}` shape makes it fail with
Expand Down
51 changes: 51 additions & 0 deletions src/lib/modelMetadataRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<providerPrefix>/<baseName>` (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<T extends JsonRecord>(models: T[]): T[] {
// Count how many distinct provider prefixes use each display name.
const nameToProviders = new Map<string, Set<string>>();
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<string>();
existing.add(prefix);
nameToProviders.set(name, existing);
}

// Only rewrite names that are shared across 2+ providers.
const ambiguous = new Set<string>();
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}` };
});
}
80 changes: 80 additions & 0 deletions tests/unit/model-catalog-name-disambiguation.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading