From 0f5fc78d8acbaa6d178b1e2d22e7e8badcedafa6 Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Thu, 3 Sep 2026 11:38:16 -0400 Subject: [PATCH] feat(providers): search connections by name and baseUrl (#12495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../12108-provider-search-name-baseurl.md | 1 + .../providers/[id]/connectionsSearchFilter.ts | 9 +- .../(dashboard)/dashboard/providers/page.tsx | 54 +++++++---- .../dashboard/providers/providerPageUtils.ts | 43 ++++++++- ...r-search-connection-identity-12108.test.ts | 92 +++++++++++++++++++ .../unit/ui/connectionsSearchFilter.test.tsx | 19 ++++ 6 files changed, 197 insertions(+), 21 deletions(-) create mode 100644 changelog.d/features/12108-provider-search-name-baseurl.md create mode 100644 tests/unit/provider-search-connection-identity-12108.test.ts diff --git a/changelog.d/features/12108-provider-search-name-baseurl.md b/changelog.d/features/12108-provider-search-name-baseurl.md new file mode 100644 index 0000000000..a13f2a12a1 --- /dev/null +++ b/changelog.d/features/12108-provider-search-name-baseurl.md @@ -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)) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/connectionsSearchFilter.ts b/src/app/(dashboard)/dashboard/providers/[id]/connectionsSearchFilter.ts index 8c676c74be..6c7209b1aa 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/connectionsSearchFilter.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/connectionsSearchFilter.ts @@ -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)); } diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index a65478dd2a..a9cde7c6f7 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -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({ diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 8c7be96c71..8356a1f9e5 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -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 | 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( entries: ProviderEntry[], showConfiguredOnly: boolean, @@ -428,7 +449,8 @@ export function filterConfiguredProviderEntries( showFreeOnly?: boolean, modelSearchQuery?: string, serviceKindFilter?: string | null, - liveModelsByProviderId?: LiveModelsByProviderId + liveModelsByProviderId?: LiveModelsByProviderId, + connections?: ProviderSearchConnection[] ): ProviderEntry[] { let filtered = entries; @@ -461,9 +483,26 @@ export function filterConfiguredProviderEntries( if (searchQuery && searchQuery.trim()) { filtered = filtered.filter((entry) => { const provider = entry.provider as Record; - 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) + ) ); }); } diff --git a/tests/unit/provider-search-connection-identity-12108.test.ts b/tests/unit/provider-search-connection-identity-12108.test.ts new file mode 100644 index 0000000000..e6d8d5a556 --- /dev/null +++ b/tests/unit/provider-search-connection-identity-12108.test.ts @@ -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"]); +}); diff --git a/tests/unit/ui/connectionsSearchFilter.test.tsx b/tests/unit/ui/connectionsSearchFilter.test.tsx index 6885c1a91c..3b2e9ab7ce 100644 --- a/tests/unit/ui/connectionsSearchFilter.test.tsx +++ b/tests/unit/ui/connectionsSearchFilter.test.tsx @@ -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); + }); }); // ---------------------------------------------------------------------------