+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ />
+ {searchTerm && (
+
+ setSearchTerm("")}>
+
+
+
+ )}
+
{
});
describe("hub tabs", () => {
- const renderHub = async () => {
+ const renderHub = async (agents: object[] = []) => {
vi.mocked(networking.modelHubCall).mockResolvedValue({
data: [{ model_group: "claude-opus-4-8", providers: ["anthropic"], mode: "chat" }],
});
vi.mocked(networking.getConfigFieldSetting).mockResolvedValue({ field_value: false });
- vi.mocked(networking.getAgentsList).mockResolvedValue({ agents: [] });
+ vi.mocked(networking.getAgentsList).mockResolvedValue({ agents });
vi.mocked(networking.fetchMCPServers).mockResolvedValue([]);
vi.mocked(networking.getUiSettings).mockResolvedValue({ values: {} });
mockUseUISettings.mockReturnValue({ data: { values: {} }, isLoading: false });
@@ -230,6 +230,37 @@ describe("ModelHubTable", () => {
expect(await screen.findByPlaceholderText("Search model names...")).toHaveValue("opus");
});
+ it("filters the Agent Hub table by name or description and shows the no-match state", async () => {
+ const { user } = await renderHub([
+ {
+ agent_id: "a1",
+ agent_card_params: { name: "Billing Router", description: "routes billing questions" },
+ litellm_params: { is_public: false },
+ },
+ {
+ agent_id: "a2",
+ agent_card_params: { name: "Support Bot", description: "handles support tickets" },
+ litellm_params: { is_public: false },
+ },
+ ]);
+ const agentCount = (expected: string) =>
+ screen.getByText((_, el) => el?.tagName === "P" && el.textContent === expected);
+
+ await user.click(screen.getByRole("tab", { name: "Agent Hub" }));
+ expect(await screen.findByText("Billing Router")).toBeInTheDocument();
+
+ const search = screen.getByPlaceholderText("Search agent names or descriptions...");
+ await user.type(search, "support tickets");
+ expect(screen.queryByText("Billing Router")).not.toBeInTheDocument();
+ expect(screen.getByText("Support Bot")).toBeInTheDocument();
+ expect(agentCount("Showing 1 of 2 agents")).toBeInTheDocument();
+
+ await user.clear(search);
+ await user.type(search, "zzzz");
+ expect(screen.getByText("No matching agents")).toBeInTheDocument();
+ expect(agentCount("Showing 0 of 2 agents")).toBeInTheDocument();
+ });
+
it("renders the hub strip as underlined tabs rather than a segmented pill", async () => {
await renderHub();
diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx
index 299104b271b..475e3dcd70b 100644
--- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx
+++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx
@@ -23,13 +23,15 @@ import {
import PublicModelHub from "@/components/public_model_hub";
import { copyToClipboard } from "@/utils/dataUtils";
import { isAdminRole, isProxyAdminRole } from "@/utils/roles";
+import { filterBySearchTerm } from "@/utils/searchUtils";
import { SortingState } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Copy, Inbox } from "lucide-react";
+import { Copy, Inbox, Search as SearchIcon, X } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
@@ -80,6 +82,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage,
const [agentLoading, setAgentLoading] = useState(true);
const [selectedAgent, setSelectedAgent] = useState(null);
const [isAgentModalVisible, setIsAgentModalVisible] = useState(false);
+ const [agentSearchTerm, setAgentSearchTerm] = useState("");
// MCP Hub state
const [mcpHubData, setMcpHubData] = useState(null);
const [mcpLoading, setMcpLoading] = useState(true);
@@ -385,6 +388,10 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage,
const modelColumns = useMemo(() => getModelHubTableColumns({ onModelClick: showModal }), [showModal]);
const agentColumns = useMemo(() => getAgentHubTableColumns({ onAgentClick: showAgentModal }), [showAgentModal]);
+ const filteredAgentData = useMemo(
+ () => filterBySearchTerm(agentHubData ?? [], agentSearchTerm, (agent) => [agent.name, agent.description]),
+ [agentHubData, agentSearchTerm],
+ );
const mcpColumns = useMemo(() => getMCPHubTableColumns({ onServerClick: showMcpModal }), [showMcpModal]);
// If this is a public page, use the dedicated PublicModelHub component
@@ -505,9 +512,34 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage,
)}
+
+
Search Agents:
+
+
+
+
+ setAgentSearchTerm(e.target.value)}
+ />
+ {agentSearchTerm && (
+
+ setAgentSearchTerm("")}
+ >
+
+
+
+ )}
+
+
+
{/* Agent Table */}
agent.agent_id || agent.name || String(index)}
sortingMode="client"
@@ -516,7 +548,14 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage,
isLoading={agentLoading}
loadingMessage="Loading agents…"
noDataMessage={
-
+
}
size="compact"
/>
@@ -524,7 +563,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage,
- Showing {agentHubData?.length || 0} agent{agentHubData?.length !== 1 ? "s" : ""}
+ Showing {filteredAgentData.length} of {agentHubData?.length || 0} agents
diff --git a/ui/litellm-dashboard/src/components/model_filters.tsx b/ui/litellm-dashboard/src/components/model_filters.tsx
index 96041905d97..0ce82d6d050 100644
--- a/ui/litellm-dashboard/src/components/model_filters.tsx
+++ b/ui/litellm-dashboard/src/components/model_filters.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo, useRef } from "react";
import { Card } from "@/components/ui/card";
+import { matchesSearchTerm } from "@/utils/searchUtils";
interface ModelGroupInfo {
model_group: string;
@@ -76,7 +77,7 @@ const ModelFilters: React.FC = ({
const filteredData = useMemo(() => {
return (
modelHubData?.filter((model) => {
- const matchesSearch = model.model_group.toLowerCase().includes(searchTerm.toLowerCase());
+ const matchesSearch = matchesSearchTerm(searchTerm, [model.model_group]);
const matchesProvider = selectedProvider === "" || model.providers.includes(selectedProvider);
const matchesMode = selectedMode === "" || model.mode === selectedMode;
diff --git a/ui/litellm-dashboard/src/components/public_model_hub.test.tsx b/ui/litellm-dashboard/src/components/public_model_hub.test.tsx
index 875f89b5adc..fec46e98077 100644
--- a/ui/litellm-dashboard/src/components/public_model_hub.test.tsx
+++ b/ui/litellm-dashboard/src/components/public_model_hub.test.tsx
@@ -134,6 +134,27 @@ describe("PublicModelHub", () => {
expect(within(gpt35Row as HTMLElement).getByText("Unknown")).toBeInTheDocument();
});
});
+ it("shows no models when the search has no matches (LIT-5230 regression)", async () => {
+ const networkingModule = await import("./networking");
+ vi.mocked(networkingModule.modelHubPublicModelsCall).mockResolvedValue([
+ { model_group: "gpt-4", providers: ["openai"], mode: "chat" },
+ { model_group: "claude-3", providers: ["anthropic"], mode: "chat" },
+ ]);
+
+ render();
+ expect(await screen.findByText("gpt-4")).toBeInTheDocument();
+
+ fireEvent.change(screen.getByPlaceholderText("Search model names... (smart search enabled)"), {
+ target: { value: "zzzz" },
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText("gpt-4")).not.toBeInTheDocument();
+ expect(screen.queryByText("claude-3")).not.toBeInTheDocument();
+ expect(screen.getByText("No matching models")).toBeInTheDocument();
+ });
+ });
+
it("handles non-array response gracefully (regression test for e.filter crash)", async () => {
const networkingModule = await import("./networking");
// Mock the API to return an object (like an error response) instead of an array
diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx
index 171b7992325..f6364b5d9d1 100644
--- a/ui/litellm-dashboard/src/components/public_model_hub.tsx
+++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx
@@ -47,6 +47,7 @@ import { generateCodeSnippet } from "@/components/chat_ui/CodeSnippets";
import { getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import { MessageType } from "@/components/chat_ui/types";
import { getProviderLogoAndName } from "./provider_info_helpers";
+import { filterBySearchTerm, rankBySearchRelevance } from "@/utils/searchUtils";
interface PublicModelHubProps {
accessToken?: string | null;
@@ -236,52 +237,11 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded
const filteredData = useMemo(() => {
if (!modelHubData || !Array.isArray(modelHubData)) return [];
- let searchResults = modelHubData;
-
- // Apply search if there's a search term
- if (searchTerm.trim()) {
- const lowercaseSearch = searchTerm.toLowerCase();
- const searchWords = lowercaseSearch.split(/\s+/);
-
- // First, try flexible matching that handles different separators
- const exactMatches = modelHubData.filter((model) => {
- const modelName = model.model_group.toLowerCase();
-
- // Check if it contains the exact search term
- if (modelName.includes(lowercaseSearch)) {
- return true;
- }
-
- // Check if it contains all search words (handles spaces vs slashes/dashes)
- return searchWords.every((word) => modelName.includes(word));
- });
-
- // If we have exact matches, rank them by relevance
- if (exactMatches.length > 0) {
- searchResults = exactMatches.sort((a, b) => {
- const aName = a.model_group.toLowerCase();
- const bName = b.model_group.toLowerCase();
-
- // Calculate relevance scores
- const aExactMatch = aName === lowercaseSearch ? 1000 : 0;
- const bExactMatch = bName === lowercaseSearch ? 1000 : 0;
-
- const aStartsWith = aName.startsWith(lowercaseSearch) ? 100 : 0;
- const bStartsWith = bName.startsWith(lowercaseSearch) ? 100 : 0;
-
- const aContainsWords = lowercaseSearch.split(/\s+/).every((word) => aName.includes(word)) ? 50 : 0;
- const bContainsWords = lowercaseSearch.split(/\s+/).every((word) => bName.includes(word)) ? 50 : 0;
-
- const aLength = aName.length;
- const bLength = bName.length;
-
- const aScore = aExactMatch + aStartsWith + aContainsWords + (1000 - aLength);
- const bScore = bExactMatch + bStartsWith + bContainsWords + (1000 - bLength);
-
- return bScore - aScore; // Higher score first
- });
- }
- }
+ const searchResults = rankBySearchRelevance(
+ filterBySearchTerm(modelHubData, searchTerm, (model) => [model.model_group]),
+ searchTerm,
+ (model) => model.model_group,
+ );
// Apply other filters
return searchResults.filter((model) => {
@@ -310,43 +270,11 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded
const filteredAgentData = useMemo(() => {
if (!agentHubData || !Array.isArray(agentHubData)) return [];
- let searchResults = agentHubData;
-
- // Apply search if there's a search term
- if (agentSearchTerm.trim()) {
- const lowercaseSearch = agentSearchTerm.toLowerCase();
- const searchWords = lowercaseSearch.split(/\s+/);
-
- searchResults = agentHubData.filter((agent) => {
- const agentName = agent.name.toLowerCase();
- const agentDescription = agent.description.toLowerCase();
-
- // Check if it contains the exact search term
- if (agentName.includes(lowercaseSearch) || agentDescription.includes(lowercaseSearch)) {
- return true;
- }
-
- // Check if it contains all search words
- return searchWords.every((word) => agentName.includes(word) || agentDescription.includes(word));
- });
-
- // Sort by relevance
- searchResults = searchResults.sort((a, b) => {
- const aName = a.name.toLowerCase();
- const bName = b.name.toLowerCase();
-
- const aExactMatch = aName === lowercaseSearch ? 1000 : 0;
- const bExactMatch = bName === lowercaseSearch ? 1000 : 0;
-
- const aStartsWith = aName.startsWith(lowercaseSearch) ? 100 : 0;
- const bStartsWith = bName.startsWith(lowercaseSearch) ? 100 : 0;
-
- const aScore = aExactMatch + aStartsWith + (1000 - aName.length);
- const bScore = bExactMatch + bStartsWith + (1000 - bName.length);
-
- return bScore - aScore;
- });
- }
+ const searchResults = rankBySearchRelevance(
+ filterBySearchTerm(agentHubData, agentSearchTerm, (agent) => [agent.name, agent.description]),
+ agentSearchTerm,
+ (agent) => agent.name,
+ );
// Apply skill filters
return searchResults.filter((agent) => {
@@ -361,43 +289,11 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded
const filteredMcpData = useMemo(() => {
if (!mcpHubData || !Array.isArray(mcpHubData)) return [];
- let searchResults = mcpHubData;
-
- // Apply search if there's a search term
- if (mcpSearchTerm.trim()) {
- const lowercaseSearch = mcpSearchTerm.toLowerCase();
- const searchWords = lowercaseSearch.split(/\s+/);
-
- searchResults = mcpHubData.filter((server) => {
- const serverName = server.server_name.toLowerCase();
- const serverDescription = (server.mcp_info?.description || "").toLowerCase();
-
- // Check if it contains the exact search term
- if (serverName.includes(lowercaseSearch) || serverDescription.includes(lowercaseSearch)) {
- return true;
- }
-
- // Check if it contains all search words
- return searchWords.every((word) => serverName.includes(word) || serverDescription.includes(word));
- });
-
- // Sort by relevance
- searchResults = searchResults.sort((a, b) => {
- const aName = a.server_name.toLowerCase();
- const bName = b.server_name.toLowerCase();
-
- const aExactMatch = aName === lowercaseSearch ? 1000 : 0;
- const bExactMatch = bName === lowercaseSearch ? 1000 : 0;
-
- const aStartsWith = aName.startsWith(lowercaseSearch) ? 100 : 0;
- const bStartsWith = bName.startsWith(lowercaseSearch) ? 100 : 0;
-
- const aScore = aExactMatch + aStartsWith + (1000 - aName.length);
- const bScore = bExactMatch + bStartsWith + (1000 - bName.length);
-
- return bScore - aScore;
- });
- }
+ const searchResults = rankBySearchRelevance(
+ filterBySearchTerm(mcpHubData, mcpSearchTerm, (server) => [server.server_name, server.mcp_info?.description]),
+ mcpSearchTerm,
+ (server) => server.server_name,
+ );
// Apply transport filters
return searchResults.filter((server) => {
diff --git a/ui/litellm-dashboard/src/utils/searchUtils.test.ts b/ui/litellm-dashboard/src/utils/searchUtils.test.ts
new file mode 100644
index 00000000000..4935e8ab15d
--- /dev/null
+++ b/ui/litellm-dashboard/src/utils/searchUtils.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+
+import { filterBySearchTerm, matchesSearchTerm, rankBySearchRelevance } from "./searchUtils";
+
+describe("matchesSearchTerm", () => {
+ it("matches everything on an empty or whitespace-only term", () => {
+ expect(matchesSearchTerm("", ["anything"])).toBe(true);
+ expect(matchesSearchTerm(" ", ["anything"])).toBe(true);
+ });
+
+ it("matches a substring of any field, case-insensitively", () => {
+ expect(matchesSearchTerm("BILL", ["Billing Router", "routes invoices"])).toBe(true);
+ expect(matchesSearchTerm("invoice", ["Billing Router", "routes invoices"])).toBe(true);
+ });
+
+ it("matches when every word appears in some field", () => {
+ expect(matchesSearchTerm("router invoices", ["Billing Router", "routes invoices"])).toBe(true);
+ expect(matchesSearchTerm("router refunds", ["Billing Router", "routes invoices"])).toBe(false);
+ });
+
+ it("returns false when nothing matches", () => {
+ expect(matchesSearchTerm("zzzz", ["Billing Router", "routes invoices"])).toBe(false);
+ });
+
+ it("ignores null and undefined fields", () => {
+ expect(matchesSearchTerm("billing", [null, undefined, "Billing Router"])).toBe(true);
+ expect(matchesSearchTerm("billing", [null, undefined])).toBe(false);
+ });
+});
+
+describe("filterBySearchTerm", () => {
+ const agents = [
+ { name: "Billing Router", description: "routes invoices" },
+ { name: "Support Bot", description: "handles tickets" },
+ ];
+
+ it("keeps only items whose fields match", () => {
+ expect(filterBySearchTerm(agents, "tickets", (a) => [a.name, a.description])).toEqual([agents[1]]);
+ });
+
+ it("returns an empty list when nothing matches", () => {
+ expect(filterBySearchTerm(agents, "zzzz", (a) => [a.name, a.description])).toEqual([]);
+ });
+
+ it("returns all items for an empty term", () => {
+ expect(filterBySearchTerm(agents, "", (a) => [a.name, a.description])).toEqual(agents);
+ });
+});
+
+describe("rankBySearchRelevance", () => {
+ it("orders exact match, then prefix match, then shorter names", () => {
+ const items = [{ name: "gpt-4o-mini-transcribe" }, { name: "gpt-4o" }, { name: "chatgpt-4o-latest" }];
+ expect(rankBySearchRelevance(items, "gpt-4o", (m) => m.name).map((m) => m.name)).toEqual([
+ "gpt-4o",
+ "gpt-4o-mini-transcribe",
+ "chatgpt-4o-latest",
+ ]);
+ });
+
+ it("keeps the original order for an empty term", () => {
+ const items = [{ name: "b" }, { name: "a" }];
+ expect(rankBySearchRelevance(items, "", (m) => m.name)).toEqual(items);
+ });
+});
diff --git a/ui/litellm-dashboard/src/utils/searchUtils.ts b/ui/litellm-dashboard/src/utils/searchUtils.ts
new file mode 100644
index 00000000000..b256a5db0c4
--- /dev/null
+++ b/ui/litellm-dashboard/src/utils/searchUtils.ts
@@ -0,0 +1,32 @@
+type SearchField = string | null | undefined;
+
+const normalizeTerm = (term: string): string => term.trim().toLowerCase();
+
+export function matchesSearchTerm(term: string, fields: ReadonlyArray): boolean {
+ const needle = normalizeTerm(term);
+ if (needle === "") return true;
+
+ const haystacks = fields.filter((field): field is string => typeof field === "string").map((f) => f.toLowerCase());
+ if (haystacks.some((haystack) => haystack.includes(needle))) return true;
+
+ return needle.split(/\s+/).every((word) => haystacks.some((haystack) => haystack.includes(word)));
+}
+
+export function filterBySearchTerm(
+ items: ReadonlyArray,
+ term: string,
+ fields: (item: T) => ReadonlyArray,
+): T[] {
+ return items.filter((item) => matchesSearchTerm(term, fields(item)));
+}
+
+export function rankBySearchRelevance(items: ReadonlyArray, term: string, name: (item: T) => string): T[] {
+ const needle = normalizeTerm(term);
+ if (needle === "") return [...items];
+
+ const score = (item: T): number => {
+ const candidate = name(item).toLowerCase();
+ return (candidate === needle ? 1000 : 0) + (candidate.startsWith(needle) ? 100 : 0) + (1000 - candidate.length);
+ };
+ return [...items].sort((a, b) => score(b) - score(a));
+}