mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
0bd60f3fb2
commit
851582a88a
@@ -0,0 +1 @@
|
||||
- fix(dashboard): providers model-name filter now matches an aggregator's live/synced catalog, not just the static curated registry (#7250)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
138
tests/unit/provider-model-filter-live-catalog-7250.test.ts
Normal file
138
tests/unit/provider-model-filter-live-catalog-7250.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user