From 851582a88a1855b6cb13239ecb1bf992f0dbc109 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 05:01:23 -0300 Subject: [PATCH] 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. --- ...7250-provider-model-filter-live-catalog.md | 1 + .../hooks/useSyncedModelsByProvider.ts | 36 +++++ .../(dashboard)/dashboard/providers/page.tsx | 56 ++++--- .../dashboard/providers/providerPageUtils.ts | 28 +++- ...der-model-filter-live-catalog-7250.test.ts | 138 ++++++++++++++++++ 5 files changed, 238 insertions(+), 21 deletions(-) create mode 100644 changelog.d/fixes/7250-provider-model-filter-live-catalog.md create mode 100644 src/app/(dashboard)/dashboard/providers/hooks/useSyncedModelsByProvider.ts create mode 100644 tests/unit/provider-model-filter-live-catalog-7250.test.ts diff --git a/changelog.d/fixes/7250-provider-model-filter-live-catalog.md b/changelog.d/fixes/7250-provider-model-filter-live-catalog.md new file mode 100644 index 0000000000..b8b3c72266 --- /dev/null +++ b/changelog.d/fixes/7250-provider-model-filter-live-catalog.md @@ -0,0 +1 @@ +- fix(dashboard): providers model-name filter now matches an aggregator's live/synced catalog, not just the static curated registry (#7250) diff --git a/src/app/(dashboard)/dashboard/providers/hooks/useSyncedModelsByProvider.ts b/src/app/(dashboard)/dashboard/providers/hooks/useSyncedModelsByProvider.ts new file mode 100644 index 0000000000..86e89abcee --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/hooks/useSyncedModelsByProvider.ts @@ -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({}); + + 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; +} diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 2393ac6046..f6192498ef 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -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(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({ diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 762a9bb629..3c9edf35f6 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -212,13 +212,35 @@ export function buildCompatibleProviderGroups( return { openai, anthropic, claudeCode }; } +export type LiveModelsByProviderId = Record>; + +/** + * 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( entries: ProviderEntry[], showConfiguredOnly: boolean, searchQuery?: string, showFreeOnly?: boolean, modelSearchQuery?: string, - serviceKindFilter?: string | null + serviceKindFilter?: string | null, + liveModelsByProviderId?: LiveModelsByProviderId ): ProviderEntry[] { let filtered = entries; @@ -261,8 +283,8 @@ export function filterConfiguredProviderEntries( 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)); }); } diff --git a/tests/unit/provider-model-filter-live-catalog-7250.test.ts b/tests/unit/provider-model-filter-live-catalog-7250.test.ts new file mode 100644 index 0000000000..36e11eb327 --- /dev/null +++ b/tests/unit/provider-model-filter-live-catalog-7250.test.ts @@ -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" + ); +});