diff --git a/ui/litellm-dashboard/src/components/SearchTools/CreateSearchTools.tsx b/ui/litellm-dashboard/src/components/SearchTools/CreateSearchTools.tsx
index 18eaf5e493..49b57c2a88 100644
--- a/ui/litellm-dashboard/src/components/SearchTools/CreateSearchTools.tsx
+++ b/ui/litellm-dashboard/src/components/SearchTools/CreateSearchTools.tsx
@@ -1,14 +1,14 @@
-import React, { useState } from "react";
-import { Modal, Tooltip, Form, Select, Input, Typography } from "antd";
-import { InfoCircleOutlined } from "@ant-design/icons";
-import { Button, TextInput } from "@tremor/react";
-import { createSearchTool, fetchAvailableSearchProviders } from "../networking";
-import { SearchTool, AvailableSearchProvider } from "./types";
import { isAdminRole } from "@/utils/roles";
-import NotificationsManager from "../molecules/notifications_manager";
+import { InfoCircleOutlined } from "@ant-design/icons";
import { useQuery } from "@tanstack/react-query";
-import SearchConnectionTest from "./SearchConnectionTest";
+import { Button, TextInput } from "@tremor/react";
+import { Form, Input, Modal, Select, Tooltip, Typography } from "antd";
import Image from "next/image";
+import React, { useState } from "react";
+import NotificationsManager from "../molecules/notifications_manager";
+import { createSearchTool, fetchAvailableSearchProviders } from "../networking";
+import SearchConnectionTest from "./SearchConnectionTest";
+import { AvailableSearchProvider, SearchTool } from "./types";
const { TextArea } = Input;
diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchConnectionTest.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchConnectionTest.tsx
index e18d314591..a8472cb1f8 100644
--- a/ui/litellm-dashboard/src/components/SearchTools/SearchConnectionTest.tsx
+++ b/ui/litellm-dashboard/src/components/SearchTools/SearchConnectionTest.tsx
@@ -1,8 +1,8 @@
+import { InfoCircleOutlined, WarningOutlined } from "@ant-design/icons";
+import { Button, Divider, Typography } from "antd";
import React, { useEffect, useState } from "react";
-import { testSearchToolConnection } from "../networking";
-import { Button, Typography, Divider } from "antd";
-import { WarningOutlined, InfoCircleOutlined } from "@ant-design/icons";
import NotificationsManager from "../molecules/notifications_manager";
+import { testSearchToolConnection } from "../networking";
const { Text } = Typography;
diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx
index 86b0bde4d0..e741bd06a9 100644
--- a/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx
+++ b/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx
@@ -1,7 +1,7 @@
-import { ColumnsType } from "antd/es/table";
-import { SearchTool } from "./types";
import { Tag } from "antd";
+import { ColumnsType } from "antd/es/table";
import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
+import { SearchTool } from "./types";
export const searchToolColumns = (
onView: (searchToolId: string) => void,
diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.test.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.test.tsx
new file mode 100644
index 0000000000..bb04a04a99
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.test.tsx
@@ -0,0 +1,278 @@
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { SearchToolView } from "./SearchToolView";
+import { AvailableSearchProvider, SearchTool } from "./types";
+
+vi.mock("@/utils/dataUtils", () => ({
+ copyToClipboard: vi.fn().mockResolvedValue(true),
+}));
+
+vi.mock("./SearchToolTester", () => ({
+ SearchToolTester: ({ searchToolName, accessToken }: { searchToolName: string; accessToken: string }) => (
+
+ Search Tool Tester for {searchToolName}
+ Access Token: {accessToken}
+
+ ),
+}));
+
+describe("SearchToolView", () => {
+ const mockSearchTool: SearchTool = {
+ search_tool_id: "test-tool-id-123",
+ search_tool_name: "Test Search Tool",
+ litellm_params: {
+ search_provider: "perplexity",
+ api_key: "sk-test-key",
+ },
+ search_tool_info: {
+ description: "Test description",
+ },
+ created_at: "2024-01-15T10:30:00Z",
+ };
+
+ const mockAvailableProviders: AvailableSearchProvider[] = [
+ {
+ provider_name: "perplexity",
+ ui_friendly_name: "Perplexity AI",
+ },
+ {
+ provider_name: "tavily",
+ ui_friendly_name: "Tavily Search",
+ },
+ ];
+
+ const defaultProps = {
+ searchTool: mockSearchTool,
+ onBack: vi.fn(),
+ isEditing: false,
+ accessToken: "test-token",
+ availableProviders: mockAvailableProviders,
+ };
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ const { copyToClipboard } = await import("@/utils/dataUtils");
+ vi.mocked(copyToClipboard).mockResolvedValue(true);
+ });
+
+ it("should render", () => {
+ render();
+ expect(screen.getByText("Test Search Tool")).toBeInTheDocument();
+ });
+
+ it("should display search tool name", () => {
+ render();
+ expect(screen.getByText("Test Search Tool")).toBeInTheDocument();
+ });
+
+ it("should display search tool ID", () => {
+ render();
+ expect(screen.getByText("test-tool-id-123")).toBeInTheDocument();
+ });
+
+ it("should display provider name using UI-friendly name when available", () => {
+ render();
+ expect(screen.getByText("Perplexity AI")).toBeInTheDocument();
+ });
+
+ it("should display provider name using provider_name when UI-friendly name is not available", () => {
+ const searchToolWithoutProvider: SearchTool = {
+ ...mockSearchTool,
+ litellm_params: {
+ search_provider: "unknown-provider",
+ },
+ };
+
+ render(
+ ,
+ );
+ expect(screen.getByText("unknown-provider")).toBeInTheDocument();
+ });
+
+ it("should display masked API key when API key is set", () => {
+ render();
+ expect(screen.getByText("****")).toBeInTheDocument();
+ });
+
+ it("should display 'Not set' when API key is not set", () => {
+ const searchToolWithoutApiKey: SearchTool = {
+ ...mockSearchTool,
+ litellm_params: {
+ search_provider: "perplexity",
+ },
+ };
+
+ render(
+ ,
+ );
+ expect(screen.getByText("Not set")).toBeInTheDocument();
+ });
+
+ it("should display formatted created_at date", () => {
+ render();
+ const dateText = screen.getByText(/2024-01-15/);
+ expect(dateText).toBeInTheDocument();
+ });
+
+ it("should display 'Unknown' when created_at is not set", () => {
+ const searchToolWithoutDate: SearchTool = {
+ ...mockSearchTool,
+ created_at: undefined,
+ };
+
+ render(
+ ,
+ );
+ expect(screen.getByText("Unknown")).toBeInTheDocument();
+ });
+
+ it("should display description when search_tool_info.description is provided", () => {
+ render();
+ expect(screen.getByText("Test description")).toBeInTheDocument();
+ });
+
+ it("should not display description card when search_tool_info.description is not provided", () => {
+ const searchToolWithoutDescription: SearchTool = {
+ ...mockSearchTool,
+ search_tool_info: {},
+ };
+
+ render(
+ ,
+ );
+ expect(screen.queryByText("Description")).not.toBeInTheDocument();
+ });
+
+ it("should call onBack when back button is clicked", async () => {
+ const user = userEvent.setup({ delay: null });
+ const onBack = vi.fn();
+ render();
+
+ const backButton = screen.getByRole("button", { name: /back to all search tools/i });
+ await user.click(backButton);
+
+ expect(onBack).toHaveBeenCalledTimes(1);
+ });
+
+ it("should copy search tool name to clipboard when copy button is clicked", async () => {
+ const user = userEvent.setup({ delay: null });
+ const { copyToClipboard } = await import("@/utils/dataUtils");
+ render();
+
+ const toolNameContainer = screen.getByText("Test Search Tool").closest("div");
+ expect(toolNameContainer).toBeInTheDocument();
+
+ const copyButtons = within(toolNameContainer!).getAllByRole("button");
+ const nameCopyButton = copyButtons.find((button) => {
+ return button.querySelector("svg") !== null;
+ });
+
+ expect(nameCopyButton).toBeInTheDocument();
+ await user.click(nameCopyButton!);
+
+ await waitFor(() => {
+ expect(copyToClipboard).toHaveBeenCalledWith("Test Search Tool");
+ });
+ });
+
+ it("should copy search tool ID to clipboard when copy button is clicked", async () => {
+ const user = userEvent.setup({ delay: null });
+ const { copyToClipboard } = await import("@/utils/dataUtils");
+ render();
+
+ const toolIdContainer = screen.getByText("test-tool-id-123").closest("div");
+ expect(toolIdContainer).toBeInTheDocument();
+
+ const copyButtons = within(toolIdContainer!).getAllByRole("button");
+ const idCopyButton = copyButtons.find((button) => {
+ return button.querySelector("svg") !== null;
+ });
+
+ expect(idCopyButton).toBeInTheDocument();
+ await user.click(idCopyButton!);
+
+ await waitFor(() => {
+ expect(copyToClipboard).toHaveBeenCalledWith("test-tool-id-123");
+ });
+ });
+
+ it("should show check icon after copying search tool name", async () => {
+ const user = userEvent.setup({ delay: null });
+ const { copyToClipboard } = await import("@/utils/dataUtils");
+ vi.mocked(copyToClipboard).mockResolvedValue(true);
+
+ render();
+
+ const toolNameContainer = screen.getByText("Test Search Tool").closest("div");
+ const copyButtons = within(toolNameContainer!).getAllByRole("button");
+ const nameCopyButton = copyButtons.find((button) => {
+ return button.querySelector("svg") !== null;
+ });
+
+ expect(nameCopyButton).toBeInTheDocument();
+
+ const initialSvg = nameCopyButton!.querySelector("svg");
+ expect(initialSvg).toBeInTheDocument();
+
+ await user.click(nameCopyButton!);
+
+ await waitFor(() => {
+ const updatedSvg = nameCopyButton!.querySelector("svg");
+ expect(updatedSvg).toBeInTheDocument();
+ expect(nameCopyButton).toHaveClass("text-green-600");
+ });
+ });
+
+
+ it("should not show check icon when copy fails", async () => {
+ const user = userEvent.setup({ delay: null });
+ const { copyToClipboard } = await import("@/utils/dataUtils");
+ vi.mocked(copyToClipboard).mockResolvedValue(false);
+
+ render();
+
+ const toolNameContainer = screen.getByText("Test Search Tool").closest("div");
+ const copyButtons = within(toolNameContainer!).getAllByRole("button");
+ const nameCopyButton = copyButtons.find((button) => {
+ return button.querySelector("svg") !== null;
+ });
+
+ expect(nameCopyButton).toBeInTheDocument();
+ await user.click(nameCopyButton!);
+
+ await waitFor(() => {
+ expect(copyToClipboard).toHaveBeenCalledWith("Test Search Tool");
+ }, { timeout: 3000 });
+
+ expect(nameCopyButton).not.toHaveClass("text-green-600");
+ });
+
+ it("should render SearchToolTester when accessToken is provided", () => {
+ render();
+ expect(screen.getByTestId("search-tool-tester")).toBeInTheDocument();
+ expect(screen.getByText(/Search Tool Tester for Test Search Tool/)).toBeInTheDocument();
+ });
+
+ it("should not render SearchToolTester when accessToken is null", () => {
+ render();
+ expect(screen.queryByTestId("search-tool-tester")).not.toBeInTheDocument();
+ });
+
+ it("should pass correct props to SearchToolTester", () => {
+ render();
+ expect(screen.getByText("Access Token: test-token")).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.tsx
index c07041471e..ad88acd127 100644
--- a/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.tsx
+++ b/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.tsx
@@ -1,11 +1,11 @@
-import React, { useState } from "react";
-import { ArrowLeftIcon } from "@heroicons/react/outline";
-import { Title, Card, Button, Text, Grid } from "@tremor/react";
-import { SearchTool, AvailableSearchProvider } from "./types";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
-import { CheckIcon, CopyIcon } from "lucide-react";
+import { ArrowLeftIcon } from "@heroicons/react/outline";
+import { Button, Card, Grid, Text, Title } from "@tremor/react";
import { Button as AntdButton } from "antd";
+import { CheckIcon, CopyIcon } from "lucide-react";
+import React, { useState } from "react";
import { SearchToolTester } from "./SearchToolTester";
+import { AvailableSearchProvider, SearchTool } from "./types";
interface SearchToolViewProps {
searchTool: SearchTool;
@@ -54,8 +54,8 @@ export const SearchToolView: React.FC = ({
icon={copiedStates["search-tool-name"] ? : }
onClick={() => copyToClipboard(searchTool.search_tool_name, "search-tool-name")}
className={`left-2 z-10 transition-all duration-200 ${copiedStates["search-tool-name"]
- ? "text-green-600 bg-green-50 border-green-200"
- : "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
+ ? "text-green-600 bg-green-50 border-green-200"
+ : "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
/>
@@ -67,8 +67,8 @@ export const SearchToolView: React.FC = ({
icon={copiedStates["search-tool-id"] ? : }
onClick={() => copyToClipboard(searchTool.search_tool_id, "search-tool-id")}
className={`left-2 z-10 transition-all duration-200 ${copiedStates["search-tool-id"]
- ? "text-green-600 bg-green-50 border-green-200"
- : "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
+ ? "text-green-600 bg-green-50 border-green-200"
+ : "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
/>
diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchTools.test.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.test.tsx
new file mode 100644
index 0000000000..f1f7bf8ab4
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.test.tsx
@@ -0,0 +1,234 @@
+import * as roles from "@/utils/roles";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import * as networking from "../networking";
+import SearchTools from "./SearchTools";
+import { AvailableSearchProvider, SearchTool } from "./types";
+
+vi.mock("../networking", () => ({
+ fetchSearchTools: vi.fn(),
+ updateSearchTool: vi.fn(),
+ deleteSearchTool: vi.fn(),
+ fetchAvailableSearchProviders: vi.fn(),
+}));
+
+vi.mock("@/utils/roles", () => ({
+ isAdminRole: vi.fn(),
+}));
+
+vi.mock("./SearchToolView", () => ({
+ SearchToolView: ({ searchTool, onBack }: { searchTool: SearchTool; onBack: () => void }) => (
+
+
Search Tool View: {searchTool.search_tool_name}
+
+
+ ),
+}));
+
+vi.mock("./CreateSearchTools", () => ({
+ default: ({
+ isModalVisible,
+ setModalVisible,
+ }: {
+ isModalVisible: boolean;
+ setModalVisible: (visible: boolean) => void;
+ }) =>
+ isModalVisible ? (
+
+
+
+ ) : null,
+}));
+
+vi.mock("../common_components/DeleteResourceModal", () => ({
+ default: ({
+ isOpen,
+ onOk,
+ onCancel,
+ }: {
+ isOpen: boolean;
+ onOk: () => void;
+ onCancel: () => void;
+ }) =>
+ isOpen ? (
+
+
+
+
+ ) : null,
+}));
+
+const mockSearchTools: SearchTool[] = [
+ {
+ search_tool_id: "tool-1",
+ search_tool_name: "Perplexity Search",
+ litellm_params: {
+ search_provider: "perplexity",
+ api_key: "sk-test-key",
+ },
+ search_tool_info: {
+ description: "Test description",
+ },
+ created_at: "2024-01-15T10:30:00Z",
+ },
+ {
+ search_tool_id: "tool-2",
+ search_tool_name: "Tavily Search",
+ litellm_params: {
+ search_provider: "tavily",
+ },
+ created_at: "2024-01-16T10:30:00Z",
+ },
+];
+
+const mockAvailableProviders: AvailableSearchProvider[] = [
+ {
+ provider_name: "perplexity",
+ ui_friendly_name: "Perplexity AI",
+ },
+ {
+ provider_name: "tavily",
+ ui_friendly_name: "Tavily Search",
+ },
+];
+
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+};
+
+describe("SearchTools", () => {
+ const defaultProps = {
+ accessToken: "test-token",
+ userRole: "Admin",
+ userID: "user-1",
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(networking.fetchSearchTools).mockResolvedValue({ search_tools: mockSearchTools });
+ vi.mocked(networking.fetchAvailableSearchProviders).mockResolvedValue({ providers: mockAvailableProviders });
+ vi.mocked(roles.isAdminRole).mockReturnValue(true);
+ });
+
+ it("should render", async () => {
+ render(, { wrapper: createWrapper() });
+ await waitFor(() => {
+ expect(screen.getByText("Search Tools")).toBeInTheDocument();
+ });
+ });
+
+ it("should display missing authentication parameters message when accessToken is missing", () => {
+ render(, { wrapper: createWrapper() });
+ expect(screen.getByText("Missing required authentication parameters.")).toBeInTheDocument();
+ });
+
+ it("should display missing authentication parameters message when userRole is missing", () => {
+ render(, { wrapper: createWrapper() });
+ expect(screen.getByText("Missing required authentication parameters.")).toBeInTheDocument();
+ });
+
+ it("should display missing authentication parameters message when userID is missing", () => {
+ render(, { wrapper: createWrapper() });
+ expect(screen.getByText("Missing required authentication parameters.")).toBeInTheDocument();
+ });
+
+ it("should display search tools table with tools", async () => {
+ render(, { wrapper: createWrapper() });
+ await waitFor(() => {
+ expect(screen.getByText("Perplexity Search")).toBeInTheDocument();
+ });
+ expect(screen.getAllByText("Tavily Search").length).toBeGreaterThan(0);
+ });
+
+ it("should display empty state when no search tools are available", async () => {
+ vi.mocked(networking.fetchSearchTools).mockResolvedValue({ search_tools: [] });
+
+ render(, { wrapper: createWrapper() });
+ await waitFor(() => {
+ expect(screen.getByText("No search tools configured")).toBeInTheDocument();
+ });
+ });
+
+ it("should show Add New Search Tool button when user is admin", async () => {
+ render(, { wrapper: createWrapper() });
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /add new search tool/i })).toBeInTheDocument();
+ });
+ });
+
+ it("should not show Add New Search Tool button when user is not admin", async () => {
+ vi.mocked(roles.isAdminRole).mockReturnValue(false);
+
+ render(, { wrapper: createWrapper() });
+ await waitFor(() => {
+ expect(screen.getByText("Search Tools")).toBeInTheDocument();
+ });
+ expect(screen.queryByRole("button", { name: /add new search tool/i })).not.toBeInTheDocument();
+ });
+
+ it("should open create modal when Add New Search Tool button is clicked", async () => {
+ const user = userEvent.setup({ delay: null });
+ render(, { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /add new search tool/i })).toBeInTheDocument();
+ });
+
+ const addButton = screen.getByRole("button", { name: /add new search tool/i });
+ await user.click(addButton);
+
+ expect(screen.getByTestId("create-search-tool-modal")).toBeInTheDocument();
+ });
+
+ it("should navigate to tool view when tool ID is clicked", async () => {
+ const user = userEvent.setup({ delay: null });
+ render(, { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByText("Perplexity Search")).toBeInTheDocument();
+ });
+
+ const toolIdButton = screen.getByRole("button", { name: /tool-1/i });
+ await user.click(toolIdButton);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("search-tool-view")).toBeInTheDocument();
+ });
+ expect(screen.getByText(/Search Tool View: Perplexity Search/i)).toBeInTheDocument();
+ });
+
+ it("should navigate back from tool view to table", async () => {
+ const user = userEvent.setup({ delay: null });
+ render(, { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByText("Perplexity Search")).toBeInTheDocument();
+ });
+
+ const toolIdButton = screen.getByRole("button", { name: /tool-1/i });
+ await user.click(toolIdButton);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("search-tool-view")).toBeInTheDocument();
+ });
+
+ const backButton = screen.getByRole("button", { name: /back/i });
+ await user.click(backButton);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("search-tool-view")).not.toBeInTheDocument();
+ expect(screen.getByText("Perplexity Search")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx
index ba42ea22cd..dd2033fc18 100644
--- a/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx
+++ b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx
@@ -1,21 +1,21 @@
-import React, { useState } from "react";
-import { useQuery } from "@tanstack/react-query";
-import { Modal, Form, Input, Select, Table, Spin } from "antd";
+import { isAdminRole } from "@/utils/roles";
import { LoadingOutlined } from "@ant-design/icons";
-import { Button, Title, Text } from "@tremor/react";
-import { searchToolColumns } from "./SearchToolColumn";
+import { useQuery } from "@tanstack/react-query";
+import { Button, Text, Title } from "@tremor/react";
+import { Form, Input, Modal, Select, Spin, Table } from "antd";
+import React, { useState } from "react";
+import DeleteResourceModal from "../common_components/DeleteResourceModal";
+import NotificationsManager from "../molecules/notifications_manager";
import {
- fetchSearchTools,
- updateSearchTool,
deleteSearchTool,
fetchAvailableSearchProviders,
+ fetchSearchTools,
+ updateSearchTool,
} from "../networking";
-import { SearchTool, AvailableSearchProvider } from "./types";
-import { isAdminRole } from "@/utils/roles";
-import NotificationsManager from "../molecules/notifications_manager";
-import { SearchToolView } from "./SearchToolView";
import CreateSearchTool from "./CreateSearchTools";
-import DeleteResourceModal from "../common_components/DeleteResourceModal";
+import { searchToolColumns } from "./SearchToolColumn";
+import { SearchToolView } from "./SearchToolView";
+import { AvailableSearchProvider, SearchTool } from "./types";
interface SearchToolsProps {
accessToken: string | null;