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:
Bob.Hou
2026-09-03 11:38:16 -04:00
committed by GitHub
parent c9fb06e26c
commit 0f5fc78d8a
6 changed files with 197 additions and 21 deletions

View File

@@ -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))

View File

@@ -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));
}

View File

@@ -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({

View File

@@ -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)
)
);
});
}

View 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"]);
});

View File

@@ -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);
});
});
// ---------------------------------------------------------------------------