mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
feat(providers): search connections by name and baseUrl (#12495)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca. O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- **feat(providers):** dashboard search matches connection name and `baseUrl` so imported OpenAI-compat nodes surface on the provider card ([#12108](https://github.com/diegosouzapw/OmniRoute/issues/12108))
|
||||
@@ -4,7 +4,8 @@
|
||||
*
|
||||
* Case-insensitive, plain SUBSTRING match (mirrors the semantics of
|
||||
* `src/shared/utils/modelCatalogSearch.ts` — do not reimplement a fuzzy
|
||||
* matcher here). Matches against id, tag, name, and email.
|
||||
* matcher here). Matches against id, tag, name, email, and
|
||||
* providerSpecificData.baseUrl (#12108).
|
||||
*/
|
||||
import type { ConnectionRowConnection } from "./components/ConnectionRow";
|
||||
|
||||
@@ -17,6 +18,11 @@ function getConnectionTag(conn: ConnectionRowConnection): string {
|
||||
return typeof tag === "string" ? tag : "";
|
||||
}
|
||||
|
||||
function getConnectionBaseUrl(conn: ConnectionRowConnection): string {
|
||||
const baseUrl = conn.providerSpecificData?.baseUrl;
|
||||
return typeof baseUrl === "string" ? baseUrl : "";
|
||||
}
|
||||
|
||||
/** True when `conn` matches `query` (empty/whitespace query always matches). */
|
||||
export function matchesAccountQuery(query: string, conn: ConnectionRowConnection): boolean {
|
||||
const normalizedQuery = normalize(query);
|
||||
@@ -27,6 +33,7 @@ export function matchesAccountQuery(query: string, conn: ConnectionRowConnection
|
||||
normalize(getConnectionTag(conn)),
|
||||
normalize(conn.name),
|
||||
normalize(conn.email),
|
||||
normalize(getConnectionBaseUrl(conn)),
|
||||
];
|
||||
return haystacks.some((haystack) => haystack.includes(normalizedQuery));
|
||||
}
|
||||
|
||||
@@ -558,7 +558,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
|
||||
const rawNoAuthEntriesAll = buildStaticProviderEntries("no-auth", getProviderStats);
|
||||
@@ -576,7 +577,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
|
||||
const apiKeyProviderEntriesAll = buildStaticProviderEntries("apikey", getProviderStats);
|
||||
@@ -595,7 +597,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
const aggregatorProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) =>
|
||||
AGGREGATOR_PROVIDER_IDS.has(entry.providerId)
|
||||
@@ -607,7 +610,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
const imageProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) =>
|
||||
IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId)
|
||||
@@ -619,7 +623,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
const enterpriseProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) =>
|
||||
ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId)
|
||||
@@ -631,7 +636,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
const videoProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) =>
|
||||
VIDEO_PROVIDER_IDS.has(entry.providerId)
|
||||
@@ -643,7 +649,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
const embeddingRerankProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) =>
|
||||
EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)
|
||||
@@ -655,7 +662,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
|
||||
const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats);
|
||||
@@ -666,7 +674,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
|
||||
const localProviderEntriesAll = buildStaticProviderEntries("local", getProviderStats);
|
||||
@@ -677,7 +686,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
|
||||
const searchProviderEntriesAll = buildStaticProviderEntries("search", getProviderStats);
|
||||
@@ -688,7 +698,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
|
||||
const audioProviderEntriesAll = buildStaticProviderEntries("audio", getProviderStats);
|
||||
@@ -699,7 +710,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
|
||||
const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats);
|
||||
@@ -710,7 +722,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
|
||||
const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats);
|
||||
@@ -721,7 +734,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
|
||||
const compatibleProviderEntriesAll = [
|
||||
@@ -754,7 +768,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
|
||||
const staticProviderEntriesAll = dedupeProviderEntries([
|
||||
@@ -780,7 +795,8 @@ function ProvidersPageContent() {
|
||||
undefined,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
|
||||
// IDE providers: subset of oauth/apikey providers that are editors/IDEs with
|
||||
@@ -796,7 +812,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
|
||||
const oauthOnlyEntriesAll = oauthProviderEntriesAll
|
||||
@@ -817,7 +834,8 @@ function ProvidersPageContent() {
|
||||
showFreeOnly,
|
||||
modelSearchQuery,
|
||||
activeServiceKind,
|
||||
liveModelsByProviderId
|
||||
liveModelsByProviderId,
|
||||
connections
|
||||
);
|
||||
|
||||
const compactProviderEntries = buildCompactProviderEntriesForPage({
|
||||
|
||||
@@ -421,6 +421,27 @@ function getFilterableModelsForEntry(
|
||||
return [...staticModels, ...liveModels];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard-card search identity for an imported connection (#12108).
|
||||
* Only `name` and `providerSpecificData.baseUrl` — those are the two
|
||||
* fields the issue asked for. id/tag/email stay on the detail-page
|
||||
* haystack (`matchesAccountQuery`); surfacing a provider card from an
|
||||
* account email would mix account-picker UX into the catalog filter.
|
||||
*/
|
||||
export type ProviderSearchConnection = {
|
||||
provider?: string | null;
|
||||
name?: string | null;
|
||||
providerSpecificData?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function connectionSearchHaystacks(conn: ProviderSearchConnection): string[] {
|
||||
const baseUrl = conn.providerSpecificData?.baseUrl;
|
||||
return [
|
||||
typeof conn.name === "string" ? conn.name : "",
|
||||
typeof baseUrl === "string" ? baseUrl : "",
|
||||
];
|
||||
}
|
||||
|
||||
export function filterConfiguredProviderEntries<TProvider>(
|
||||
entries: ProviderEntry<TProvider>[],
|
||||
showConfiguredOnly: boolean,
|
||||
@@ -428,7 +449,8 @@ export function filterConfiguredProviderEntries<TProvider>(
|
||||
showFreeOnly?: boolean,
|
||||
modelSearchQuery?: string,
|
||||
serviceKindFilter?: string | null,
|
||||
liveModelsByProviderId?: LiveModelsByProviderId
|
||||
liveModelsByProviderId?: LiveModelsByProviderId,
|
||||
connections?: ProviderSearchConnection[]
|
||||
): ProviderEntry<TProvider>[] {
|
||||
let filtered = entries;
|
||||
|
||||
@@ -461,9 +483,26 @@ export function filterConfiguredProviderEntries<TProvider>(
|
||||
if (searchQuery && searchQuery.trim()) {
|
||||
filtered = filtered.filter((entry) => {
|
||||
const provider = entry.provider as Record<string, unknown>;
|
||||
return (
|
||||
if (
|
||||
matchesAnyToken(String(provider.name || ""), searchQuery) ||
|
||||
matchesAnyToken(entry.providerId, searchQuery)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// #12108: imported connections live under the canonical provider card.
|
||||
// Match their operator-visible name / baseUrl so "Grade-S-Node" or an
|
||||
// IP in the search box surfaces the OpenAI card instead of vanishing.
|
||||
// Same matcher as provider.name / providerId above (matchesAnyToken:
|
||||
// full-string first, then whitespace-token OR). The detail page uses
|
||||
// a single-substring haystack — that is a different surface, not a
|
||||
// bug in this filter.
|
||||
if (!connections || connections.length === 0) return false;
|
||||
return connections.some(
|
||||
(conn) =>
|
||||
connectionBelongsToProviderPage(conn.provider, entry.providerId) &&
|
||||
connectionSearchHaystacks(conn).some((haystack) =>
|
||||
matchesAnyToken(haystack, searchQuery)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
92
tests/unit/provider-search-connection-identity-12108.test.ts
Normal file
92
tests/unit/provider-search-connection-identity-12108.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { filterConfiguredProviderEntries } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/providers/providerPageUtils.ts"
|
||||
);
|
||||
|
||||
const ENTRIES = [
|
||||
{
|
||||
providerId: "openai",
|
||||
provider: { id: "openai", name: "OpenAI" },
|
||||
stats: { total: 1 },
|
||||
displayAuthType: "apikey" as const,
|
||||
toggleAuthType: "apikey" as const,
|
||||
},
|
||||
{
|
||||
providerId: "claude",
|
||||
provider: { id: "claude", name: "Claude" },
|
||||
stats: { total: 0 },
|
||||
displayAuthType: "oauth" as const,
|
||||
toggleAuthType: "oauth" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const CONNECTIONS = [
|
||||
{
|
||||
provider: "openai",
|
||||
name: "Grade-S-Node",
|
||||
providerSpecificData: { baseUrl: "http://145.10.20.30:8080" },
|
||||
},
|
||||
];
|
||||
|
||||
function ids(query: string, connections = CONNECTIONS) {
|
||||
return filterConfiguredProviderEntries(
|
||||
ENTRIES,
|
||||
false,
|
||||
query,
|
||||
false,
|
||||
"",
|
||||
null,
|
||||
undefined,
|
||||
connections
|
||||
).map((e) => e.providerId);
|
||||
}
|
||||
|
||||
test("#12108 top-level search matches connection name (imported Grade-S-Node)", () => {
|
||||
assert.deepEqual(ids("Grade-S-Node"), ["openai"]);
|
||||
});
|
||||
|
||||
test("#12108 top-level search matches connection baseUrl host", () => {
|
||||
assert.deepEqual(ids("145.10.20.30"), ["openai"]);
|
||||
});
|
||||
|
||||
test("#12108 top-level search still matches static provider name", () => {
|
||||
assert.deepEqual(ids("claude"), ["claude"]);
|
||||
});
|
||||
|
||||
test("#12108 top-level search without connections does not invent a name match", () => {
|
||||
assert.deepEqual(ids("Grade-S-Node", []), []);
|
||||
const withoutArg = filterConfiguredProviderEntries(ENTRIES, false, "Grade-S-Node").map(
|
||||
(e) => e.providerId
|
||||
);
|
||||
assert.deepEqual(withoutArg, []);
|
||||
});
|
||||
|
||||
test("#12108 empty search still returns every entry", () => {
|
||||
assert.deepEqual(new Set(ids("")), new Set(["openai", "claude"]));
|
||||
});
|
||||
|
||||
test("#12108 a connection on openai does not surface claude", () => {
|
||||
assert.equal(ids("Grade-S-Node").includes("claude"), false);
|
||||
});
|
||||
|
||||
test("#12108 dashboard card search does not match connection email/tag/id", () => {
|
||||
const withAccountFields = [
|
||||
{
|
||||
provider: "openai",
|
||||
name: "Grade-S-Node",
|
||||
id: "conn-grade",
|
||||
email: "ops@grade.example",
|
||||
providerSpecificData: { tag: "prod-east", baseUrl: "http://145.10.20.30:8080" },
|
||||
},
|
||||
];
|
||||
assert.deepEqual(ids("ops@grade.example", withAccountFields), []);
|
||||
assert.deepEqual(ids("prod-east", withAccountFields), []);
|
||||
assert.deepEqual(ids("conn-grade", withAccountFields), []);
|
||||
assert.deepEqual(ids("Grade-S-Node", withAccountFields), ["openai"]);
|
||||
});
|
||||
|
||||
test("#12108 connection haystack uses matchesAnyToken (token OR, same as provider.name)", () => {
|
||||
assert.deepEqual(ids("Grade Node"), ["openai"]);
|
||||
});
|
||||
@@ -35,6 +35,11 @@ const CONNECTIONS: ConnectionRowConnection[] = [
|
||||
{ id: "conn-2", name: "Bob", email: "bob@example.com", providerSpecificData: { tag: "staging" } },
|
||||
{ id: "conn-3", name: "Carol", email: "carol@gmail.com" },
|
||||
{ id: "special-id-9", name: undefined, email: undefined },
|
||||
{
|
||||
id: "conn-grade",
|
||||
name: "Grade-S-Node",
|
||||
providerSpecificData: { tag: "relay", baseUrl: "http://145.10.20.30:8080" },
|
||||
},
|
||||
];
|
||||
|
||||
describe("matchesAccountQuery / filterConnectionsByQuery — #7937", () => {
|
||||
@@ -74,6 +79,20 @@ describe("matchesAccountQuery / filterConnectionsByQuery — #7937", () => {
|
||||
it("does not match a connection missing the queried field", () => {
|
||||
expect(matchesAccountQuery("anything", CONNECTIONS[3])).toBe(false);
|
||||
});
|
||||
|
||||
// #12108 — detail-page search must also match providerSpecificData.baseUrl
|
||||
// (import stores the override there; id/tag/name/email never contain the host).
|
||||
it("matches providerSpecificData.baseUrl by host substring (#12108)", () => {
|
||||
expect(matchesAccountQuery("145.10.20.30", CONNECTIONS[4])).toBe(true);
|
||||
expect(matchesAccountQuery("145.10.20.30", CONNECTIONS[0])).toBe(false);
|
||||
expect(filterConnectionsByQuery("145.10.20.30", CONNECTIONS).map((c) => c.id)).toEqual([
|
||||
"conn-grade",
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches providerSpecificData.baseUrl case-insensitively (#12108)", () => {
|
||||
expect(matchesAccountQuery("HTTP://145.10.20.30:8080", CONNECTIONS[4])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user