[Feat] UI - Search Tools, allow adding search tools on UI + testing search (#15871)

* add LiteLLM_SearchToolsTable

* init SearchToolRegistry

* fix add SearchToolRegistry

* fix add SearchToolRegistry

* fix handling search tool management

* fix search imports

* fix registry

* init search tools in memory

* fix init tools in mem

* fix TypedDict def

* add new SCHEMA

* bump proxy extras

* add LiteLLM_SearchToolsTable_search_tool_name_key

* bump extras with migration

* fix working CRUD Ops

* fix: _init_search_tools_in_db

* add UI friendly name for search providers

* add ui friendly name for search providers

* add providers available

* working layout

* better layout

* clean add search tool

* update_router_search_tools

* fix remove in memory registry, since router is in mem store

* allow testing search tool connection

* clean create search tool

* add test_search_tool_connection

* fix: _init_search_tools_in_db

* add searchToolQueryCall

* fix icon

* clean tester
This commit is contained in:
Ishaan Jaff 2025-10-23 17:59:29 -07:00 committed by GitHub
parent d8ea1665c7
commit fc9aba279e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1974 additions and 209 deletions

View file

@ -49,6 +49,14 @@ class BaseSearchConfig:
def __init__(self) -> None:
pass
@staticmethod
def ui_friendly_name() -> str:
"""
UI-friendly name for the search provider.
Override in provider-specific implementations.
"""
return "Unknown Search Provider"
def get_http_method(self) -> Literal["GET", "POST"]:
"""
Get HTTP method for search requests.

View file

@ -27,6 +27,10 @@ class DataForSEOSearchConfig(BaseSearchConfig):
DATAFORSEO_API_BASE = "https://api.dataforseo.com/v3/serp/google/organic/live/advanced"
@staticmethod
def ui_friendly_name() -> str:
return "DataForSEO"
def get_http_method(self) -> Literal["GET", "POST"]:
"""
DataForSEO uses POST requests with JSON body.

View file

@ -46,6 +46,10 @@ class ExaAISearchRequest(_ExaAISearchRequestRequired, total=False):
class ExaAISearchConfig(BaseSearchConfig):
EXA_AI_API_BASE = "https://api.exa.ai"
@staticmethod
def ui_friendly_name() -> str:
return "Exa AI"
def validate_environment(
self,
headers: Dict,

View file

@ -55,6 +55,10 @@ class GooglePSESearchRequest(_GooglePSESearchRequestRequired, total=False):
class GooglePSESearchConfig(BaseSearchConfig):
GOOGLE_PSE_API_BASE = "https://www.googleapis.com/customsearch/v1"
@staticmethod
def ui_friendly_name() -> str:
return "Google PSE"
def get_http_method(self) -> Literal["GET", "POST"]:
"""
Google PSE uses GET requests with query parameters.

View file

@ -45,6 +45,10 @@ class ParallelAISearchConfig(BaseSearchConfig):
PARALLEL_AI_API_BASE = "https://api.parallel.ai"
PARALLEL_HEADER_SEARCH_EXTRACT_VALUE = "search-extract-2025-10-10"
@staticmethod
def ui_friendly_name() -> str:
return "Parallel AI"
def validate_environment(
self,
headers: Dict,

View file

@ -33,6 +33,10 @@ class PerplexitySearchRequest(_PerplexitySearchRequestRequired, total=False):
class PerplexitySearchConfig(BaseSearchConfig):
PERPLEXITY_API_BASE = "https://api.perplexity.ai"
@staticmethod
def ui_friendly_name() -> str:
return "Perplexity"
def validate_environment(
self,
headers: Dict,

View file

@ -45,6 +45,10 @@ class TavilySearchRequest(_TavilySearchRequestRequired, total=False):
class TavilySearchConfig(BaseSearchConfig):
TAVILY_API_BASE = "https://api.tavily.com"
@staticmethod
def ui_friendly_name() -> str:
return "Tavily"
def validate_environment(
self,
headers: Dict,

View file

@ -3523,15 +3523,35 @@ class ProxyConfig:
)
async def _init_search_tools_in_db(self, prisma_client: PrismaClient):
from litellm.proxy.search_endpoints.search_tool_registry import (
IN_MEMORY_SEARCH_TOOL_HANDLER,
SearchToolRegistry,
)
from litellm.types.search import SearchTool
"""
Initialize search tools from database into the router on startup.
"""
global llm_router
from litellm.proxy.search_endpoints.search_tool_registry import SearchToolRegistry
from litellm.router_utils.search_api_router import SearchAPIRouter
try:
search_tools = await SearchToolRegistry.get_all_search_tools_from_db(prisma_client=prisma_client)
for search_tool in search_tools:
IN_MEMORY_SEARCH_TOOL_HANDLER.add_search_tool(search_tool=cast(SearchTool, search_tool))
verbose_proxy_logger.info(
f"Loading {len(search_tools)} search tool(s) from database into router"
)
if llm_router is not None:
# Add search tools to the router
await SearchAPIRouter.update_router_search_tools(
router_instance=llm_router,
search_tools=search_tools
)
verbose_proxy_logger.info(
f"Successfully loaded {len(search_tools)} search tool(s) into router"
)
else:
verbose_proxy_logger.debug(
"Router not initialized yet, search tools will be added when router is created"
)
except Exception as e:
verbose_proxy_logger.exception(
"litellm.proxy.proxy_server.py::ProxyConfig:_init_search_tools_in_db - {}".format(

View file

@ -1,14 +1,8 @@
# litellm/proxy/search_endpoints/__init__.py
from .search_tool_registry import (
IN_MEMORY_SEARCH_TOOL_HANDLER,
InMemorySearchToolHandler,
SearchToolRegistry,
)
from .search_tool_registry import SearchToolRegistry
__all__ = [
"SearchToolRegistry",
"InMemorySearchToolHandler",
"IN_MEMORY_SEARCH_TOOL_HANDLER",
]

View file

@ -2,17 +2,14 @@
CRUD ENDPOINTS FOR SEARCH TOOLS
"""
from datetime import datetime
from typing import List, Union, cast
from typing import Any, Dict, List, Union, cast
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from litellm._logging import verbose_proxy_logger
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.search_endpoints.search_tool_registry import (
IN_MEMORY_SEARCH_TOOL_HANDLER,
SearchToolRegistry,
)
from litellm.proxy.search_endpoints.search_tool_registry import SearchToolRegistry
from litellm.types.search import (
AvailableSearchProvider,
ListSearchToolsResponse,
@ -169,16 +166,10 @@ async def create_search_tool(request: CreateSearchToolRequest):
search_tool=request.search_tool, prisma_client=prisma_client
)
# Add to in-memory cache
try:
IN_MEMORY_SEARCH_TOOL_HANDLER.add_search_tool(search_tool=cast(SearchTool, result))
verbose_proxy_logger.info(
f"Successfully added search tool '{result.get('search_tool_name')}' to in-memory cache"
)
except Exception as cache_error:
verbose_proxy_logger.warning(
f"Failed to add search tool to in-memory cache: {cache_error}"
)
verbose_proxy_logger.info(
f"Successfully added search tool '{result.get('search_tool_name')}' to database. "
f"Router will be updated by the cron job."
)
return result
except Exception as e:
@ -258,18 +249,10 @@ async def update_search_tool(search_tool_id: str, request: UpdateSearchToolReque
prisma_client=prisma_client,
)
# Update in-memory cache
try:
IN_MEMORY_SEARCH_TOOL_HANDLER.update_search_tool(
search_tool_id=search_tool_id, search_tool=cast(SearchTool, result)
)
verbose_proxy_logger.info(
f"Successfully updated search tool '{result.get('search_tool_name')}' in in-memory cache"
)
except Exception as cache_error:
verbose_proxy_logger.warning(
f"Failed to update search tool in in-memory cache: {cache_error}"
)
verbose_proxy_logger.info(
f"Successfully updated search tool '{result.get('search_tool_name')}' in database. "
f"Router will be updated by the cron job."
)
return result
except HTTPException as e:
@ -323,18 +306,10 @@ async def delete_search_tool(search_tool_id: str):
search_tool_id=search_tool_id, prisma_client=prisma_client
)
# Delete from in-memory cache
try:
IN_MEMORY_SEARCH_TOOL_HANDLER.delete_search_tool(
search_tool_id=search_tool_id
)
verbose_proxy_logger.info(
f"Successfully removed search tool from in-memory cache"
)
except Exception as cache_error:
verbose_proxy_logger.warning(
f"Failed to remove search tool from in-memory cache: {cache_error}"
)
verbose_proxy_logger.info(
f"Successfully deleted search tool from database. "
f"Router will be updated by the cron job."
)
return result
except HTTPException as e:
@ -387,12 +362,6 @@ async def get_search_tool_info(search_tool_id: str):
search_tool_id=search_tool_id, prisma_client=prisma_client
)
if result is None:
# Try in-memory cache
result = IN_MEMORY_SEARCH_TOOL_HANDLER.get_search_tool_by_id(
search_tool_id=search_tool_id
)
if result is None:
raise HTTPException(
status_code=404,
@ -422,6 +391,110 @@ async def get_search_tool_info(search_tool_id: str):
raise HTTPException(status_code=500, detail=str(e))
class TestSearchToolConnectionRequest(BaseModel):
litellm_params: Dict[str, Any]
@router.post(
"/search_tools/test_connection",
tags=["Search Tools"],
dependencies=[Depends(user_api_key_auth)],
)
async def test_search_tool_connection(request: TestSearchToolConnectionRequest):
"""
Test connection to a search provider with the given configuration.
Makes a simple test search query to verify the API key and configuration are valid.
Example Request:
```bash
curl -X POST "http://localhost:4000/search_tools/test_connection" \\
-H "Authorization: Bearer <your_api_key>" \\
-H "Content-Type: application/json" \\
-d '{
"litellm_params": {
"search_provider": "perplexity",
"api_key": "sk-..."
}
}'
```
Example Response (Success):
```json
{
"status": "success",
"message": "Successfully connected to perplexity search provider",
"test_query": "test",
"results_count": 5
}
```
Example Response (Failure):
```json
{
"status": "error",
"message": "Authentication failed: Invalid API key",
"error_type": "AuthenticationError"
}
```
"""
try:
from litellm.search import asearch
# Extract params from request
litellm_params = request.litellm_params
search_provider = litellm_params.get("search_provider")
api_key = litellm_params.get("api_key")
api_base = litellm_params.get("api_base")
if not search_provider:
raise HTTPException(
status_code=400,
detail="search_provider is required in litellm_params"
)
verbose_proxy_logger.debug(
f"Testing connection to search provider: {search_provider}"
)
# Make a simple test search query with max_results=1 to minimize cost
test_query = "test"
response = await asearch(
query=test_query,
search_provider=search_provider,
api_key=api_key,
api_base=api_base,
max_results=1, # Minimize results to reduce cost
timeout=10.0, # 10 second timeout for test
)
verbose_proxy_logger.info(
f"Successfully tested connection to {search_provider} search provider"
)
return {
"status": "success",
"message": f"Successfully connected to {search_provider} search provider",
"test_query": test_query,
"results_count": len(response.results) if response and response.results else 0,
}
except Exception as e:
error_message = str(e)
error_type = type(e).__name__
verbose_proxy_logger.exception(
f"Failed to connect to search provider: {error_message}"
)
# Return error details in a structured format
return {
"status": "error",
"message": error_message,
"error_type": error_type,
}
@router.get(
"/search_tools/ui/available_providers",
tags=["Search Tools"],
@ -431,7 +504,7 @@ async def get_available_search_providers():
"""
Get the list of available search providers with their configuration fields.
This auto-discovers search providers from the SearchProviders enum.
Auto-discovers search providers and their UI-friendly names from transformation configs.
Example Request:
```bash
@ -441,80 +514,46 @@ async def get_available_search_providers():
Example Response:
```json
[
{
"provider": "perplexity",
"display_name": "Perplexity",
"fields": [
{
"name": "api_key",
"type": "string",
"required": false,
"description": "API key for Perplexity"
},
{
"name": "api_base",
"type": "string",
"required": false,
"description": "API base URL"
}
]
}
]
{
"providers": [
{
"provider_name": "perplexity",
"ui_friendly_name": "Perplexity"
},
{
"provider_name": "tavily",
"ui_friendly_name": "Tavily"
}
]
}
```
"""
try:
available_providers: List[AvailableSearchProvider] = []
from litellm.utils import ProviderConfigManager
available_providers = []
# Common fields for all search providers
common_fields = [
{
"name": "api_key",
"type": "string",
"required": False,
"description": "API key for the search provider",
},
{
"name": "api_base",
"type": "string",
"required": False,
"description": "Custom API base URL (optional)",
},
{
"name": "timeout",
"type": "number",
"required": False,
"description": "Request timeout in seconds",
},
{
"name": "max_retries",
"type": "number",
"required": False,
"description": "Maximum number of retry attempts",
},
]
# Provider display name mapping
provider_display_names = {
SearchProviders.PERPLEXITY: "Perplexity",
SearchProviders.TAVILY: "Tavily",
SearchProviders.PARALLEL_AI: "Parallel AI",
SearchProviders.EXA_AI: "Exa AI",
SearchProviders.GOOGLE_PSE: "Google PSE",
SearchProviders.DATAFORSEO: "DataForSEO",
}
# Auto-discover providers from SearchProviders enum
for provider in SearchProviders:
available_providers.append(
AvailableSearchProvider(
provider=provider.value,
display_name=provider_display_names.get(provider, provider.value.title()),
fields=common_fields,
try:
# Get the config class for this provider
config = ProviderConfigManager.get_provider_search_config(provider=provider)
if config is not None:
# Get the UI-friendly name from the config class
ui_name = config.ui_friendly_name()
available_providers.append({
"provider_name": provider.value,
"ui_friendly_name": ui_name,
})
except Exception as e:
verbose_proxy_logger.debug(
f"Could not get config for search provider {provider.value}: {e}"
)
)
return available_providers
continue
return {"providers": available_providers}
except Exception as e:
verbose_proxy_logger.exception(f"Error getting available search providers: {e}")
raise HTTPException(status_code=500, detail=str(e))

View file

@ -239,82 +239,3 @@ class SearchToolRegistry:
verbose_proxy_logger.exception(f"Error getting search tool from DB: {str(e)}")
raise Exception(f"Error getting search tool from DB: {str(e)}")
class InMemorySearchToolHandler:
"""
Class that handles caching search tools in memory.
"""
def __init__(self):
self.IN_MEMORY_SEARCH_TOOLS: Dict[str, SearchTool] = {}
"""
Search tool id to SearchTool object mapping
"""
def add_search_tool(self, search_tool: SearchTool) -> None:
"""
Add a search tool to in-memory cache.
Args:
search_tool: Search tool configuration
"""
search_tool_id = search_tool.get("search_tool_id")
if search_tool_id:
self.IN_MEMORY_SEARCH_TOOLS[search_tool_id] = search_tool
verbose_proxy_logger.debug(
f"Added search tool '{search_tool.get('search_tool_name')}' to in-memory cache"
)
def update_search_tool(self, search_tool_id: str, search_tool: SearchTool) -> None:
"""
Update a search tool in in-memory cache.
Args:
search_tool_id: ID of search tool to update
search_tool: Updated search tool configuration
"""
self.IN_MEMORY_SEARCH_TOOLS[search_tool_id] = search_tool
verbose_proxy_logger.debug(
f"Updated search tool '{search_tool.get('search_tool_name')}' in in-memory cache"
)
def delete_search_tool(self, search_tool_id: str) -> None:
"""
Delete a search tool from in-memory cache.
Args:
search_tool_id: ID of search tool to delete
"""
self.IN_MEMORY_SEARCH_TOOLS.pop(search_tool_id, None)
verbose_proxy_logger.debug(
f"Deleted search tool with ID '{search_tool_id}' from in-memory cache"
)
def list_search_tools(self) -> List[SearchTool]:
"""
List all search tools in in-memory cache.
Returns:
List of search tool configurations
"""
return list(self.IN_MEMORY_SEARCH_TOOLS.values())
def get_search_tool_by_id(self, search_tool_id: str) -> Optional[SearchTool]:
"""
Get a search tool by its ID from in-memory cache.
Args:
search_tool_id: ID of search tool to retrieve
Returns:
Search tool configuration or None if not found
"""
return self.IN_MEMORY_SEARCH_TOOLS.get(search_tool_id)
########################################################
# In Memory Search Tool Handler for LiteLLM Proxy
########################################################
IN_MEMORY_SEARCH_TOOL_HANDLER = InMemorySearchToolHandler()
########################################################

View file

@ -20,6 +20,47 @@ class SearchAPIRouter:
Provides methods for search tool selection, load balancing, and fallback handling.
"""
@staticmethod
async def update_router_search_tools(router_instance: Any, search_tools: list):
"""
Update the router with search tools from the database.
This method is called by a cron job to sync search tools from DB to router.
Args:
router_instance: The Router instance to update
search_tools: List of search tool configurations from the database
"""
try:
from litellm.types.router import SearchToolTypedDict
verbose_router_logger.debug(f"Adding {len(search_tools)} search tools to router")
# Convert search tools to the format expected by the router
router_search_tools: list = []
for tool in search_tools:
# Create dict that matches SearchToolTypedDict structure
router_search_tool: SearchToolTypedDict = { # type: ignore
"search_tool_id": tool.get("search_tool_id"),
"search_tool_name": tool.get("search_tool_name"),
"litellm_params": tool.get("litellm_params", {}),
"search_tool_info": tool.get("search_tool_info"),
}
router_search_tools.append(router_search_tool)
# Update the router's search_tools list
router_instance.search_tools = router_search_tools
verbose_router_logger.info(
f"Successfully updated router with {len(router_search_tools)} search tool(s)"
)
except Exception as e:
verbose_router_logger.exception(
f"Error updating router with search tools: {str(e)}"
)
raise e
@staticmethod
def get_matching_search_tools(
router_instance: Any,

View file

@ -69,8 +69,7 @@ class ListSearchToolsResponse(TypedDict):
class AvailableSearchProvider(TypedDict):
"""Information about an available search provider."""
provider: str
display_name: str
fields: List[dict]
provider_name: str
ui_friendly_name: str

View file

@ -41,6 +41,7 @@ import { cx } from "@/lib/cva.config";
import useFeatureFlags from "@/hooks/useFeatureFlags";
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
import OldTeams from "@/components/OldTeams";
import { SearchTools } from "@/components/search_tools";
function getCookie(name: string) {
// Safer cookie read + decoding; handles '=' inside values
@ -463,6 +464,8 @@ export default function CreateKeyPage() {
/>
) : page == "mcp-servers" ? (
<MCPServers accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "search-tools" ? (
<SearchTools accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tag-management" ? (
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "vector-stores" ? (

View file

@ -18,6 +18,7 @@ import {
ToolOutlined,
TagsOutlined,
BgColorsOutlined,
SearchOutlined,
} from "@ant-design/icons";
import { all_admin_roles, rolesWithWriteAccess, internalUserRoles, isAdminRole } from "../utils/roles";
import UsageIndicator from "./usage_indicator";
@ -110,6 +111,7 @@ const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defau
icon: <ToolOutlined style={{ fontSize: "18px" }} />,
children: [
{ key: "18", page: "mcp-servers", label: "MCP Servers", icon: <ToolOutlined style={{ fontSize: "18px" }} /> },
{ key: "28", page: "search-tools", label: "Search Tools", icon: <SearchOutlined style={{ fontSize: "18px" }} /> },
{
key: "21",
page: "vector-stores",

View file

@ -5421,6 +5421,226 @@ export const deleteMCPServer = async (accessToken: string, serverId: string) =>
}
};
// Search Tools API calls
export const fetchSearchTools = async (accessToken: string) => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/search_tools/list` : `/search_tools/list`;
console.log("Fetching search tools from:", url);
const response = await fetch(url, {
method: HTTP_REQUEST.GET,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
console.log("Fetched search tools:", data);
return data;
} catch (error) {
console.error("Failed to fetch search tools:", error);
throw error;
}
};
export const fetchSearchToolById = async (accessToken: string, searchToolId: string) => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/search_tools/${searchToolId}` : `/search_tools/${searchToolId}`;
console.log("Fetching search tool by ID from:", url);
const response = await fetch(url, {
method: HTTP_REQUEST.GET,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
console.log("Fetched search tool:", data);
return data;
} catch (error) {
console.error("Failed to fetch search tool:", error);
throw error;
}
};
export const createSearchTool = async (accessToken: string, formValues: Record<string, any>) => {
try {
console.log("Creating search tool with values:", formValues);
const url = proxyBaseUrl ? `${proxyBaseUrl}/search_tools` : `/search_tools`;
const response = await fetch(url, {
method: HTTP_REQUEST.POST,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
search_tool: formValues,
}),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
console.log("Created search tool:", data);
return data;
} catch (error) {
console.error("Failed to create search tool:", error);
throw error;
}
};
export const updateSearchTool = async (accessToken: string, searchToolId: string, formValues: Record<string, any>) => {
try {
console.log("Updating search tool with ID:", searchToolId, "values:", formValues);
const url = proxyBaseUrl ? `${proxyBaseUrl}/search_tools/${searchToolId}` : `/search_tools/${searchToolId}`;
const response = await fetch(url, {
method: HTTP_REQUEST.PUT,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
search_tool: formValues,
}),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
console.log("Updated search tool:", data);
return data;
} catch (error) {
console.error("Failed to update search tool:", error);
throw error;
}
};
export const deleteSearchTool = async (accessToken: string, searchToolId: string) => {
try {
const url = (proxyBaseUrl ? `${proxyBaseUrl}` : "") + `/search_tools/${searchToolId}`;
console.log("Deleting search tool:", searchToolId);
const response = await fetch(url, {
method: HTTP_REQUEST.DELETE,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
console.log("Deleted search tool:", data);
return data;
} catch (error) {
console.error("Failed to delete search tool:", error);
throw error;
}
};
export const fetchAvailableSearchProviders = async (accessToken: string) => {
try {
const url = proxyBaseUrl
? `${proxyBaseUrl}/search_tools/ui/available_providers`
: `/search_tools/ui/available_providers`;
console.log("Fetching available search providers from:", url);
const response = await fetch(url, {
method: HTTP_REQUEST.GET,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
console.log("Fetched available search providers:", data);
return data;
} catch (error) {
console.error("Failed to fetch available search providers:", error);
throw error;
}
};
export const testSearchToolConnection = async (
accessToken: string,
litellmParams: Record<string, any>
) => {
try {
const url = proxyBaseUrl
? `${proxyBaseUrl}/search_tools/test_connection`
: `/search_tools/test_connection`;
console.log("Testing search tool connection:", url);
const response = await fetch(url, {
method: HTTP_REQUEST.POST,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
litellm_params: litellmParams,
}),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
console.log("Test connection response:", data);
return data;
} catch (error) {
console.error("Failed to test search tool connection:", error);
throw error;
}
};
export const listMCPTools = async (accessToken: string, serverId: string, authValue?: string, serverAlias?: string) => {
try {
// Construct base URL
@ -6609,6 +6829,40 @@ export const vectorStoreSearchCall = async (
}
};
export const searchToolQueryCall = async (
accessToken: string,
searchToolName: string,
query: string,
maxResults?: number,
): Promise<any> => {
try {
const url = `${getProxyBaseUrl()}/v1/search/${searchToolName}`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: query,
max_results: maxResults || 5,
}),
});
if (!response.ok) {
const errorData = await response.text();
await handleError(errorData);
return null;
}
const data = await response.json();
return data;
} catch (error) {
console.error("Error querying search tool:", error);
throw error;
}
};
export const userAgentAnalyticsCall = async (
accessToken: string,
startTime: Date,

View file

@ -0,0 +1,289 @@
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 { useQuery } from "@tanstack/react-query";
import SearchConnectionTest from "./search_connection_test";
const { TextArea } = Input;
interface CreateSearchToolProps {
userRole: string;
accessToken: string | null;
onCreateSuccess: (newSearchTool: SearchTool) => void;
isModalVisible: boolean;
setModalVisible: (visible: boolean) => void;
}
const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
userRole,
accessToken,
onCreateSuccess,
isModalVisible,
setModalVisible,
}) => {
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
const [formValues, setFormValues] = useState<Record<string, any>>({});
const [isTestModalVisible, setIsTestModalVisible] = useState(false);
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [connectionTestId, setConnectionTestId] = useState<string>("");
// Fetch available search providers
const {
data: providersResponse,
isLoading: isLoadingProviders,
} = useQuery({
queryKey: ["searchProviders"],
queryFn: () => {
if (!accessToken) throw new Error("Access Token required");
return fetchAvailableSearchProviders(accessToken);
},
enabled: !!accessToken && isModalVisible,
}) as { data: { providers: AvailableSearchProvider[] }; isLoading: boolean };
const availableProviders = providersResponse?.providers || [];
const handleCreate = async (formValues: Record<string, any>) => {
setIsLoading(true);
try {
// Prepare the payload
const payload = {
search_tool_name: formValues.search_tool_name,
litellm_params: {
search_provider: formValues.search_provider,
api_key: formValues.api_key,
api_base: formValues.api_base,
timeout: formValues.timeout ? parseFloat(formValues.timeout) : undefined,
max_retries: formValues.max_retries ? parseInt(formValues.max_retries) : undefined,
},
search_tool_info: formValues.description
? {
description: formValues.description,
}
: undefined,
};
console.log(`Creating search tool with payload:`, payload);
if (accessToken != null) {
const response = await createSearchTool(accessToken, payload);
NotificationsManager.success("Search tool created successfully");
form.resetFields();
setFormValues({});
setModalVisible(false);
onCreateSuccess(response);
}
} catch (error) {
NotificationsManager.error("Error creating search tool: " + error);
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
setFormValues({});
setModalVisible(false);
};
const handleTestConnection = async () => {
try {
// Validate required fields for testing
await form.validateFields(["search_provider", "api_key"]);
setIsTestingConnection(true);
// Generate a new test ID (using timestamp for uniqueness)
setConnectionTestId(`test-${Date.now()}`);
// Show the modal with the fresh test
setIsTestModalVisible(true);
} catch (error) {
NotificationsManager.error("Please fill in Search Provider and API Key before testing");
}
};
// Clear formValues when modal closes to reset
React.useEffect(() => {
if (!isModalVisible) {
setFormValues({});
}
}, [isModalVisible]);
if (!isAdminRole(userRole)) {
return null;
}
return (
<Modal
title={
<div className="flex items-center space-x-3 pb-4 border-b border-gray-100">
<span className="text-2xl">🔍</span>
<h2 className="text-xl font-semibold text-gray-900">Add New Search Tool</h2>
</div>
}
open={isModalVisible}
width={800}
onCancel={handleCancel}
footer={null}
className="top-8"
styles={{
body: { padding: "24px" },
header: { padding: "24px 24px 0 24px", border: "none" },
}}
>
<div className="mt-6">
<Form
form={form}
onFinish={handleCreate}
onValuesChange={(_, allValues) => setFormValues(allValues)}
layout="vertical"
className="space-y-6"
>
<div className="grid grid-cols-1 gap-6">
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Search Tool Name
<Tooltip title="A unique name to identify this search tool configuration (e.g., 'perplexity-search', 'tavily-news-search').">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="search_tool_name"
rules={[
{ required: true, message: "Please enter a search tool name" },
{
pattern: /^[a-zA-Z0-9_-]+$/,
message: "Name can only contain letters, numbers, hyphens, and underscores",
},
]}
>
<TextInput
placeholder="e.g., perplexity-search, my-tavily-tool"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Search Provider
<Tooltip title="Select the search provider you want to use. Each provider has different capabilities and pricing.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="search_provider"
rules={[{ required: true, message: "Please select a search provider" }]}
>
<Select
placeholder="Select a search provider"
className="rounded-lg"
size="large"
loading={isLoadingProviders}
showSearch
optionFilterProp="children"
>
{availableProviders.map((provider) => (
<Select.Option key={provider.provider_name} value={provider.provider_name}>
{provider.ui_friendly_name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
API Key
<Tooltip title="The API key for authenticating with the search provider. This will be securely stored.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="api_key"
rules={[{ required: false, message: "Please enter an API key" }]}
>
<TextInput
type="password"
placeholder="Enter your API key"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={<span className="text-sm font-medium text-gray-700">Description (Optional)</span>}
name="description"
>
<TextArea
rows={3}
placeholder="Brief description of this search tool's purpose"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
</div>
<div className="flex justify-between items-center pt-6 border-t border-gray-100">
<Tooltip title="Get help on our github">
<Typography.Link href="https://github.com/BerriAI/litellm/issues" target="_blank">
Need Help?
</Typography.Link>
</Tooltip>
<div className="space-x-2">
<Button onClick={handleTestConnection} loading={isTestingConnection}>
Test Connection
</Button>
<Button loading={isLoading} type="submit">
Add Search Tool
</Button>
</div>
</div>
</Form>
</div>
{/* Test Connection Results Modal */}
<Modal
title="Connection Test Results"
open={isTestModalVisible}
onCancel={() => {
setIsTestModalVisible(false);
setIsTestingConnection(false);
}}
footer={[
<Button
key="close"
onClick={() => {
setIsTestModalVisible(false);
setIsTestingConnection(false);
}}
>
Close
</Button>,
]}
width={700}
>
{/* Only render the SearchConnectionTest when modal is visible and we have a test ID */}
{isTestModalVisible && accessToken && (
<SearchConnectionTest
key={connectionTestId}
litellmParams={{
search_provider: formValues.search_provider,
api_key: formValues.api_key,
api_base: formValues.api_base,
}}
accessToken={accessToken}
onTestComplete={() => setIsTestingConnection(false)}
/>
)}
</Modal>
</Modal>
);
};
export default CreateSearchTool;

View file

@ -0,0 +1,6 @@
export { default as SearchTools } from './search_tools';
export { SearchToolView } from './search_tool_view';
export { default as SearchConnectionTest } from './search_connection_test';
export { SearchToolTester } from './search_tool_tester';
export * from './types';

View file

@ -0,0 +1,272 @@
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";
const { Text } = Typography;
interface SearchConnectionTestProps {
litellmParams: Record<string, any>;
accessToken: string;
onTestComplete?: () => void;
}
const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({
litellmParams,
accessToken,
onTestComplete,
}) => {
const [isLoading, setIsLoading] = useState(true);
const [testResult, setTestResult] = useState<{
status: "success" | "error";
message: string;
test_query?: string;
results_count?: number;
error_type?: string;
} | null>(null);
const [showDetails, setShowDetails] = useState(false);
useEffect(() => {
const runTest = async () => {
setIsLoading(true);
try {
const result = await testSearchToolConnection(accessToken, litellmParams);
setTestResult(result);
if (result.status === "success") {
NotificationsManager.success("Connection test successful!");
}
} catch (error) {
setTestResult({
status: "error",
message: error instanceof Error ? error.message : "Unknown error occurred",
error_type: "NetworkError",
});
} finally {
setIsLoading(false);
if (onTestComplete) {
onTestComplete();
}
}
};
runTest();
}, [accessToken, litellmParams, onTestComplete]);
const getCleanErrorMessage = (errorMsg: string) => {
if (!errorMsg) return "Unknown error";
// Remove stack traces
const mainError = errorMsg.split("stack trace:")[0].trim();
// Remove litellm error prefixes
const cleanedError = mainError.replace(/^litellm\.(.*?)Error:\s*/, "");
// Remove AuthenticationError prefix if it exists
const finalError = cleanedError.replace(/^AuthenticationError:\s*/, "");
// If the error contains HTML (like a 401 page), extract just the key info
if (finalError.includes("<html>") || finalError.includes("<!DOCTYPE")) {
// Try to extract the title or main error from HTML
const titleMatch = finalError.match(/<title>(.*?)<\/title>/);
if (titleMatch) {
return titleMatch[1];
}
// If it's a 401 error
if (finalError.includes("401") || finalError.includes("Authorization Required")) {
return "Authentication failed: Invalid API key or credentials";
}
return "Authentication error - please check your API key";
}
// Limit very long error messages
if (finalError.length > 200) {
return finalError.substring(0, 200) + "...";
}
return finalError;
};
const errorMessage = testResult?.message ? getCleanErrorMessage(testResult.message) : "Unknown error";
if (isLoading) {
return (
<div style={{ padding: "24px", borderRadius: "8px", backgroundColor: "#fff" }}>
<div style={{ textAlign: "center", padding: "32px 20px" }}>
<div className="loading-spinner" style={{ marginBottom: "16px" }}>
<div
style={{
border: "3px solid #f3f3f3",
borderTop: "3px solid #1890ff",
borderRadius: "50%",
width: "30px",
height: "30px",
animation: "spin 1s linear infinite",
margin: "0 auto",
}}
/>
</div>
<Text style={{ fontSize: "16px" }}>
Testing connection to {litellmParams.search_provider || "search provider"}...
</Text>
<style jsx>{`
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
`}</style>
</div>
</div>
);
}
if (!testResult) {
return null;
}
return (
<div style={{ padding: "24px", borderRadius: "8px", backgroundColor: "#fff" }}>
{testResult.status === "success" ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "32px 20px" }}>
<div style={{ color: "#52c41a", fontSize: "24px", display: "flex", alignItems: "center" }}>
<svg
viewBox="64 64 896 896"
focusable="false"
data-icon="check-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
>
<path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 01-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z"></path>
</svg>
</div>
<div style={{ marginLeft: "12px" }}>
<Text type="success" style={{ fontSize: "18px", fontWeight: 500, display: "block" }}>
Connection to {litellmParams.search_provider} successful!
</Text>
{testResult.test_query && (
<Text style={{ fontSize: "14px", color: "#666", marginTop: "8px", display: "block" }}>
Test query: <code style={{ backgroundColor: "#f0f0f0", padding: "2px 6px", borderRadius: "4px" }}>{testResult.test_query}</code>
</Text>
)}
{testResult.results_count !== undefined && (
<Text style={{ fontSize: "14px", color: "#666", display: "block" }}>
Results retrieved: {testResult.results_count}
</Text>
)}
</div>
</div>
) : (
<>
<div>
<div style={{ display: "flex", alignItems: "center", marginBottom: "20px" }}>
<WarningOutlined style={{ color: "#ff4d4f", fontSize: "24px", marginRight: "12px" }} />
<Text type="danger" style={{ fontSize: "18px", fontWeight: 500 }}>
Connection to {litellmParams.search_provider || "search provider"} failed
</Text>
</div>
<div
style={{
backgroundColor: "#fff2f0",
border: "1px solid #ffccc7",
borderRadius: "8px",
padding: "16px",
marginBottom: "20px",
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.03)",
}}
>
<Text strong style={{ display: "block", marginBottom: "8px" }}>
Error:{" "}
</Text>
<Text type="danger" style={{ fontSize: "14px", lineHeight: "1.5" }}>
{errorMessage}
</Text>
{testResult.error_type && (
<div style={{ marginTop: "8px" }}>
<Text style={{ fontSize: "13px", color: "#666" }}>
Error type:{" "}
<code style={{ backgroundColor: "#ffebee", padding: "2px 6px", borderRadius: "4px", color: "#d32f2f" }}>
{testResult.error_type}
</code>
</Text>
</div>
)}
{testResult.message && (
<div style={{ marginTop: "12px" }}>
<Button
type="link"
onClick={() => setShowDetails(!showDetails)}
style={{ paddingLeft: 0, height: "auto" }}
>
{showDetails ? "Hide Details" : "Show Details"}
</Button>
</div>
)}
</div>
{showDetails && (
<div style={{ marginBottom: "20px" }}>
<Text strong style={{ display: "block", marginBottom: "8px", fontSize: "15px" }}>
Full Error Details
</Text>
<pre
style={{
backgroundColor: "#f5f5f5",
padding: "16px",
borderRadius: "8px",
fontSize: "13px",
maxHeight: "200px",
overflow: "auto",
border: "1px solid #e8e8e8",
lineHeight: "1.5",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{testResult.message}
</pre>
</div>
)}
<div
style={{
backgroundColor: "#fffbf0",
border: "1px solid #ffe58f",
borderLeft: "4px solid #faad14",
borderRadius: "8px",
padding: "16px",
}}
>
<Text strong style={{ display: "block", marginBottom: "8px", color: "#d48806" }}>
Troubleshooting tips:
</Text>
<ul style={{ margin: "8px 0", paddingLeft: "20px", color: "#ad6800" }}>
<li style={{ marginBottom: "6px" }}>Verify your API key is correct and active</li>
<li style={{ marginBottom: "6px" }}>Check if the search provider service is operational</li>
<li style={{ marginBottom: "6px" }}>Ensure you have sufficient credits/quota with the provider</li>
<li style={{ marginBottom: "6px" }}>Review the provider's documentation for any additional requirements</li>
</ul>
</div>
</div>
</>
)}
<Divider style={{ margin: "24px 0 16px" }} />
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Button type="link" href="https://docs.litellm.ai/docs/search" target="_blank" icon={<InfoCircleOutlined />}>
View Search Documentation
</Button>
</div>
</div>
);
};
export default SearchConnectionTest;

View file

@ -0,0 +1,94 @@
import { ColumnDef } from "@tanstack/react-table";
import { SearchTool } from "./types";
import { Icon } from "@tremor/react";
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
import { Tooltip } from "antd";
export const searchToolColumns = (
onView: (searchToolId: string) => void,
onEdit: (searchToolId: string) => void,
onDelete: (searchToolId: string) => void,
availableProviders: Array<{ provider_name: string; ui_friendly_name: string }>,
): ColumnDef<SearchTool>[] => [
{
accessorKey: "search_tool_id",
header: "Search Tool ID",
cell: ({ row }) => (
<button
onClick={() => onView(row.original.search_tool_id!)}
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left w-full truncate whitespace-nowrap cursor-pointer max-w-[15ch]"
>
{row.original.search_tool_id?.slice(0, 7)}...
</button>
),
},
{
accessorKey: "search_tool_name",
header: "Name",
cell: ({ getValue }) => (
<span className="font-medium">{getValue() as string}</span>
),
},
{
id: "provider",
header: "Provider",
cell: ({ row }) => {
const provider = row.original.litellm_params.search_provider;
const providerInfo = availableProviders.find(p => p.provider_name === provider);
const displayName = providerInfo?.ui_friendly_name || provider;
return (
<span className="text-sm">
{displayName}
</span>
);
},
},
{
header: "Created At",
accessorKey: "created_at",
sortingFn: "datetime",
cell: ({ row }) => {
const tool = row.original;
return (
<span className="text-xs">
{tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"}
</span>
);
},
},
{
header: "Updated At",
accessorKey: "updated_at",
sortingFn: "datetime",
cell: ({ row }) => {
const tool = row.original;
return (
<span className="text-xs">
{tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"}
</span>
);
},
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Icon
icon={PencilAltIcon}
size="sm"
onClick={() => onEdit(row.original.search_tool_id!)}
className="cursor-pointer"
/>
<Icon
icon={TrashIcon}
size="sm"
onClick={() => onDelete(row.original.search_tool_id!)}
className="cursor-pointer"
/>
</div>
),
},
];

View file

@ -0,0 +1,344 @@
import React, { useState } from "react";
import { Button, Input, Typography, Spin, message } from "antd";
import { SearchOutlined, LoadingOutlined } from "@ant-design/icons";
import { searchToolQueryCall } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import { Card, Title as TremorTitle } from "@tremor/react";
const { Text } = Typography;
interface SearchResult {
title: string;
url: string;
snippet: string;
}
interface SearchToolQueryResponse {
results: SearchResult[];
}
interface SearchToolTesterProps {
searchToolName: string;
accessToken: string;
className?: string;
}
export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolName, accessToken, className = "" }) => {
const [query, setQuery] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [searchHistory, setSearchHistory] = useState<
{
query: string;
response: SearchToolQueryResponse | null;
timestamp: number;
latency?: number;
}[]
>([]);
const [expandedResults, setExpandedResults] = useState<Record<string, boolean>>({});
const [isInputFocused, setIsInputFocused] = useState(false);
const handleSearch = async () => {
if (!query.trim()) {
message.warning("Please enter a search query");
return;
}
setIsLoading(true);
const startTime = performance.now();
try {
const response = await searchToolQueryCall(accessToken, searchToolName, query);
const endTime = performance.now();
const latency = Math.round(endTime - startTime);
const historyEntry = {
query,
response,
timestamp: Date.now(),
latency,
};
setSearchHistory((prev) => [historyEntry, ...prev]);
// Don't clear query after search so user can modify it
} catch (error) {
console.error("Error querying search tool:", error);
NotificationsManager.fromBackend("Failed to query search tool");
} finally {
setIsLoading(false);
}
};
const formatTimestamp = (timestamp: number): string => {
return new Date(timestamp).toLocaleString();
};
const clearHistory = () => {
setSearchHistory([]);
setExpandedResults({});
NotificationsManager.success("Search history cleared");
};
const toggleResultExpansion = (historyIndex: number, resultIndex: number) => {
const key = `${historyIndex}-${resultIndex}`;
setExpandedResults((prev) => ({
...prev,
[key]: !prev[key],
}));
};
const antIcon = <LoadingOutlined style={{ fontSize: 24 }} spin />;
const latestResults = searchHistory.length > 0 ? searchHistory[0] : null;
return (
<Card className="mt-6">
<div className="mb-6">
<TremorTitle>Test Search Tool</TremorTitle>
</div>
<div className="flex flex-col" style={{ minHeight: "600px" }}>
{/* Search Bar at Top */}
<div className="mb-6">
<div className="flex items-stretch gap-3">
<div
className="flex items-center flex-1 bg-white rounded-lg px-4 transition-all duration-200"
style={{
border: isInputFocused ? "2px solid #3b82f6" : "2px solid #e5e7eb",
boxShadow: isInputFocused ? "0 0 0 3px rgba(59, 130, 246, 0.1)" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
height: "48px"
}}
>
<SearchOutlined className="text-gray-400 mr-3" style={{ fontSize: "18px" }} />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
handleSearch();
}
}}
placeholder="Enter your search query..."
disabled={isLoading}
bordered={false}
style={{ fontSize: "15px", padding: 0, height: "100%", boxShadow: "none" }}
/>
</div>
<Button
type="primary"
onClick={handleSearch}
disabled={isLoading || !query.trim()}
icon={<SearchOutlined />}
loading={isLoading}
style={{
height: "48px",
paddingLeft: "24px",
paddingRight: "24px",
borderRadius: "8px",
fontWeight: 500,
fontSize: "15px",
backgroundColor: isLoading || !query.trim() ? undefined : "#1890ff",
borderColor: isLoading || !query.trim() ? undefined : "#1890ff",
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)"
}}
>
Search
</Button>
</div>
</div>
{/* Results Area */}
<div className="flex-1">
{!latestResults && !isLoading ? (
<div className="h-full flex flex-col items-center justify-center p-8">
<div className="flex items-center justify-center w-24 h-24 rounded-full bg-gray-100 mb-6">
<SearchOutlined style={{ fontSize: "48px", color: "#9ca3af" }} />
</div>
<Text className="text-lg text-gray-600 font-medium">Test your search tool</Text>
<Text className="text-sm text-gray-500 mt-2">Enter a query above to see search results</Text>
</div>
) : (
<div>
{isLoading && (
<div className="flex flex-col justify-center items-center py-16">
<Spin indicator={antIcon} />
<Text className="mt-4 text-gray-600 font-medium">Searching...</Text>
</div>
)}
{latestResults && !isLoading && (
<>
{/* Query Info Bar */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg" style={{ boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)" }}>
<div className="flex items-center justify-between">
<div className="flex-1">
<Text className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Search Query</Text>
<div className="text-base font-semibold text-gray-900 mt-1.5">{latestResults.query}</div>
</div>
<div className="text-right ml-4">
<Text className="text-xs text-gray-500">{formatTimestamp(latestResults.timestamp)}</Text>
<div className="flex items-center gap-3 mt-1">
<div className="text-sm font-semibold text-blue-600">
{latestResults.response?.results?.length || 0} {latestResults.response?.results?.length === 1 ? 'result' : 'results'}
</div>
{latestResults.latency !== undefined && (
<>
<span className="text-gray-400"></span>
<div className="text-sm font-semibold text-green-600">
{latestResults.latency}ms
</div>
</>
)}
</div>
</div>
</div>
</div>
{/* Search Results */}
{latestResults.response && latestResults.response.results && latestResults.response.results.length > 0 ? (
<div className="space-y-3">
{latestResults.response.results.map((result, resultIndex) => {
const isResultExpanded = expandedResults[`0-${resultIndex}`] || false;
return (
<div
key={resultIndex}
className="bg-white border border-gray-200 rounded-lg overflow-hidden transition-all duration-200"
style={{ boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)" }}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)";
e.currentTarget.style.borderColor = "#e0e7ff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
e.currentTarget.style.borderColor = "#e5e7eb";
}}
>
<div className="p-5">
{/* Title and External Link */}
<div className="flex items-start justify-between gap-3 mb-2">
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
className="text-lg font-semibold text-blue-600 hover:text-blue-700 flex-1 leading-snug"
style={{ textDecoration: "none" }}
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = "underline")}
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = "none")}
>
{result.title}
</a>
<Button
type="text"
size="small"
className="flex-shrink-0"
icon={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
}
onClick={() => window.open(result.url, "_blank")}
style={{ color: "#6b7280" }}
/>
</div>
{/* URL */}
<div className="text-sm text-green-700 mb-3 truncate font-medium">{result.url}</div>
{/* Snippet Preview */}
<div className="text-sm text-gray-700 leading-relaxed">
{isResultExpanded ? result.snippet : `${result.snippet.substring(0, 200)}${result.snippet.length > 200 ? '...' : ''}`}
</div>
{/* Expand/Collapse */}
{result.snippet.length > 200 && (
<Button
type="link"
size="small"
className="mt-3 p-0 h-auto"
onClick={() => toggleResultExpansion(0, resultIndex)}
style={{
fontSize: "13px",
fontWeight: 500,
color: "#3b82f6"
}}
>
{isResultExpanded ? "Show less" : "Show more"}
</Button>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-12 bg-gray-50 border border-gray-200 rounded-lg">
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 mx-auto mb-4">
<SearchOutlined style={{ fontSize: "24px", color: "#9ca3af" }} />
</div>
<Text className="text-gray-600 font-medium">No results found</Text>
<Text className="text-sm text-gray-500 mt-1">Try a different search query</Text>
</div>
)}
</>
)}
{/* Search History Sidebar */}
{searchHistory.length > 1 && (
<div className="mt-8 pt-6 border-t border-gray-200">
<div className="flex items-center justify-between mb-4">
<Text className="text-sm font-semibold text-gray-700">Previous Searches</Text>
<Button
onClick={clearHistory}
size="small"
type="link"
style={{
fontSize: "13px",
fontWeight: 500,
}}
>
Clear All
</Button>
</div>
<div className="space-y-2">
{searchHistory.slice(1, 6).map((entry, index) => (
<div
key={index + 1}
className="p-3 bg-gray-50 border border-gray-200 rounded-lg cursor-pointer transition-all duration-200 hover:bg-gray-100 hover:border-gray-300"
onClick={() => {
setQuery(entry.query);
}}
>
<div className="text-sm font-medium text-gray-800 truncate">{entry.query}</div>
<div className="text-xs text-gray-500 mt-1.5 flex items-center gap-2">
<span className="font-medium text-blue-600">
{entry.response?.results?.length || 0} {entry.response?.results?.length === 1 ? 'result' : 'results'}
</span>
{entry.latency !== undefined && (
<>
<span></span>
<span className="font-medium text-green-600">
{entry.latency}ms
</span>
</>
)}
<span></span>
<span>{formatTimestamp(entry.timestamp)}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</Card>
);
};
export default SearchToolTester;

View file

@ -0,0 +1,127 @@
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 { Button as AntdButton } from "antd";
import { SearchToolTester } from "./search_tool_tester";
interface SearchToolViewProps {
searchTool: SearchTool;
onBack: () => void;
isEditing: boolean;
accessToken: string | null;
availableProviders: AvailableSearchProvider[];
}
export const SearchToolView: React.FC<SearchToolViewProps> = ({
searchTool,
onBack,
isEditing,
accessToken,
availableProviders,
}) => {
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
const copyToClipboard = async (text: string | null | undefined, key: string) => {
const success = await utilCopyToClipboard(text);
if (success) {
setCopiedStates((prev) => ({ ...prev, [key]: true }));
setTimeout(() => {
setCopiedStates((prev) => ({ ...prev, [key]: false }));
}, 2000);
}
};
const getProviderDisplayName = (providerName: string) => {
const provider = availableProviders.find(p => p.provider_name === providerName);
return provider?.ui_friendly_name || providerName;
};
return (
<div className="p-4 max-w-full">
<div className="flex justify-between items-center mb-6">
<div>
<Button icon={ArrowLeftIcon} variant="light" className="mb-4" onClick={onBack}>
Back to All Search Tools
</Button>
<div className="flex items-center cursor-pointer">
<Title>{searchTool.search_tool_name}</Title>
<AntdButton
type="text"
size="small"
icon={copiedStates["search-tool-name"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
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"
}`}
/>
</div>
<div className="flex items-center cursor-pointer">
<Text className="text-gray-500 font-mono">{searchTool.search_tool_id}</Text>
<AntdButton
type="text"
size="small"
icon={copiedStates["search-tool-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
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"
}`}
/>
</div>
</div>
</div>
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
<Card>
<Text>Provider</Text>
<div className="mt-2">
<Title>{getProviderDisplayName(searchTool.litellm_params.search_provider)}</Title>
</div>
</Card>
<Card>
<Text>API Key</Text>
<div className="mt-2">
<Text>{searchTool.litellm_params.api_key ? "****" : "Not set"}</Text>
</div>
</Card>
<Card>
<Text>Created At</Text>
<div className="mt-2">
<Text>
{searchTool.created_at ? new Date(searchTool.created_at).toLocaleString() : "Unknown"}
</Text>
</div>
</Card>
</Grid>
{searchTool.search_tool_info?.description && (
<Card className="mt-6">
<Text>Description</Text>
<div className="mt-2">
<Text>{searchTool.search_tool_info.description}</Text>
</div>
</Card>
)}
{/* Search Tool Tester */}
<div className="mt-6">
{accessToken && (
<SearchToolTester
searchToolName={searchTool.search_tool_name}
accessToken={accessToken}
/>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,292 @@
import React, { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { Modal, Form, Input, Select } from "antd";
import { Button, Title, Text, Grid, Col } from "@tremor/react";
import { DataTable } from "../view_logs/table";
import { searchToolColumns } from "./search_tool_columns";
import {
fetchSearchTools,
updateSearchTool,
deleteSearchTool,
fetchAvailableSearchProviders,
} from "../networking";
import { SearchTool, AvailableSearchProvider } from "./types";
import { isAdminRole } from "@/utils/roles";
import NotificationsManager from "../molecules/notifications_manager";
import { SearchToolView } from "./search_tool_view";
import CreateSearchTool from "./create_search_tool";
interface SearchToolsProps {
accessToken: string | null;
userRole: string | null;
userID: string | null;
}
const DeleteModal: React.FC<{
isModalOpen: boolean;
title: string;
confirmDelete: () => void;
cancelDelete: () => void;
}> = ({ isModalOpen, title, confirmDelete, cancelDelete }) => {
if (!isModalOpen) return null;
return (
<Modal open={isModalOpen} onOk={confirmDelete} okType="danger" onCancel={cancelDelete}>
<Grid numItems={1} className="gap-2 w-full">
<Title>{title}</Title>
<Col numColSpan={1}>
<p>Are you sure you want to delete this search tool?</p>
</Col>
</Grid>
</Modal>
);
};
const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID }) => {
const {
data: searchTools,
isLoading: isLoadingTools,
refetch,
} = useQuery({
queryKey: ["searchTools"],
queryFn: () => {
if (!accessToken) throw new Error("Access Token required");
return fetchSearchTools(accessToken).then((res) => res.search_tools || []);
},
enabled: !!accessToken,
}) as { data: SearchTool[]; isLoading: boolean; refetch: () => void };
const {
data: providersResponse,
isLoading: isLoadingProviders,
} = useQuery({
queryKey: ["searchProviders"],
queryFn: () => {
if (!accessToken) throw new Error("Access Token required");
return fetchAvailableSearchProviders(accessToken);
},
enabled: !!accessToken,
}) as { data: { providers: AvailableSearchProvider[] }; isLoading: boolean };
const availableProviders = providersResponse?.providers || [];
// State
const [toolIdToDelete, setToolToDelete] = useState<string | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedToolId, setSelectedToolId] = useState<string | null>(null);
const [editTool, setEditTool] = useState(false);
const [isCreateModalVisible, setCreateModalVisible] = useState(false);
const [isEditModalVisible, setEditModalVisible] = useState(false);
const [form] = Form.useForm();
const columns = React.useMemo(
() =>
searchToolColumns(
(toolId: string) => {
setSelectedToolId(toolId);
setEditTool(false);
},
(toolId: string) => {
const tool = searchTools?.find((t) => t.search_tool_id === toolId);
if (tool) {
form.setFieldsValue({
search_tool_name: tool.search_tool_name,
search_provider: tool.litellm_params.search_provider,
api_key: tool.litellm_params.api_key,
api_base: tool.litellm_params.api_base,
timeout: tool.litellm_params.timeout,
max_retries: tool.litellm_params.max_retries,
description: tool.search_tool_info?.description,
});
setSelectedToolId(toolId);
setEditModalVisible(true);
}
},
handleDelete,
availableProviders,
),
[availableProviders, searchTools],
);
function handleDelete(toolId: string) {
setToolToDelete(toolId);
setIsDeleteModalOpen(true);
}
const confirmDelete = async () => {
if (toolIdToDelete == null || accessToken == null) {
return;
}
try {
await deleteSearchTool(accessToken, toolIdToDelete);
NotificationsManager.success("Deleted search tool successfully");
refetch();
} catch (error) {
console.error("Error deleting the search tool:", error);
NotificationsManager.error("Failed to delete search tool");
}
setIsDeleteModalOpen(false);
setToolToDelete(null);
};
const cancelDelete = () => {
setIsDeleteModalOpen(false);
setToolToDelete(null);
};
const handleCreateSuccess = (newSearchTool: SearchTool) => {
setCreateModalVisible(false);
refetch();
};
const handleEditSubmit = async () => {
if (!accessToken || !selectedToolId) return;
try {
const values = await form.validateFields();
const searchToolData = {
search_tool_name: values.search_tool_name,
litellm_params: {
search_provider: values.search_provider,
api_key: values.api_key,
api_base: values.api_base,
timeout: values.timeout ? parseFloat(values.timeout) : undefined,
max_retries: values.max_retries ? parseInt(values.max_retries) : undefined,
},
search_tool_info: values.description ? {
description: values.description,
} : undefined,
};
await updateSearchTool(accessToken, selectedToolId, searchToolData);
NotificationsManager.success("Search tool updated successfully");
setEditModalVisible(false);
form.resetFields();
setSelectedToolId(null);
refetch();
} catch (error) {
console.error("Failed to update search tool:", error);
NotificationsManager.error("Failed to update search tool");
}
};
const renderEditForm = () => (
<Form form={form} layout="vertical">
<Form.Item
name="search_tool_name"
label="Search Tool Name"
rules={[{ required: true, message: "Please enter a search tool name" }]}
>
<Input placeholder="e.g., my-perplexity-search" />
</Form.Item>
<Form.Item
name="search_provider"
label="Search Provider"
rules={[{ required: true, message: "Please select a search provider" }]}
>
<Select placeholder="Select a search provider" loading={isLoadingProviders}>
{availableProviders.map((provider) => (
<Select.Option key={provider.provider_name} value={provider.provider_name}>
{provider.ui_friendly_name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="api_key" label="API Key" extra="API key for the search provider">
<Input.Password placeholder="Enter API key" />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} placeholder="Description of this search tool" />
</Form.Item>
</Form>
);
if (!accessToken || !userRole || !userID) {
console.log("Missing required authentication parameters", { accessToken, userRole, userID });
return <div className="p-6 text-center text-gray-500">Missing required authentication parameters.</div>;
}
const ToolsTab = () =>
selectedToolId ? (
<SearchToolView
searchTool={
searchTools?.find((tool: SearchTool) => tool.search_tool_id === selectedToolId) || {
search_tool_id: "",
search_tool_name: "",
litellm_params: {
search_provider: "",
},
}
}
onBack={() => {
setEditTool(false);
setSelectedToolId(null);
refetch();
}}
isEditing={editTool}
accessToken={accessToken}
availableProviders={availableProviders}
/>
) : (
<div className="w-full h-full">
<div className="w-full px-6 mt-6">
<DataTable
data={searchTools || []}
columns={columns}
renderSubComponent={() => <div></div>}
getRowCanExpand={() => false}
isLoading={isLoadingTools}
noDataMessage="No search tools configured"
/>
</div>
</div>
);
return (
<div className="w-full h-full p-6">
<DeleteModal
isModalOpen={isDeleteModalOpen}
title="Delete Search Tool"
confirmDelete={confirmDelete}
cancelDelete={cancelDelete}
/>
<CreateSearchTool
userRole={userRole}
accessToken={accessToken}
onCreateSuccess={handleCreateSuccess}
isModalVisible={isCreateModalVisible}
setModalVisible={setCreateModalVisible}
/>
{/* Edit Modal */}
<Modal
title="Edit Search Tool"
open={isEditModalVisible}
onOk={handleEditSubmit}
onCancel={() => {
setEditModalVisible(false);
form.resetFields();
setSelectedToolId(null);
}}
width={600}
>
{renderEditForm()}
</Modal>
<Title>Search Tools</Title>
<Text className="text-tremor-content mt-2">Configure and manage your search providers</Text>
{isAdminRole(userRole) && (
<Button className="mt-4 mb-4" onClick={() => setCreateModalVisible(true)}>
+ Add New Search Tool
</Button>
)}
<ToolsTab />
</div>
);
};
export default SearchTools;

View file

@ -0,0 +1,36 @@
export interface SearchToolLiteLLMParams {
search_provider: string;
api_key?: string;
api_base?: string;
timeout?: number;
max_retries?: number;
[key: string]: any;
}
export interface SearchToolInfo {
description?: string;
[key: string]: any;
}
export interface SearchTool {
search_tool_id?: string;
search_tool_name: string;
litellm_params: SearchToolLiteLLMParams;
search_tool_info?: SearchToolInfo;
created_at?: string;
updated_at?: string;
}
export interface SearchToolsResponse {
search_tools: SearchTool[];
}
export interface AvailableSearchProvider {
provider_name: string;
ui_friendly_name: string;
}
export interface AvailableSearchProvidersResponse {
providers: AvailableSearchProvider[];
}