fix(dashboard): providers model-name filter matches live/synced catalog (#7250) (#7561)

Root cause: the Providers page model-name filter
(filterConfiguredProviderEntries) matched only against
getModelsByProviderId(...), the static curated model registry, never
the live/synced catalog for the connection. Aggregator providers
(openrouter, kilocode, theoldllm...) declare a single-entry static
placeholder (e.g. openrouter's {id:'auto',name:'Auto (Best Available)'}),
so searching for any real upstream model name could never match and the
whole provider silently disappeared from the list.

Fix: source the live/synced catalog (already persisted per-connection via
GET /api/synced-available-models, the same store the combo builder's model
picker already relies on) via a new useSyncedModelsByProvider hook, and
union it with the static registry inside the filter. An empty/never-synced
catalog falls back to the static-only match so already-correct static
providers are unaffected.

Regression test: tests/unit/provider-model-filter-live-catalog-7250.test.ts
reproduces the original bug (static-only match returns 0 results for a
real model name) and proves the fix (live catalog match returns 1),
plus non-regression coverage for the static-only fast path and unrelated
providers.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 05:01:23 -03:00
committed by GitHub
parent 0bd60f3fb2
commit 851582a88a
5 changed files with 238 additions and 21 deletions

View File

@@ -0,0 +1 @@
- fix(dashboard): providers model-name filter now matches an aggregator's live/synced catalog, not just the static curated registry (#7250)

View File

@@ -0,0 +1,36 @@
"use client";
import { useEffect, useState } from "react";
import type { LiveModelsByProviderId } from "../providerPageUtils";
/**
* useSyncedModelsByProvider — fetch the live/synced model catalog for every
* provider connection via GET /api/synced-available-models, so the Providers
* page model-name filter can match against real upstream models (not just
* the static curated registry). See #7250: aggregator providers (openrouter,
* kilocode, theoldllm...) declare a single-entry static placeholder, so a
* search for a real model name never matched and silently hid the provider.
*
* Fails soft — a fetch error leaves the map empty, and callers fall back to
* the static registry only.
*/
export function useSyncedModelsByProvider(): LiveModelsByProviderId {
const [models, setModels] = useState<LiveModelsByProviderId>({});
useEffect(() => {
let cancelled = false;
fetch("/api/synced-available-models")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!cancelled && data && typeof data === "object") {
setModels(data as LiveModelsByProviderId);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
return models;
}

View File

@@ -17,6 +17,7 @@ import { pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
import { useSyncedModelsByProvider } from "./hooks/useSyncedModelsByProvider";
import {
buildStaticProviderEntries,
buildCompatibleProviderGroups,
@@ -191,6 +192,7 @@ export default function ProvidersPage() {
const [repairingEnv, setRepairingEnv] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [modelSearchQuery, setModelSearchQuery] = useState("");
const liveModelsByProviderId = useSyncedModelsByProvider();
const [showFreeOnly, setShowFreeOnly] = useState(false);
const [activeCategory, setActiveCategory] = useState<string | null>(null);
// #4240: media-category (serviceKind) filter — composes with activeCategory,
@@ -497,7 +499,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const rawNoAuthEntriesAll = buildStaticProviderEntries("no-auth", getProviderStats);
@@ -514,7 +517,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const apiKeyProviderEntriesAll = buildStaticProviderEntries("apikey", getProviderStats);
@@ -532,7 +536,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const aggregatorProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) =>
AGGREGATOR_PROVIDER_IDS.has(entry.providerId)
@@ -543,7 +548,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const imageProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) =>
IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId)
@@ -554,7 +560,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const enterpriseProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) =>
ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId)
@@ -565,7 +572,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const videoProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) =>
VIDEO_PROVIDER_IDS.has(entry.providerId)
@@ -576,7 +584,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const embeddingRerankProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) =>
EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)
@@ -587,7 +596,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats);
@@ -597,7 +607,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const localProviderEntriesAll = buildStaticProviderEntries("local", getProviderStats);
@@ -607,7 +618,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const searchProviderEntriesAll = buildStaticProviderEntries("search", getProviderStats);
@@ -617,7 +629,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const audioProviderEntriesAll = buildStaticProviderEntries("audio", getProviderStats);
@@ -627,7 +640,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats);
@@ -637,7 +651,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats);
@@ -647,7 +662,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const compatibleProviderEntriesAll = [
@@ -679,7 +695,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const staticProviderEntriesAll = dedupeProviderEntries([
@@ -704,7 +721,8 @@ export default function ProvidersPage() {
searchQuery,
undefined,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
// IDE providers: subset of oauth/apikey providers that are editors/IDEs with
@@ -719,7 +737,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const oauthOnlyEntriesAll = oauthProviderEntriesAll
@@ -739,7 +758,8 @@ export default function ProvidersPage() {
searchQuery,
showFreeOnly,
modelSearchQuery,
activeServiceKind
activeServiceKind,
liveModelsByProviderId
);
const compactProviderEntries = buildCompactProviderEntriesForPage({

View File

@@ -212,13 +212,35 @@ export function buildCompatibleProviderGroups(
return { openai, anthropic, claudeCode };
}
export type LiveModelsByProviderId = Record<string, Array<{ id: string; name?: string }>>;
/**
* Models to match against for the model-name filter: the static curated
* registry PLUS any live/synced catalog for that provider connection (#7250).
* Aggregator providers (openrouter, kilocode, theoldllm...) declare a
* single-entry static placeholder — matching only that entry means a search
* for any real upstream model name can never match, silently hiding the
* provider. When the live catalog is empty/unavailable we fall back to the
* static-only list so already-correct static providers are unaffected.
*/
function getFilterableModelsForEntry(
providerId: string,
liveModelsByProviderId?: LiveModelsByProviderId
): Array<{ id: string; name?: string }> {
const staticModels = getModelsByProviderId(providerId);
const liveModels = liveModelsByProviderId?.[providerId];
if (!liveModels || liveModels.length === 0) return staticModels;
return [...staticModels, ...liveModels];
}
export function filterConfiguredProviderEntries<TProvider>(
entries: ProviderEntry<TProvider>[],
showConfiguredOnly: boolean,
searchQuery?: string,
showFreeOnly?: boolean,
modelSearchQuery?: string,
serviceKindFilter?: string | null
serviceKindFilter?: string | null,
liveModelsByProviderId?: LiveModelsByProviderId
): ProviderEntry<TProvider>[] {
let filtered = entries;
@@ -261,8 +283,8 @@ export function filterConfiguredProviderEntries<TProvider>(
if (modelSearchQuery && modelSearchQuery.trim()) {
const q = modelSearchQuery.trim();
filtered = filtered.filter((entry) => {
const models = getModelsByProviderId(entry.providerId);
return models.some((m) => matchesSearch(m.id, q) || matchesSearch(m.name, q));
const models = getFilterableModelsForEntry(entry.providerId, liveModelsByProviderId);
return models.some((m) => matchesSearch(m.id, q) || matchesSearch(m.name || "", q));
});
}

View File

@@ -0,0 +1,138 @@
import test from "node:test";
import assert from "node:assert/strict";
const providerPageUtils =
await import("../../src/app/(dashboard)/dashboard/providers/providerPageUtils.ts");
// #7250: the Providers page model-name filter only matched against the static
// curated model registry (getModelsByProviderId), never against a provider's
// live/synced catalog. Aggregator providers (openrouter, kilocode,
// theoldllm...) declare a single-entry static placeholder
// (`{ id: "auto", name: "Auto (Best Available)" }` for openrouter), so a
// search for any real upstream model name — e.g. "laguna" — could never
// match, and the whole provider silently disappeared from the list.
function makeOpenRouterEntry() {
return {
providerId: "openrouter",
provider: { name: "OpenRouter" },
stats: { total: 1 },
displayAuthType: "apikey" as const,
toggleAuthType: "apikey" as const,
};
}
test("#7250: model filter still finds openrouter by its static 'auto' model id (non-regression)", () => {
const entries = [makeOpenRouterEntry()];
const filtered = providerPageUtils.filterConfiguredProviderEntries(
entries,
false,
undefined,
undefined,
"auto"
);
assert.equal(
filtered.length,
1,
"static registry match must keep working when no live catalog is supplied"
);
});
test("#7250: model filter hides openrouter for a real upstream model name when only the static catalog is available (documents the bug's shape)", () => {
const entries = [makeOpenRouterEntry()];
const filtered = providerPageUtils.filterConfiguredProviderEntries(
entries,
false,
undefined,
undefined,
"laguna"
);
assert.equal(
filtered.length,
0,
"with no live catalog supplied, a real model name cannot match the single-entry static registry"
);
});
test("#7250: model filter matches a real upstream model name when the live/synced catalog is supplied", () => {
const entries = [makeOpenRouterEntry()];
const liveModelsByProviderId = {
openrouter: [
{ id: "meta-llama/llama-3.1-laguna", name: "Llama 3.1 Laguna" },
{ id: "anthropic/claude-3.5-sonnet", name: "Claude 3.5 Sonnet" },
],
};
const filtered = providerPageUtils.filterConfiguredProviderEntries(
entries,
false,
undefined,
undefined,
"laguna",
undefined,
liveModelsByProviderId
);
assert.equal(
filtered.length,
1,
"openrouter must be found once its live catalog is consulted, not just the static placeholder"
);
assert.equal(filtered[0].providerId, "openrouter");
});
test("#7250: an empty live catalog entry falls back to the static registry instead of excluding the provider", () => {
const entries = [makeOpenRouterEntry()];
const liveModelsByProviderId = { openrouter: [] };
const filtered = providerPageUtils.filterConfiguredProviderEntries(
entries,
false,
undefined,
undefined,
"auto",
undefined,
liveModelsByProviderId
);
assert.equal(
filtered.length,
1,
"an empty/never-synced live catalog must not regress the static-only match"
);
});
test("#7250: providers with a fully static catalog are unaffected by an unrelated live catalog map", () => {
const entries = [
{
providerId: "minimax",
provider: { name: "MiniMax" },
stats: { total: 1 },
displayAuthType: "apikey" as const,
toggleAuthType: "apikey" as const,
},
];
const liveModelsByProviderId = {
openrouter: [{ id: "meta-llama/llama-3.1-laguna", name: "Llama 3.1 Laguna" }],
};
const filtered = providerPageUtils.filterConfiguredProviderEntries(
entries,
false,
undefined,
undefined,
"minimax-m3",
undefined,
liveModelsByProviderId
);
assert.equal(
filtered.length,
1,
"minimax's own static-catalog match must be unaffected by an unrelated provider's live catalog"
);
});