fix(models): reconcile provider dashboards with active live catalogs (#13434)

Merged. The NVIDIA 116-vs-82 discrepancy is the visible symptom; the fix is the right one — reuse the existing `liveCatalogAuthoritative` policy on the dashboard listing instead of a provider-specific filter, refresh after a removals-only import, keep the last confirmed snapshot on a failed refresh, and apply the same membership rule to the OpenRouter/compatible/passthrough row builders so static fallbacks cannot resurrect retired rows. Operator custom models and overrides preserved.

Validated as a combined board first (this PR merged with the 4 siblings of the JxnLexn wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 77 passing / 0 failing focused node:test cases across the test files the wave touches. The wave's i18n fill (new keys carried to all 66 locales), free-tier doc counts and file-size rebaseline land in one follow-up PR right after the wave, as with #13904.

Thank you — checking the projection against 14 production catalog snapshots is the kind of evidence that makes a listing change safe to land.
This commit is contained in:
Jan Leon
2026-09-16 21:56:28 +02:00
committed by GitHub
parent bd8a12f304
commit a928ea8762
13 changed files with 306 additions and 38 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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<string, string>;
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,

View File

@@ -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<string, string>;
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,

View File

@@ -49,6 +49,7 @@ export interface ProviderModelsSectionProps {
modelMeta: { customModels: any[]; modelCompatOverrides?: any[] };
modelAliases: Record<string, string>;
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}

View File

@@ -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) => ({

View File

@@ -28,6 +28,7 @@ export interface ModelMeta {
export interface UseProviderModelsReturn {
modelMeta: ModelMeta;
syncedAvailableModels: any[];
syncedCatalogAuthoritative: boolean;
modelAliases: Record<string, string>;
fetchProviderModelMeta: () => Promise<void>;
fetchAliases: () => Promise<void>;
@@ -46,7 +47,14 @@ export function useProviderModels(
customModels: [],
modelCompatOverrides: [],
});
const [syncedAvailableModels, setSyncedAvailableModels] = useState<any[]>([]);
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<Record<string, string>>({});
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,

View File

@@ -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();

View File

@@ -238,7 +238,11 @@ async function loadConnectionCatalog(storedProviderId: string): Promise<Connecti
};
}
export async function getActiveSyncedCatalog(providerId: string): Promise<ActiveSyncedCatalog> {
/** Set includeCustomModels=false for consumers that overlay custom rows separately. */
export async function getActiveSyncedCatalog(
providerId: string,
includeCustomModels = true
): Promise<ActiveSyncedCatalog> {
const storedProviderId = resolveStoredProviderId(providerId);
if (!storedProviderId) {
return { authoritative: false, models: [] };
@@ -249,12 +253,10 @@ export async function getActiveSyncedCatalog(providerId: string): Promise<Active
const siblingCatalogs = await Promise.all(lookupIds.map(loadConnectionCatalog));
// #12866 unions the agy/antigravity sibling catalogs; #12934 then overlays the
// picker-added customModels so dispatch admits the same rows the picker REST shows.
const discovered = unionModels(siblingCatalogs.map((catalog) => 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<Active
authoritative: false,
models: enrichCursorCatalog(
storedProviderId,
await unionCustomModels(storedProviderId, await getSyncedAvailableModels(storedProviderId))
includeCustomModels
? await unionCustomModels(
storedProviderId,
await getSyncedAvailableModels(storedProviderId)
)
: await getSyncedAvailableModels(storedProviderId)
),
};
} catch {

View File

@@ -1,7 +1,7 @@
/**
* Pure merge of registry / synced / custom model rows for the provider detail
* dashboard (and thus Test All targets). Cursor exclusive listing prefers the
* live synced catalog when non-empty.
* dashboard (and thus Test All targets). Server-confirmed authoritative catalogs
* exclude retired static/imported rows while preserving operator-owned models.
*/
import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog";
@@ -18,12 +18,28 @@ export type ProviderListingModel = {
[key: string]: unknown;
};
/** Apply the same membership rule to compatible/passthrough rows, including alias-only rows. */
export function filterUnavailableModelRows<T extends { modelId: string }>(
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));
}

View File

@@ -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);
});

View File

@@ -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"));
});