Merge pull request #20371 from BerriAI/litellm_config_search_tools

[Feature] Include Config Defined Search Tools in /search_tools/list
This commit is contained in:
yuneng-jiang
2026-02-03 20:00:21 -08:00
committed by GitHub
3 changed files with 617 additions and 7 deletions
@@ -48,7 +48,7 @@ def _convert_datetime_to_str(value: Union[datetime, str, None]) -> Union[str, No
)
async def list_search_tools():
"""
List all search tools that are available in the database.
List all search tools that are available in the database and config file.
Example Request:
```bash
@@ -71,38 +71,100 @@ async def list_search_tools():
"description": "Perplexity search tool"
},
"created_at": "2023-11-09T12:34:56.789Z",
"updated_at": "2023-11-09T12:34:56.789Z"
"updated_at": "2023-11-09T12:34:56.789Z",
"is_from_config": false
},
{
"search_tool_name": "config-search-tool",
"litellm_params": {
"search_provider": "tavily",
"api_key": "tvly-***"
},
"is_from_config": true
}
]
}
```
"""
from litellm.proxy.proxy_server import prisma_client
from litellm.litellm_core_utils.litellm_logging import _get_masked_values
from litellm.proxy.proxy_server import prisma_client, proxy_config
if prisma_client is None:
raise HTTPException(status_code=500, detail="Prisma client not initialized")
try:
search_tools = await SEARCH_TOOL_REGISTRY.get_all_search_tools_from_db(
search_tools_from_db = await SEARCH_TOOL_REGISTRY.get_all_search_tools_from_db(
prisma_client=prisma_client
)
db_tool_names = {
tool.get("search_tool_name") for tool in search_tools_from_db
}
search_tool_configs: List[SearchToolInfoResponse] = []
for search_tool in search_tools:
config_search_tools = []
try:
config = await proxy_config.get_config()
parsed_tools = proxy_config.parse_search_tools(config)
if parsed_tools:
config_search_tools = parsed_tools
except Exception as e:
verbose_proxy_logger.debug(
f"Could not get config-defined search tools: {e}"
)
for search_tool in config_search_tools:
tool_name = search_tool.get("search_tool_name")
if tool_name:
litellm_params_dict = dict(search_tool.get("litellm_params", {}))
masked_litellm_params_dict = _get_masked_values(
litellm_params_dict,
unmasked_length=4,
number_of_asterisks=4,
)
search_tool_configs.append(
SearchToolInfoResponse(
search_tool_id=None,
search_tool_name=tool_name,
litellm_params=masked_litellm_params_dict,
search_tool_info=search_tool.get("search_tool_info"),
created_at=None,
updated_at=None,
is_from_config=True,
)
)
search_tool_configs = [
tool for tool in search_tool_configs
if tool.get("search_tool_name") not in db_tool_names
]
for search_tool in search_tools_from_db:
litellm_params_dict = dict(search_tool.get("litellm_params", {}))
masked_litellm_params_dict = _get_masked_values(
litellm_params_dict,
unmasked_length=4,
number_of_asterisks=4,
)
search_tool_configs.append(
SearchToolInfoResponse(
search_tool_id=search_tool.get("search_tool_id"),
search_tool_name=search_tool.get("search_tool_name", ""),
litellm_params=dict(search_tool.get("litellm_params", {})),
litellm_params=masked_litellm_params_dict,
search_tool_info=search_tool.get("search_tool_info"),
created_at=_convert_datetime_to_str(search_tool.get("created_at")),
updated_at=_convert_datetime_to_str(search_tool.get("updated_at")),
is_from_config=False,
)
)
return ListSearchToolsResponse(search_tools=search_tool_configs)
except Exception as e:
verbose_proxy_logger.exception(f"Error getting search tools from db: {e}")
verbose_proxy_logger.exception(f"Error getting search tools: {e}")
raise HTTPException(status_code=500, detail=str(e))
@@ -382,6 +444,7 @@ async def get_search_tool_info(search_tool_id: str):
search_tool_info=result.get("search_tool_info"),
created_at=_convert_datetime_to_str(result.get("created_at")),
updated_at=_convert_datetime_to_str(result.get("updated_at")),
is_from_config=False, # This endpoint only returns DB tools
)
except HTTPException as e:
raise e
+1
View File
@@ -60,6 +60,7 @@ class SearchToolInfoResponse(TypedDict, total=False):
search_tool_info: Optional[dict]
created_at: Optional[str]
updated_at: Optional[str]
is_from_config: Optional[bool] # True if this tool is defined in config file, False if from DB
class ListSearchToolsResponse(TypedDict):
@@ -0,0 +1,546 @@
import os
import sys
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
sys.path.insert(
0, os.path.abspath("../../../../..")
) # Adds the parent directory to the system path
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
# Import proxy_server module first to ensure it's initialized
import litellm.proxy.proxy_server as ps
# Now we can safely import app
from litellm.proxy.proxy_server import app
client = TestClient(app)
@pytest.mark.asyncio
async def test_list_search_tools_db_only(monkeypatch):
"""Test listing search tools when only DB tools exist"""
# Mock DB tools
db_tools = [
{
"search_tool_id": "test-id-1",
"search_tool_name": "db-tool-1",
"litellm_params": {"search_provider": "perplexity", "api_key": "sk-test"},
"search_tool_info": {"description": "DB tool 1"},
"created_at": datetime(2023, 11, 9, 12, 34, 56),
"updated_at": datetime(2023, 11, 9, 13, 45, 12),
}
]
# Mock SearchToolRegistry
mock_registry = MagicMock()
mock_registry.get_all_search_tools_from_db = AsyncMock(return_value=db_tools)
with patch(
"litellm.proxy.search_endpoints.search_tool_management.SEARCH_TOOL_REGISTRY",
mock_registry,
):
# Mock prisma_client
mock_prisma = MagicMock()
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma):
# Mock proxy_config
mock_proxy_config = MagicMock()
mock_proxy_config.get_config = AsyncMock(return_value={})
mock_proxy_config.parse_search_tools = MagicMock(return_value=None)
with patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config):
# Mock auth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
test_client = TestClient(app)
response = test_client.get("/search_tools/list")
assert response.status_code == 200
data = response.json()
assert "search_tools" in data
assert len(data["search_tools"]) == 1
tool = data["search_tools"][0]
assert tool["search_tool_id"] == "test-id-1"
assert tool["search_tool_name"] == "db-tool-1"
assert tool["is_from_config"] is False
# Verify datetime conversion to ISO string
assert tool["created_at"] == "2023-11-09T12:34:56"
assert tool["updated_at"] == "2023-11-09T13:45:12"
# Verify masking of sensitive values
assert tool["litellm_params"]["api_key"] != "sk-test"
assert "****" in tool["litellm_params"]["api_key"]
assert tool["litellm_params"]["search_provider"] == "perplexity"
finally:
app.dependency_overrides.pop(user_api_key_auth, None)
@pytest.mark.asyncio
async def test_list_search_tools_config_only(monkeypatch):
"""Test listing search tools when only config tools exist"""
# Mock DB tools - empty
db_tools = []
# Mock config tools
config_tools = [
{
"search_tool_name": "config-tool-1",
"litellm_params": {"search_provider": "tavily", "api_key": "tvly-secret-key"},
"search_tool_info": {"description": "Config tool 1"},
}
]
# Mock SearchToolRegistry
mock_registry = MagicMock()
mock_registry.get_all_search_tools_from_db = AsyncMock(return_value=db_tools)
with patch(
"litellm.proxy.search_endpoints.search_tool_management.SEARCH_TOOL_REGISTRY",
mock_registry,
):
# Mock prisma_client
mock_prisma = MagicMock()
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma):
# Mock proxy_config
mock_proxy_config = MagicMock()
mock_proxy_config.get_config = AsyncMock(return_value={"search_tools": config_tools})
mock_proxy_config.parse_search_tools = MagicMock(return_value=config_tools)
with patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config):
# Mock auth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
test_client = TestClient(app)
response = test_client.get("/search_tools/list")
assert response.status_code == 200
data = response.json()
assert "search_tools" in data
assert len(data["search_tools"]) == 1
tool = data["search_tools"][0]
assert tool["search_tool_name"] == "config-tool-1"
assert tool["is_from_config"] is True
assert tool["search_tool_id"] is None
assert tool["created_at"] is None
assert tool["updated_at"] is None
# Verify masking
assert "tv****ey" in tool["litellm_params"]["api_key"]
finally:
app.dependency_overrides.pop(user_api_key_auth, None)
@pytest.mark.asyncio
async def test_list_search_tools_filters_duplicate_config_tools(monkeypatch):
"""
Test that config tools with the same name as DB tools are filtered out.
This tests the new filtering logic added in lines 139-142.
"""
# Mock DB tools
db_tools = [
{
"search_tool_id": "db-id-1",
"search_tool_name": "existing-tool",
"litellm_params": {"search_provider": "perplexity", "api_key": "sk-db"},
"search_tool_info": {"description": "DB tool"},
"created_at": datetime(2023, 11, 9, 12, 34, 56),
"updated_at": datetime(2023, 11, 9, 13, 45, 12),
}
]
# Mock config tools - one duplicate, one unique
config_tools = [
{
"search_tool_name": "existing-tool", # Duplicate - should be filtered
"litellm_params": {"search_provider": "tavily", "api_key": "tvly-config"},
"search_tool_info": {"description": "Config tool - duplicate"},
},
{
"search_tool_name": "unique-config-tool", # Unique - should be included
"litellm_params": {"search_provider": "tavily", "api_key": "tvly-unique"},
"search_tool_info": {"description": "Config tool - unique"},
},
]
# Mock SearchToolRegistry
mock_registry = MagicMock()
mock_registry.get_all_search_tools_from_db = AsyncMock(return_value=db_tools)
with patch(
"litellm.proxy.search_endpoints.search_tool_management.SEARCH_TOOL_REGISTRY",
mock_registry,
):
# Mock prisma_client
mock_prisma = MagicMock()
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma):
# Mock proxy_config
mock_proxy_config = MagicMock()
mock_proxy_config.get_config = AsyncMock(return_value={"search_tools": config_tools})
mock_proxy_config.parse_search_tools = MagicMock(return_value=config_tools)
with patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config):
# Mock auth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
test_client = TestClient(app)
response = test_client.get("/search_tools/list")
assert response.status_code == 200
data = response.json()
assert "search_tools" in data
# Should have 1 DB tool + 1 unique config tool (duplicate filtered out)
assert len(data["search_tools"]) == 2
# Verify DB tool is present
db_tool = next(
(t for t in data["search_tools"] if t["search_tool_name"] == "existing-tool"),
None,
)
assert db_tool is not None
assert db_tool["is_from_config"] is False
assert db_tool["search_tool_id"] == "db-id-1"
# Verify masking of sensitive values in DB tool
assert db_tool["litellm_params"]["api_key"] != "sk-db"
assert "****" in db_tool["litellm_params"]["api_key"]
assert db_tool["litellm_params"]["search_provider"] == "perplexity"
# Verify unique config tool is present
config_tool = next(
(t for t in data["search_tools"] if t["search_tool_name"] == "unique-config-tool"),
None,
)
assert config_tool is not None
assert config_tool["is_from_config"] is True
# Verify duplicate config tool is NOT present
duplicate_tool = next(
(
t
for t in data["search_tools"]
if t["search_tool_name"] == "existing-tool" and t["is_from_config"] is True
),
None,
)
assert duplicate_tool is None
finally:
app.dependency_overrides.pop(user_api_key_auth, None)
@pytest.mark.asyncio
async def test_list_search_tools_datetime_conversion(monkeypatch):
"""
Test that datetime objects in DB tools are properly converted to ISO format strings.
This tests the new datetime conversion logic using _convert_datetime_to_str.
"""
# Mock DB tools with datetime objects
db_tools = [
{
"search_tool_id": "test-id-1",
"search_tool_name": "datetime-test-tool",
"litellm_params": {"search_provider": "perplexity", "api_key": "sk-test"},
"search_tool_info": {"description": "Test tool"},
"created_at": datetime(2024, 1, 15, 10, 30, 45, 123456),
"updated_at": datetime(2024, 1, 16, 14, 20, 30, 789012),
},
{
"search_tool_id": "test-id-2",
"search_tool_name": "null-datetime-tool",
"litellm_params": {"search_provider": "tavily", "api_key": "tvly-test"},
"search_tool_info": None,
"created_at": None,
"updated_at": None,
},
{
"search_tool_id": "test-id-3",
"search_tool_name": "string-datetime-tool",
"litellm_params": {"search_provider": "perplexity", "api_key": "sk-test"},
"search_tool_info": {"description": "Already string"},
"created_at": "2024-01-17T08:15:00", # Already a string
"updated_at": "2024-01-18T09:25:00", # Already a string
},
]
# Mock SearchToolRegistry
mock_registry = MagicMock()
mock_registry.get_all_search_tools_from_db = AsyncMock(return_value=db_tools)
with patch(
"litellm.proxy.search_endpoints.search_tool_management.SEARCH_TOOL_REGISTRY",
mock_registry,
):
# Mock prisma_client
mock_prisma = MagicMock()
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma):
# Mock proxy_config
mock_proxy_config = MagicMock()
mock_proxy_config.get_config = AsyncMock(return_value={})
mock_proxy_config.parse_search_tools = MagicMock(return_value=None)
with patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config):
# Mock auth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
test_client = TestClient(app)
response = test_client.get("/search_tools/list")
assert response.status_code == 200
data = response.json()
assert "search_tools" in data
assert len(data["search_tools"]) == 3
# Test datetime conversion for tool 1
tool1 = next(
(t for t in data["search_tools"] if t["search_tool_name"] == "datetime-test-tool"),
None,
)
assert tool1 is not None
assert isinstance(tool1["created_at"], str)
assert tool1["created_at"] == "2024-01-15T10:30:45.123456"
assert isinstance(tool1["updated_at"], str)
assert tool1["updated_at"] == "2024-01-16T14:20:30.789012"
# Verify masking of sensitive values
assert tool1["litellm_params"]["api_key"] != "sk-test"
assert "****" in tool1["litellm_params"]["api_key"]
# Test None handling for tool 2
tool2 = next(
(t for t in data["search_tools"] if t["search_tool_name"] == "null-datetime-tool"),
None,
)
assert tool2 is not None
assert tool2["created_at"] is None
assert tool2["updated_at"] is None
# Verify masking of sensitive values
assert tool2["litellm_params"]["api_key"] != "tvly-test"
assert "****" in tool2["litellm_params"]["api_key"]
# Test string passthrough for tool 3
tool3 = next(
(t for t in data["search_tools"] if t["search_tool_name"] == "string-datetime-tool"),
None,
)
assert tool3 is not None
assert tool3["created_at"] == "2024-01-17T08:15:00"
assert tool3["updated_at"] == "2024-01-18T09:25:00"
# Verify masking of sensitive values
assert tool3["litellm_params"]["api_key"] != "sk-test"
assert "****" in tool3["litellm_params"]["api_key"]
finally:
app.dependency_overrides.pop(user_api_key_auth, None)
@pytest.mark.asyncio
async def test_list_search_tools_config_error_handling(monkeypatch):
"""Test that config errors are handled gracefully"""
# Mock DB tools
db_tools = [
{
"search_tool_id": "test-id-1",
"search_tool_name": "db-tool-1",
"litellm_params": {"search_provider": "perplexity", "api_key": "sk-test"},
"search_tool_info": {"description": "DB tool"},
"created_at": datetime(2023, 11, 9, 12, 34, 56),
"updated_at": datetime(2023, 11, 9, 13, 45, 12),
}
]
# Mock SearchToolRegistry
mock_registry = MagicMock()
mock_registry.get_all_search_tools_from_db = AsyncMock(return_value=db_tools)
with patch(
"litellm.proxy.search_endpoints.search_tool_management.SEARCH_TOOL_REGISTRY",
mock_registry,
):
# Mock prisma_client
mock_prisma = MagicMock()
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma):
# Mock proxy_config to raise an error
mock_proxy_config = MagicMock()
mock_proxy_config.get_config = AsyncMock(side_effect=Exception("Config error"))
with patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config):
# Mock auth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
# Should still succeed and return DB tools only
response = client.get("/search_tools/list")
assert response.status_code == 200
data = response.json()
assert "search_tools" in data
# Should only have DB tools since config failed
assert len(data["search_tools"]) == 1
assert data["search_tools"][0]["search_tool_name"] == "db-tool-1"
# Verify masking of sensitive values
assert data["search_tools"][0]["litellm_params"]["api_key"] != "sk-test"
assert "****" in data["search_tools"][0]["litellm_params"]["api_key"]
finally:
app.dependency_overrides.pop(user_api_key_auth, None)
@pytest.mark.asyncio
async def test_list_search_tools_no_prisma_client(monkeypatch):
"""Test error handling when prisma_client is None"""
with patch("litellm.proxy.proxy_server.prisma_client", None):
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
test_client = TestClient(app)
response = test_client.get("/search_tools/list")
assert response.status_code == 500
data = response.json()
assert "Prisma client not initialized" in data["detail"]
finally:
app.dependency_overrides.pop(user_api_key_auth, None)
@pytest.mark.asyncio
async def test_list_search_tools_db_masking_sensitive_values(monkeypatch):
"""
Test that sensitive values in DB search tools are properly masked.
This tests the new masking logic added for database search tools.
"""
# Mock DB tools with various sensitive fields
db_tools = [
{
"search_tool_id": "test-id-1",
"search_tool_name": "perplexity-tool",
"litellm_params": {
"search_provider": "perplexity",
"api_key": "pplx-sk-1234567890abcdef",
"api_base": "https://api.perplexity.ai",
},
"search_tool_info": {"description": "Perplexity tool"},
"created_at": datetime(2023, 11, 9, 12, 34, 56),
"updated_at": datetime(2023, 11, 9, 13, 45, 12),
},
{
"search_tool_id": "test-id-2",
"search_tool_name": "tavily-tool",
"litellm_params": {
"search_provider": "tavily",
"api_key": "tvly-secret-key-12345",
"api_base": "https://api.tavily.com",
},
"search_tool_info": {"description": "Tavily tool"},
"created_at": datetime(2023, 11, 9, 12, 34, 56),
"updated_at": datetime(2023, 11, 9, 13, 45, 12),
},
{
"search_tool_id": "test-id-3",
"search_tool_name": "tool-with-token",
"litellm_params": {
"search_provider": "custom",
"access_token": "token-abcdefghijklmnop",
"secret_key": "secret-xyz123",
},
"search_tool_info": {"description": "Tool with token"},
"created_at": datetime(2023, 11, 9, 12, 34, 56),
"updated_at": datetime(2023, 11, 9, 13, 45, 12),
},
{
"search_tool_id": "test-id-4",
"search_tool_name": "tool-with-non-sensitive",
"litellm_params": {
"search_provider": "custom",
"max_results": 10,
"timeout": 30,
},
"search_tool_info": {"description": "Tool without sensitive fields"},
"created_at": datetime(2023, 11, 9, 12, 34, 56),
"updated_at": datetime(2023, 11, 9, 13, 45, 12),
},
]
# Mock SearchToolRegistry
mock_registry = MagicMock()
mock_registry.get_all_search_tools_from_db = AsyncMock(return_value=db_tools)
with patch(
"litellm.proxy.search_endpoints.search_tool_management.SEARCH_TOOL_REGISTRY",
mock_registry,
):
# Mock prisma_client
mock_prisma = MagicMock()
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma):
# Mock proxy_config
mock_proxy_config = MagicMock()
mock_proxy_config.get_config = AsyncMock(return_value={})
mock_proxy_config.parse_search_tools = MagicMock(return_value=None)
with patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config):
# Mock auth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
test_client = TestClient(app)
response = test_client.get("/search_tools/list")
assert response.status_code == 200
data = response.json()
assert "search_tools" in data
assert len(data["search_tools"]) == 4
# Test tool 1: api_key should be masked
tool1 = next(
(t for t in data["search_tools"] if t["search_tool_name"] == "perplexity-tool"),
None,
)
assert tool1 is not None
assert tool1["litellm_params"]["api_key"] != "pplx-sk-1234567890abcdef"
assert "****" in tool1["litellm_params"]["api_key"]
assert tool1["litellm_params"]["search_provider"] == "perplexity"
assert tool1["litellm_params"]["api_base"] == "https://api.perplexity.ai"
# Test tool 2: api_key should be masked
tool2 = next(
(t for t in data["search_tools"] if t["search_tool_name"] == "tavily-tool"),
None,
)
assert tool2 is not None
assert tool2["litellm_params"]["api_key"] != "tvly-secret-key-12345"
assert "****" in tool2["litellm_params"]["api_key"]
assert tool2["litellm_params"]["search_provider"] == "tavily"
# Test tool 3: access_token and secret_key should be masked
tool3 = next(
(t for t in data["search_tools"] if t["search_tool_name"] == "tool-with-token"),
None,
)
assert tool3 is not None
assert tool3["litellm_params"]["access_token"] != "token-abcdefghijklmnop"
assert "****" in tool3["litellm_params"]["access_token"]
assert tool3["litellm_params"]["secret_key"] != "secret-xyz123"
assert "****" in tool3["litellm_params"]["secret_key"]
# Test tool 4: non-sensitive fields should remain unmasked
tool4 = next(
(t for t in data["search_tools"] if t["search_tool_name"] == "tool-with-non-sensitive"),
None,
)
assert tool4 is not None
assert tool4["litellm_params"]["max_results"] == 10
assert tool4["litellm_params"]["timeout"] == 30
assert tool4["litellm_params"]["search_provider"] == "custom"
finally:
app.dependency_overrides.pop(user_api_key_auth, None)