mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
[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:
parent
d8ea1665c7
commit
fc9aba279e
24 changed files with 1974 additions and 209 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
########################################################
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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" ? (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
36
ui/litellm-dashboard/src/components/search_tools/types.tsx
Normal file
36
ui/litellm-dashboard/src/components/search_tools/types.tsx
Normal 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[];
|
||||
}
|
||||
|
||||
Loading…
Add table
Reference in a new issue