From 2d3be8dc7bc0b66730def2ca9b6cbe8abf57065d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:57:38 +0000 Subject: [PATCH] feat(ui): show search provider logos across search tools views Reuse the provider logo label in the search tools table, detail view, and edit form, and fix the provider dropdown search, which matched nothing because antd stringified the JSX option children. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../CreateSearchTools.integration.test.tsx | 74 +++++++++++++++++++ .../_components/CreateSearchTools.tsx | 35 +-------- ....test.tsx => SearchProviderLabel.test.tsx} | 2 +- .../_components/SearchProviderLabel.tsx | 38 ++++++++++ .../_components/SearchToolTable.test.tsx | 21 ++++++ .../_components/SearchToolTableColumns.tsx | 3 +- .../_components/SearchToolView.tsx | 10 ++- .../search-tools/_components/SearchTools.tsx | 21 +++++- .../searchProviderFilterOption.test.ts | 32 ++++++++ .../_components/searchProviderFilterOption.ts | 13 ++++ 10 files changed, 210 insertions(+), 39 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.integration.test.tsx rename ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/{CreateSearchTools.test.tsx => SearchProviderLabel.test.tsx} (96%) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchProviderLabel.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/searchProviderFilterOption.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/searchProviderFilterOption.ts 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), + ); +};