diff --git a/changelog.d/fixes/13434-authoritative-provider-listing.md b/changelog.d/fixes/13434-authoritative-provider-listing.md new file mode 100644 index 0000000000..ee15d53801 --- /dev/null +++ b/changelog.d/fixes/13434-authoritative-provider-listing.md @@ -0,0 +1 @@ +- **fix(models):** Reconcile provider dashboards with confirmed authoritative live catalogs, excluding retired built-in/imported rows while preserving manual custom models and partial-catalog fallbacks. ([#13434](https://github.com/diegosouzapw/OmniRoute/pull/13434)) — thanks @JxnLexn diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 5b4c52f0b9..2cba5d6daf 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -916,11 +916,6 @@ "count": 1 } }, - "src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts": { "@typescript-eslint/no-unused-vars": { "count": 2 diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 20b36fbe91..55779730ce 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -192,6 +192,7 @@ export default function ProviderDetailPageClient() { const { modelMeta, syncedAvailableModels, + syncedCatalogAuthoritative, modelAliases, fetchProviderModelMeta, fetchAliases, @@ -286,14 +287,14 @@ export default function ProviderDetailPageClient() { NOAUTH_PROVIDERS[providerId]?.noAuth === true || getProviderById(providerId)?.managedAccount === true; const registryModels = getModelsByProviderId(providerId); - // Prefer synced API-discovered models when available, then merge built-ins - // and user-managed custom models without duplicating IDs. Cursor exclusive - // listing drops the static registry entirely when synced is non-empty. + // Use the server's active-catalog authority decision for display and Test All. + // Registry entries supply metadata/fallback; operator custom models remain. const models = useMemo(() => { return mergeProviderModelListing({ providerId, registryModels, syncedModels: syncedAvailableModels, + syncedCatalogAuthoritative, customModels: (modelMeta.customModels || []).map((cm) => ({ ...cm, id: cm.id, @@ -307,6 +308,7 @@ export default function ProviderDetailPageClient() { registryModels, syncedAvailableModels, modelMeta.customModels, + syncedCatalogAuthoritative, usesCuratedModelsOnly, ]); const isUpstreamProxyProvider = providerInfo?.category === "upstream-proxy"; @@ -770,6 +772,7 @@ export default function ProviderDetailPageClient() { modelMeta={modelMeta} modelAliases={modelAliases} syncedAvailableModels={syncedAvailableModels} + syncedCatalogAuthoritative={syncedCatalogAuthoritative} compatibleFallbackModels={compatibleFallbackModels} copied={copied} onCopy={copy} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx index 096dc23584..a5e92cae7d 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx @@ -1,4 +1,5 @@ "use client"; +import { filterUnavailableModelRows } from "@/lib/providers/mergeProviderModelListing"; /** * CompatibleModelsSection — Issue #3501 Phase 1e * @@ -42,6 +43,7 @@ export interface CompatibleModelsSectionProps { providerDisplayAlias: string; modelAliases: Record; availableModels?: CompatModelRow[]; + syncedCatalogAuthoritative?: boolean; customModels?: CompatModelRow[]; fallbackModels?: CompatModelRow[]; allowImport: boolean; @@ -86,6 +88,7 @@ export default function CompatibleModelsSection({ providerDisplayAlias, modelAliases, availableModels = [], + syncedCatalogAuthoritative = false, customModels = [], fallbackModels = [], description, @@ -223,9 +226,15 @@ export default function CompatibleModelsSection({ seenModelIds.add(modelId); } - return rows; + return filterUnavailableModelRows( + rows, + availableModels, + customModels, + syncedCatalogAuthoritative + ); }, [ availableModels, + syncedCatalogAuthoritative, customModelMap, customModels, fallbackModels, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx index 94ca769c7b..7197b19b6c 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx @@ -1,4 +1,5 @@ "use client"; +import { filterUnavailableModelRows } from "@/lib/providers/mergeProviderModelListing"; /** * PassthroughModelsSection — Issue #3501 Phase 1e * @@ -51,6 +52,7 @@ export interface PassthroughModelsSectionProps { modelAliases: Record; catalogModels?: CompatModelRow[]; availableModels?: CompatModelRow[]; + syncedCatalogAuthoritative?: boolean; customModels?: CompatModelRow[]; description: string; inputLabel: string; @@ -96,6 +98,7 @@ export default function PassthroughModelsSection({ modelAliases, catalogModels = [], availableModels = [], + syncedCatalogAuthoritative = false, customModels = [], description, inputLabel, @@ -314,9 +317,15 @@ export default function PassthroughModelsSection({ seenModelIds.add(modelId); } - return rows; + return filterUnavailableModelRows( + rows, + availableModels, + customModels, + syncedCatalogAuthoritative + ); }, [ availableModels, + syncedCatalogAuthoritative, catalogModels, customModelMap, customModels, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx index 0914b269a6..d20cc96892 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx @@ -49,6 +49,7 @@ export interface ProviderModelsSectionProps { modelMeta: { customModels: any[]; modelCompatOverrides?: any[] }; modelAliases: Record; syncedAvailableModels: any[]; + syncedCatalogAuthoritative?: boolean; compatibleFallbackModels: any[]; // Clipboard @@ -130,6 +131,7 @@ export default function ProviderModelsSection({ modelMeta, modelAliases, syncedAvailableModels, + syncedCatalogAuthoritative = false, compatibleFallbackModels, copied, onCopy, @@ -268,6 +270,7 @@ export default function ProviderModelsSection({ providerDisplayAlias={providerDisplayAlias} modelAliases={modelAliases} availableModels={syncedAvailableModels} + syncedCatalogAuthoritative={syncedCatalogAuthoritative} customModels={modelMeta.customModels} fallbackModels={compatibleFallbackModels} description={description} @@ -348,6 +351,7 @@ export default function ProviderModelsSection({ providerAlias={providerAlias} modelAliases={modelAliases} catalogModels={models} + syncedCatalogAuthoritative={syncedCatalogAuthoritative} availableModels={syncedAvailableModels} customModels={modelMeta.customModels} description={passthroughDescription} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts index 88121833bc..9aaf8533fc 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts @@ -150,6 +150,9 @@ export function useModelImportHandlers({ return; } const fetchedModels = data.models || []; + // Discovery persists its result even when no new models need importing. + // Refresh the active listing so removals take effect without a page reload. + await fetchProviderModelMeta(); const importWarning = extractImportWarning(data); if (fetchedModels.length === 0) { setImportProgress((prev) => ({ @@ -306,6 +309,8 @@ export function useModelImportHandlers({ if (!response.ok) { throw new Error(data.error || t("failedImportModels")); } + await fetchProviderModelMeta(); + await fetchAliases(); if (data.freeFilterEmpty) { setImportProgress((prev) => ({ diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts index 05d0221fcd..a513c4aa76 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts @@ -28,6 +28,7 @@ export interface ModelMeta { export interface UseProviderModelsReturn { modelMeta: ModelMeta; syncedAvailableModels: any[]; + syncedCatalogAuthoritative: boolean; modelAliases: Record; fetchProviderModelMeta: () => Promise; fetchAliases: () => Promise; @@ -46,7 +47,14 @@ export function useProviderModels( customModels: [], modelCompatOverrides: [], }); - const [syncedAvailableModels, setSyncedAvailableModels] = useState([]); + const [syncedCatalog, setSyncedCatalog] = useState({ + providerId: "", + models: [] as any[], + authoritative: false, + }); + const syncedAvailableModels = syncedCatalog.providerId === providerId ? syncedCatalog.models : []; + const syncedCatalogAuthoritative = + syncedCatalog.providerId === providerId && syncedCatalog.authoritative; const [modelAliases, setModelAliases] = useState>({}); const fetchAliases = useCallback(async () => { @@ -133,12 +141,16 @@ export function useProviderModels( ); if (syncRes.ok) { const syncData = await syncRes.json(); - setSyncedAvailableModels(syncData.models || []); - } else { - setSyncedAvailableModels([]); + if (Array.isArray(syncData.models)) { + setSyncedCatalog({ + providerId, + models: syncData.models, + authoritative: syncData.authoritative === true, + }); + } } } catch { - setSyncedAvailableModels([]); + // A transient dashboard request failure must not resurrect retired static models. } } catch (e) { console.error("fetchProviderModelMeta", e); @@ -148,6 +160,7 @@ export function useProviderModels( return { modelMeta, syncedAvailableModels, + syncedCatalogAuthoritative, modelAliases, fetchProviderModelMeta, fetchAliases, diff --git a/src/app/api/synced-available-models/route.ts b/src/app/api/synced-available-models/route.ts index 1dd9686548..e208e16ef5 100644 --- a/src/app/api/synced-available-models/route.ts +++ b/src/app/api/synced-available-models/route.ts @@ -1,4 +1,5 @@ -import { getSyncedAvailableModels, getAllSyncedAvailableModels } from "@/lib/db/models"; +import { getAllSyncedAvailableModels } from "@/lib/db/models"; +import { getActiveSyncedCatalog } from "@/lib/db/models/activeSyncedCatalog"; import { isAuthenticated } from "@/shared/utils/apiAuth"; /** @@ -18,8 +19,10 @@ export async function GET(request: Request) { const provider = searchParams.get("provider"); if (provider) { - const models = await getSyncedAvailableModels(provider); - return Response.json({ models }); + // The dashboard merges operator-owned custom rows separately. Do not let + // legacy imports act as evidence that a model still exists upstream. + const catalog = await getActiveSyncedCatalog(provider, false); + return Response.json(catalog); } const allModels = await getAllSyncedAvailableModels(); diff --git a/src/lib/db/models/activeSyncedCatalog.ts b/src/lib/db/models/activeSyncedCatalog.ts index 2fafaf9b2d..2b1118ab56 100644 --- a/src/lib/db/models/activeSyncedCatalog.ts +++ b/src/lib/db/models/activeSyncedCatalog.ts @@ -238,7 +238,11 @@ async function loadConnectionCatalog(storedProviderId: string): Promise { +/** Set includeCustomModels=false for consumers that overlay custom rows separately. */ +export async function getActiveSyncedCatalog( + providerId: string, + includeCustomModels = true +): Promise { const storedProviderId = resolveStoredProviderId(providerId); if (!storedProviderId) { return { authoritative: false, models: [] }; @@ -249,12 +253,10 @@ export async function getActiveSyncedCatalog(providerId: string): Promise catalog.models)); const models = enrichCursorCatalog( storedProviderId, - await unionCustomModels( - storedProviderId, - unionModels(siblingCatalogs.map((catalog) => catalog.models)) - ) + includeCustomModels ? await unionCustomModels(storedProviderId, discovered) : discovered ); if (models.length > 0) { // #12849: only gate on this catalog while at least one sibling connection @@ -283,7 +285,12 @@ export async function getActiveSyncedCatalog(providerId: string): Promise( + rows: T[], + syncedModels: Array<{ id?: string }>, + customModels: Array<{ id?: string; source?: string }>, + authoritative: boolean +): T[] { + if (!authoritative) return rows; + const allowed = new Set([ + ...syncedModels.map((model) => model.id), + ...customModels.filter((model) => model.source !== "imported").map((model) => model.id), + ]); + return rows.filter((row) => allowed.has(row.modelId)); +} + export type MergeProviderModelListingInput = { providerId: string; registryModels: Array<{ id: string; name?: string }>; syncedModels: Array<{ id: string; name?: string; [key: string]: unknown }>; customModels: Array<{ id: string; name?: string; source?: string; [key: string]: unknown }>; usesCuratedModelsOnly?: boolean; + syncedCatalogAuthoritative?: boolean; }; function normalizeCustomSource(source: unknown): "imported" | "custom" { @@ -46,24 +62,33 @@ export function mergeProviderModelListing( const synced = curated ? [] : input.syncedModels.filter((m) => m?.id); const custom = curated ? [] : input.customModels.filter((m) => m?.id); - const exclusive = providerUsesExclusiveSyncedListing(input.providerId) && synced.length > 0; + const exclusive = + !curated && + (input.syncedCatalogAuthoritative ?? + (providerUsesExclusiveSyncedListing(input.providerId) && synced.length > 0)); if (exclusive) { - const withAuto = ensureCursorAutoCatalogEntry( - synced.map((model) => ({ - ...model, - id: model.id, - name: model.name || model.id, - owned_by: "cursor", - source: "imported", - })) - ); - const normalizedCustom = custom.map((model) => ({ + const cursor = providerUsesExclusiveSyncedListing(input.providerId); + const registryById = new Map(input.registryModels.map((model) => [model.id, model])); + const liveModels = synced.map((model) => ({ + ...(registryById.get(model.id) || {}), ...model, id: model.id, name: model.name || model.id, - source: normalizeCustomSource(model.source), + owned_by: cursor ? "cursor" : input.providerId, + source: "imported", })); + const withAuto = + cursor && liveModels.length > 0 ? ensureCursorAutoCatalogEntry(liveModels) : liveModels; + const liveIds = new Set(withAuto.map((model) => model.id)); + const normalizedCustom = custom + .filter((model) => model.source !== "imported" || liveIds.has(model.id)) + .map((model) => ({ + ...model, + id: model.id, + name: model.name || model.id, + source: normalizeCustomSource(model.source), + })); return dedupeById(mergeModelsWithCustomPrecedence(withAuto, normalizedCustom)); } diff --git a/tests/unit/active-catalog-display-snapshot.test.ts b/tests/unit/active-catalog-display-snapshot.test.ts new file mode 100644 index 0000000000..74c440082b --- /dev/null +++ b/tests/unit/active-catalog-display-snapshot.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const directory = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-display-catalog-")); +process.env.DATA_DIR = directory; +process.env.API_KEY_SECRET = "display-catalog-test-secret"; +const core = await import("../../src/lib/db/core.ts"); +const { replaceSyncedAvailableModelsForConnection } = await import("../../src/lib/db/models.ts"); +const { getActiveSyncedCatalog } = await import("../../src/lib/db/models/activeSyncedCatalog.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(directory, { recursive: true, force: true }); +}); + +test("display snapshot unions active connections, excludes inactive catalogs and custom/import overlays", async () => { + const db = core.getDbInstance(); + const now = new Date().toISOString(); + for (const [id, active] of [ + ["active-a", 1], + ["active-b", 1], + ["inactive", 0], + ] as const) { + db.prepare( + "INSERT INTO provider_connections (id,provider,is_active,created_at,updated_at) VALUES (?,?,?,?,?)" + ).run(id, "nvidia", active, now, now); + await replaceSyncedAvailableModelsForConnection("nvidia", id, [ + { id: `${id}-model`, name: id }, + ]); + } + db.prepare("INSERT OR REPLACE INTO key_value (namespace,key,value) VALUES (?,?,?)").run( + "customModels", + "nvidia", + JSON.stringify([ + { id: "manual", source: "custom" }, + { id: "old-import", source: "imported" }, + ]) + ); + const catalog = await getActiveSyncedCatalog("nvidia", false); + assert.equal(catalog.authoritative, true); + assert.deepEqual(catalog.models.map((m) => m.id).sort(), ["active-a-model", "active-b-model"]); +}); + +test("custom rows alone cannot establish an authoritative discovery snapshot", async () => { + core + .getDbInstance() + .prepare("INSERT OR REPLACE INTO key_value (namespace,key,value) VALUES (?,?,?)") + .run("customModels", "openai", JSON.stringify([{ id: "manual", source: "custom" }])); + const catalog = await getActiveSyncedCatalog("openai", false); + assert.equal(catalog.authoritative, false); + assert.equal(catalog.models.length, 0); +}); diff --git a/tests/unit/authoritative-provider-listing.test.ts b/tests/unit/authoritative-provider-listing.test.ts new file mode 100644 index 0000000000..e41a573bba --- /dev/null +++ b/tests/unit/authoritative-provider-listing.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + mergeProviderModelListing, + filterUnavailableModelRows, +} from "../../src/lib/providers/mergeProviderModelListing.ts"; + +test("OpenRouter and compatible/passthrough aliases cannot resurrect unavailable models", () => { + const rows = ["live", "retired-fallback", "retired-alias", "manual", "old-import"].map( + (modelId) => ({ modelId }) + ); + const live = [{ id: "live" }]; + const custom = [ + { id: "manual", source: "custom" }, + { id: "old-import", source: "imported" }, + ]; + assert.deepEqual( + filterUnavailableModelRows(rows, live, custom, true).map((row) => row.modelId), + ["live", "manual"] + ); + assert.deepEqual(filterUnavailableModelRows(rows, live, custom, false), rows); +}); + +const input = { + providerId: "nvidia", + registryModels: [ + { id: "retired", name: "Retired model" }, + { id: "live", name: "Live model", supportsVision: true }, + ], + syncedModels: [{ id: "live", contextWindow: 128000 }], + customModels: [ + { id: "retired-import", source: "imported" }, + { id: "manual", name: "My endpoint", source: "custom" }, + { id: "live", name: "My live model", supportsVision: false }, + ], +}; + +test("authoritative catalog removes retired built-ins and legacy imports, retaining manual overlays", () => { + const models = mergeProviderModelListing({ ...input, syncedCatalogAuthoritative: true }); + assert.deepEqual( + models.map((m) => m.id), + ["live", "manual"] + ); + assert.equal(models[0].contextWindow, 128000); + assert.equal(models[0].supportsVision, false); + assert.equal(models[0].name, "My live model"); + assert.equal( + models.some((m) => m.id.startsWith("auto")), + false + ); +}); + +test("partial or unconfirmed catalogs retain static coverage", () => { + for (const flag of [false, undefined]) { + const ids = mergeProviderModelListing({ ...input, syncedCatalogAuthoritative: flag }).map( + (m) => m.id + ); + assert.ok(ids.includes("retired")); + assert.ok(ids.includes("retired-import")); + assert.ok(ids.includes("manual")); + } +}); + +test("a new successful catalog removes only models absent from the active union", () => { + const ids = mergeProviderModelListing({ + ...input, + syncedCatalogAuthoritative: true, + syncedModels: [{ id: "other-connection" }], + customModels: [{ id: "manual", source: "custom" }], + }).map((m) => m.id); + assert.deepEqual(ids, ["other-connection", "manual"]); +}); + +test("confirmed empty catalog preserves custom models without inventing Cursor auto rows", () => { + const ids = mergeProviderModelListing({ + ...input, + providerId: "cursor", + syncedCatalogAuthoritative: true, + syncedModels: [], + customModels: [{ id: "manual", source: "custom" }], + }).map((m) => m.id); + assert.deepEqual(ids, ["manual"]); +}); + +test("explicit non-authoritative state overrides Cursor's legacy implicit exclusivity", () => { + const ids = mergeProviderModelListing({ + ...input, + providerId: "cursor", + syncedCatalogAuthoritative: false, + }).map((m) => m.id); + assert.ok(ids.includes("retired")); +}); + +test("authoritative membership and manual metadata precedence are provider-independent", () => { + for (const providerId of ["nvidia", "openai", "openrouter", "anthropic", "vertex"]) { + const snapshot = JSON.stringify(input); + const models = mergeProviderModelListing({ + ...input, + providerId, + syncedCatalogAuthoritative: true, + }); + assert.deepEqual( + models.map((m) => m.id), + ["live", "manual"], + providerId + ); + assert.equal(models[0].owned_by, providerId); + assert.equal(models[0].contextWindow, 128000); + assert.equal(models[0].supportsVision, false); + assert.equal(JSON.stringify(input), snapshot, "must not mutate inputs"); + } +}); + +test("curated-only catalogs cannot be emptied or extended by a live-catalog flag", () => { + for (const providerId of ["kimi-web", "zai-web", "chatgpt-web"]) { + const models = mergeProviderModelListing({ + ...input, + providerId, + syncedCatalogAuthoritative: true, + }); + assert.deepEqual( + models.map((m) => m.id), + ["retired", "live"] + ); + assert.ok(models.every((m) => m.source === "system")); + } +}); + +test("Cursor legacy listing retains its synthetic auto entry without affecting other providers", () => { + const models = mergeProviderModelListing({ + ...input, + providerId: "cursor", + customModels: [], + syncedModels: [{ id: "live" }], + }); + assert.ok(models.some((m) => m.id === "auto")); + assert.ok(models.some((m) => m.id === "live")); + assert.ok(!models.some((m) => m.id === "retired")); +});