diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.integration.test.tsx new file mode 100644 index 00000000000..f02f928dcbe --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.integration.test.tsx @@ -0,0 +1,74 @@ +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, testQueryClient } from "@/../tests/test-utils"; +import CreateSearchTool from "./CreateSearchTools"; + +vi.mock("@/components/networking", () => ({ + createSearchTool: vi.fn(), + fetchAvailableSearchProviders: vi.fn(async () => ({ + providers: [ + { provider_name: "perplexity", ui_friendly_name: "Perplexity AI" }, + { provider_name: "searxng", ui_friendly_name: "SearXNG" }, + ], + })), +})); + +const renderModal = () => + renderWithProviders( + , + ); + +const openProviderDropdown = async (user: ReturnType) => { + await user.click(await screen.findByRole("combobox")); + return screen.getByRole("listbox").parentElement as HTMLElement; +}; + +describe("CreateSearchTool provider dropdown", () => { + beforeEach(() => { + testQueryClient.clear(); + }); + + it("keeps a provider listed when its slug is typed into the search box", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + renderModal(); + + const dropdown = await openProviderDropdown(user); + expect(await within(dropdown).findByTitle("SearXNG")).toBeInTheDocument(); + + await user.type(screen.getByRole("combobox"), "searxng"); + + expect(within(dropdown).getByTitle("SearXNG")).toBeInTheDocument(); + expect(within(dropdown).queryByTitle("Perplexity AI")).not.toBeInTheDocument(); + }); + + it("keeps a provider listed when its display name is typed into the search box", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + renderModal(); + + const dropdown = await openProviderDropdown(user); + await within(dropdown).findByTitle("Perplexity AI"); + + await user.type(screen.getByRole("combobox"), "perplexity ai"); + + expect(within(dropdown).getByTitle("Perplexity AI")).toBeInTheDocument(); + expect(within(dropdown).queryByTitle("SearXNG")).not.toBeInTheDocument(); + }); + + it("shows the provider logo in the dropdown options", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + renderModal(); + + const dropdown = await openProviderDropdown(user); + expect(await within(dropdown).findByRole("img", { name: "Perplexity AI logo" })).toHaveAttribute( + "src", + expect.stringContaining("perplexity.png"), + ); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx index 6c8cef0b1a1..a21814f15f8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx @@ -4,43 +4,15 @@ import { useQuery } from "@tanstack/react-query"; import { Button, TextInput } from "@tremor/react"; import { Form, Input, Modal, Select, Tooltip, Typography } from "antd"; import React, { useState } from "react"; -import { Logo } from "@/components/molecules/logo/Logo"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { createSearchTool, fetchAvailableSearchProviders } from "@/components/networking"; import SearchConnectionTest from "./SearchConnectionTest"; +import { SearchProviderLabel } from "./SearchProviderLabel"; +import { searchProviderFilterOption } from "./searchProviderFilterOption"; import { AvailableSearchProvider, SearchTool } from "./types"; -import dataforseoLogo from "../../../../../public/assets/logos/dataforseo.png"; -import exaAiLogo from "../../../../../public/assets/logos/exa_ai.png"; -import googlePseLogo from "../../../../../public/assets/logos/google_pse.png"; -import nimbleLogo from "../../../../../public/assets/logos/nimble.png"; -import parallelAiLogo from "../../../../../public/assets/logos/parallel_ai.png"; -import perplexityLogo from "../../../../../public/assets/logos/perplexity.png"; -import tavilyLogo from "../../../../../public/assets/logos/tavily.png"; const { TextArea } = Input; -const searchProviderLogoMap: Record = { - perplexity: perplexityLogo.src, - tavily: tavilyLogo.src, - parallel_ai: parallelAiLogo.src, - exa_ai: exaAiLogo.src, - google_pse: googlePseLogo.src, - dataforseo: dataforseoLogo.src, - nimble: nimbleLogo.src, -}; - -interface SearchProviderLabelProps { - providerName: string; - displayName: string; -} - -export const SearchProviderLabel: React.FC = ({ providerName, displayName }) => ( - - - {displayName} - -); - interface CreateSearchToolProps { userRole: string; accessToken: string | null; @@ -212,13 +184,14 @@ const CreateSearchTool: React.FC = ({ size="large" loading={isLoadingProviders} showSearch - optionFilterProp="children" + filterOption={searchProviderFilterOption} optionLabelProp="label" > {availableProviders.map((provider) => ( { it("renders the tavily logo from the static bundle, untouched by server-root prefixing", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchProviderLabel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchProviderLabel.tsx new file mode 100644 index 00000000000..3610d755cb3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchProviderLabel.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { Logo } from "@/components/molecules/logo/Logo"; +import dataforseoLogo from "../../../../../public/assets/logos/dataforseo.png"; +import exaAiLogo from "../../../../../public/assets/logos/exa_ai.png"; +import googlePseLogo from "../../../../../public/assets/logos/google_pse.png"; +import nimbleLogo from "../../../../../public/assets/logos/nimble.png"; +import parallelAiLogo from "../../../../../public/assets/logos/parallel_ai.png"; +import perplexityLogo from "../../../../../public/assets/logos/perplexity.png"; +import tavilyLogo from "../../../../../public/assets/logos/tavily.png"; + +const searchProviderLogoMap: Readonly> = { + perplexity: perplexityLogo.src, + tavily: tavilyLogo.src, + parallel_ai: parallelAiLogo.src, + exa_ai: exaAiLogo.src, + google_pse: googlePseLogo.src, + dataforseo: dataforseoLogo.src, + nimble: nimbleLogo.src, +}; + +interface SearchProviderLabelProps { + providerName: string; + displayName: string; + className?: string; + logoClassName?: string; +} + +export const SearchProviderLabel: React.FC = ({ + providerName, + displayName, + className = "text-sm", + logoClassName = "w-5 h-5 object-contain", +}) => ( + + + {displayName} + +); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTable.test.tsx index c4c05f97dce..ed6cd5242b3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTable.test.tsx @@ -42,6 +42,27 @@ describe("SearchToolTable", () => { expect(screen.getByText("DB")).toBeInTheDocument(); }); + it("should show the provider logo next to the provider name", () => { + renderWithProviders(); + expect(screen.getByRole("img", { name: "Perplexity AI logo" })).toHaveAttribute( + "src", + expect.stringContaining("perplexity.png"), + ); + }); + + it("should fall back to a letter avatar for a provider with no bundled logo", () => { + const tool = makeSearchTool({ litellm_params: { search_provider: "searxng" } }); + renderWithProviders( + , + ); + expect(screen.queryByRole("img", { name: /logo/ })).not.toBeInTheDocument(); + expect(screen.getByText("SearXNG")).toBeInTheDocument(); + }); + it("should call onView when the search tool ID is clicked", async () => { const user = userEvent.setup(); renderWithProviders(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTableColumns.tsx index 550bf3dc7bd..4cf3728c83f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTableColumns.tsx @@ -15,6 +15,7 @@ import { } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/cva.config"; +import { SearchProviderLabel } from "./SearchProviderLabel"; import { AvailableSearchProvider, SearchTool } from "./types"; const CONFIG_EDIT_HINT = "Config search tools cannot be edited on the dashboard. Please edit the config file."; @@ -120,7 +121,7 @@ export const getSearchToolTableColumns = ({ cell: ({ row }) => { const provider = row.original.litellm_params.search_provider; const providerInfo = availableProviders.find((candidate) => candidate.provider_name === provider); - return {providerInfo?.ui_friendly_name || provider}; + return ; }, }, { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.tsx index 1f7c992aa98..77df3555289 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, Check, Copy } from "lucide-react"; import React, { useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { SearchProviderLabel } from "./SearchProviderLabel"; import { SearchToolTester } from "./SearchToolTester"; import { AvailableSearchProvider, SearchTool } from "./types"; @@ -77,9 +78,12 @@ export const SearchToolView: React.FC = ({ Provider - - {getProviderDisplayName(searchTool.litellm_params.search_provider)} - + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchTools.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchTools.tsx index c9e2a46a861..fc3e32b1abf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchTools.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchTools.tsx @@ -12,6 +12,8 @@ import { updateSearchTool, } from "@/components/networking"; import CreateSearchTool from "./CreateSearchTools"; +import { SearchProviderLabel } from "./SearchProviderLabel"; +import { searchProviderFilterOption } from "./searchProviderFilterOption"; import SearchToolTable from "./SearchToolTable"; import { SearchToolView } from "./SearchToolView"; import { AvailableSearchProvider, SearchTool } from "./types"; @@ -168,10 +170,23 @@ const SearchTools: React.FC = ({ accessToken, userRole, userID label="Search Provider" rules={[{ required: true, message: "Please select a search provider" }]} > - + {availableProviders.map((provider) => ( - - {provider.ui_friendly_name} + + } + > + ))} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/searchProviderFilterOption.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/searchProviderFilterOption.test.ts new file mode 100644 index 00000000000..e6e6c2ca408 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/searchProviderFilterOption.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { searchProviderFilterOption } from "./searchProviderFilterOption"; + +const searxng = { value: "searxng", title: "SearXNG" }; +const googlePse = { value: "google_pse", title: "Google PSE" }; + +describe("searchProviderFilterOption", () => { + it("matches a provider by its slug", () => { + expect(searchProviderFilterOption("searxng", searxng)).toBe(true); + expect(searchProviderFilterOption("searxng", googlePse)).toBe(false); + }); + + it("matches a provider by its display name, case-insensitively", () => { + expect(searchProviderFilterOption("google pse", googlePse)).toBe(true); + expect(searchProviderFilterOption("SEARXNG", searxng)).toBe(true); + }); + + it("matches on a partial slug so results narrow as the user types", () => { + expect(searchProviderFilterOption("sear", searxng)).toBe(true); + expect(searchProviderFilterOption("sear", googlePse)).toBe(false); + }); + + it("keeps every option when the query is empty or whitespace", () => { + expect(searchProviderFilterOption("", googlePse)).toBe(true); + expect(searchProviderFilterOption(" ", googlePse)).toBe(true); + }); + + it("does not match an option that has no slug or title", () => { + expect(searchProviderFilterOption("searxng", {})).toBe(false); + expect(searchProviderFilterOption("searxng", undefined)).toBe(false); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/searchProviderFilterOption.ts b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/searchProviderFilterOption.ts new file mode 100644 index 00000000000..87cbca7f76e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/searchProviderFilterOption.ts @@ -0,0 +1,13 @@ +interface SearchProviderOption { + value?: string | number | null; + title?: string; +} + +// antd's default filter stringifies option children, which are React elements here, so it matches nothing +export const searchProviderFilterOption = (input: string, option?: SearchProviderOption): boolean => { + const needle = input.trim().toLowerCase(); + if (!needle) return true; + return [option?.value, option?.title].some( + (part) => typeof part === "string" && part.toLowerCase().includes(needle), + ); +};
Provider
- {getProviderDisplayName(searchTool.litellm_params.search_provider)} -