Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 123 additions & 34 deletions litellm/integrations/websearch_interception/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ async def try_short_circuit_search(
messages: List[Dict],
tools: Optional[List[Dict]],
custom_llm_provider: Optional[str],
kwargs: Optional[dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""
Short-circuit web-search-only requests by executing the search directly.
Expand Down Expand Up @@ -176,7 +177,10 @@ async def try_short_circuit_search(
# Execute search — keep the structured SearchResponse so the native
# block can carry per-result url/title/page_age.
try:
search_result_text, structured = await self._execute_search(query)
if kwargs is None:
search_result_text, structured = await self._execute_search(query)
else:
search_result_text, structured = await self._execute_search(query, kwargs=kwargs)
except Exception as e:
verbose_logger.error(f"WebSearchInterception: Short-circuit search failed: {e}")
search_result_text, structured = f"Search failed: {e}", None
Comment on lines 179 to 186

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Auth exception swallowed in short-circuit path

_execute_search can now raise ProxyException from _authorize_search_tool when a key or team lacks permission for a search tool. That exception is a subclass of Exception and therefore falls into the existing catch-all block, which converts it into a "Search failed: ..." text string. The client receives a 200 OK with a failed search message instead of a proper authorization error — and the proxy logs the rejection at error level rather than surfacing it to the caller. The agentic-loop path (gathered search_tasks) lets the exception propagate normally, so there is already an inconsistency between the two paths.

Expand Down Expand Up @@ -936,7 +940,7 @@ async def _build_anthropic_request_patch(
query = tool_call["input"].get("query")
if query:
verbose_logger.debug(f"WebSearchInterception: Queuing search for query='{query}'")
search_tasks.append(self._execute_search(query))
search_tasks.append(self._execute_search(query, kwargs=kwargs))
else:
verbose_logger.debug(f"WebSearchInterception: Tool call {tool_call['id']} has no query")
# Add empty result for tools without query
Expand Down Expand Up @@ -1009,7 +1013,9 @@ async def _build_anthropic_request_patch(
)
return patch, structured_results

async def _execute_search(self, query: str) -> Tuple[str, Optional[SearchResponse]]:
async def _execute_search(
self, query: str, kwargs: Optional[dict[str, Any]] = None

@mateo-berri mateo-berri Jul 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm noticing many introduced kwargs. Are these necessary? Can we just introduce named params?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes - this fixes the issue where litellm params were not being passed through. since litellm params can be a broad dict, this is appropriate imo

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a qq

Also, I'm seeing: FAILED tests/test_litellm/proxy/common_utils/test_key_rotation_e2e.py::TestDeprecatedKeyLookupDbE2E::test_deprecated_key_grace_period_cache_hit_path - httpx.ConnectError: All connection attempts failed. Deterministic failure or flake?


this seems like flake, since it's unrelated to the change here

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the issue _build_chat_completion_request_patch brings it in as kwargs.

if the tests pass, LGTM

) -> Tuple[str, Optional[SearchResponse]]:
"""
Execute a single web search using router's search tools.

Expand All @@ -1031,36 +1037,13 @@ async def _execute_search(self, query: str) -> Tuple[str, Optional[SearchRespons
)
llm_router = None

# Determine search provider from router's search_tools
search_tool = self._select_search_tool_from_router(llm_router=llm_router)
search_provider: Optional[str] = None
if llm_router is not None and hasattr(llm_router, "search_tools"):
if self.search_tool_name:
# Find specific search tool by name
matching_tools = [
tool
for tool in llm_router.search_tools
if tool.get("search_tool_name") == self.search_tool_name
]
if matching_tools:
search_tool = matching_tools[0]
search_provider = search_tool.get("litellm_params", {}).get("search_provider")
verbose_logger.debug(
f"WebSearchInterception: Found search tool '{self.search_tool_name}' "
f"with provider '{search_provider}'"
)
else:
verbose_logger.debug(
f"WebSearchInterception: Search tool '{self.search_tool_name}' not found in router, "
"falling back to first available or perplexity"
)

# If no specific tool or not found, use first available
if not search_provider and llm_router.search_tools:
first_tool = llm_router.search_tools[0]
search_provider = first_tool.get("litellm_params", {}).get("search_provider")
verbose_logger.debug(
f"WebSearchInterception: Using first available search tool with provider '{search_provider}'"
)
search_litellm_params: dict[str, Any] = {}
if search_tool is not None:
await self._authorize_search_tool(search_tool=search_tool, kwargs=kwargs)
search_litellm_params = dict(search_tool.get("litellm_params", {}) or {})
search_provider = search_litellm_params.get("search_provider")

# Fallback to perplexity if no router or no search tools configured
if not search_provider:
Expand All @@ -1073,7 +1056,12 @@ async def _execute_search(self, query: str) -> Tuple[str, Optional[SearchRespons
verbose_logger.debug(
f"WebSearchInterception: Executing search for '{query}' using provider '{search_provider}'"
)
result = await litellm.asearch(query=query, search_provider=search_provider)
search_kwargs = {
key: value
for key, value in search_litellm_params.items()
if key != "search_provider" and value is not None
}
result = await litellm.asearch(query=query, search_provider=search_provider, **search_kwargs)
Comment thread
veria-ai[bot] marked this conversation as resolved.

# Format using transformation function
search_result_text = WebSearchTransformation.format_search_response(result)
Expand All @@ -1086,6 +1074,107 @@ async def _execute_search(self, query: str) -> Tuple[str, Optional[SearchRespons
verbose_logger.error(f"WebSearchInterception: Search failed for '{query}': {str(e)}")
raise

async def _authorize_search_tool(
self,
search_tool: dict[str, Any],
kwargs: Optional[dict[str, Any]],
) -> None:
search_tool_name = search_tool.get("search_tool_name")
if not isinstance(search_tool_name, str) or not search_tool_name:
return

user_api_key_auth = self._get_user_api_key_auth_from_kwargs(kwargs)
if user_api_key_auth is None:
return

from litellm.proxy.auth.auth_checks import (
can_key_call_search_tool,
can_team_call_search_tool,
get_team_object,
)

await can_key_call_search_tool(
search_tool_name=search_tool_name,
valid_token=user_api_key_auth,
)

team_id = getattr(user_api_key_auth, "team_id", None)
if team_id:
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)

team_object = await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=getattr(user_api_key_auth, "parent_otel_span", None),
proxy_logging_obj=proxy_logging_obj,
)
await can_team_call_search_tool(
search_tool_name=search_tool_name,
team_object=team_object,
)

@staticmethod
def _get_user_api_key_auth_from_kwargs(kwargs: Optional[dict[str, Any]]) -> Any:
if not kwargs:
return None

for metadata_key in ("metadata", "litellm_metadata"):
metadata = kwargs.get(metadata_key)
if isinstance(metadata, dict) and metadata.get("user_api_key_auth") is not None:
return metadata["user_api_key_auth"]

litellm_params = kwargs.get("litellm_params")
if not isinstance(litellm_params, dict):
return None

for metadata_key in ("metadata", "litellm_metadata"):
metadata = litellm_params.get(metadata_key)
if isinstance(metadata, dict) and metadata.get("user_api_key_auth") is not None:
return metadata["user_api_key_auth"]

return None

def _select_search_tool_from_router(self, llm_router: Any) -> Optional[dict[str, Any]]:
if llm_router is None or not hasattr(llm_router, "search_tools"):
return None
search_tools = list(getattr(llm_router, "search_tools") or [])
return self._select_search_tool_from_list(search_tools=search_tools, source="router")

def _select_search_tool_from_list(
self,
search_tools: list[dict[str, Any]],
source: str,
) -> Optional[dict[str, Any]]:
if self.search_tool_name:
matching_tools = [tool for tool in search_tools if tool.get("search_tool_name") == self.search_tool_name]
if matching_tools:
search_provider = (matching_tools[0].get("litellm_params", {}) or {}).get("search_provider")
verbose_logger.debug(
f"WebSearchInterception: Found search tool '{self.search_tool_name}' "
f"from {source} with provider '{search_provider}'"
)
return matching_tools[0]
verbose_logger.debug(
f"WebSearchInterception: Search tool '{self.search_tool_name}' not found in {source}, "
"falling back to first available or perplexity"
)

if search_tools:
first_tool = search_tools[0]
search_provider = (first_tool.get("litellm_params", {}) or {}).get("search_provider")
verbose_logger.debug(
f"WebSearchInterception: Using first available search tool from {source} "
f"with provider '{search_provider}'"
)
return first_tool

return None

async def _execute_chat_completion_agentic_loop(
self,
model: str,
Expand Down Expand Up @@ -1145,7 +1234,7 @@ async def _build_chat_completion_request_patch(

if query:
verbose_logger.debug(f"WebSearchInterception: Queuing search for query='{query}'")
search_tasks.append(self._execute_search(query))
search_tasks.append(self._execute_search(query, kwargs=kwargs))
else:
verbose_logger.debug(f"WebSearchInterception: Tool call {tool_call.get('id')} has no query")
# Add empty result for tools without query
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ async def _try_websearch_short_circuit(
tools: Optional[List[Dict]],
custom_llm_provider: Optional[str],
stream: Optional[bool],
kwargs: Optional[dict] = None,
) -> Optional[Union[AnthropicMessagesResponse, AsyncIterator]]:
"""
Attempt to short-circuit a web-search-only request.
Expand Down Expand Up @@ -177,6 +178,7 @@ async def _try_websearch_short_circuit(
messages=messages,
tools=tools,
custom_llm_provider=custom_llm_provider,
kwargs=kwargs,
)
if response is not None:
anthropic_response = cast(AnthropicMessagesResponse, response)
Expand Down Expand Up @@ -292,6 +294,7 @@ async def anthropic_messages(
tools=tools,
custom_llm_provider=custom_llm_provider,
stream=original_stream,
kwargs={**kwargs, "metadata": metadata},
)
if short_circuit_response is not None:
return short_circuit_response
Expand Down
51 changes: 34 additions & 17 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6382,7 +6382,6 @@ async def _init_agents_in_db(self, prisma_client: PrismaClient):
async def _init_search_tools_in_db(self, prisma_client: PrismaClient):
"""
Initialize search tools from database into the router on startup.
Only updates router if there are tools in the database, otherwise preserves config-loaded tools.
"""
global llm_router

Expand All @@ -6392,33 +6391,51 @@ async def _init_search_tools_in_db(self, prisma_client: PrismaClient):
from litellm.router_utils.search_api_router import SearchAPIRouter

try:
search_tools = await SearchToolRegistry.get_all_search_tools_from_db(prisma_client=prisma_client)
db_search_tools = await SearchToolRegistry.get_all_search_tools_from_db(prisma_client=prisma_client)

verbose_proxy_logger.info(f"Loading {len(search_tools)} search tool(s) from database into router")
parsed_tools = self.parse_search_tools(self.get_config_state())
config_search_tools = parsed_tools or []

# Only update router if there are tools in the database
# This prevents overwriting config-loaded tools with an empty list
if len(search_tools) > 0:
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"
)
search_tools = self._merge_config_and_db_search_tools(
config_search_tools=config_search_tools,
db_search_tools=[dict(tool) for tool in db_search_tools],
)

verbose_proxy_logger.info(
f"Loading {len(search_tools)} search tool(s) into router "
f"({len(config_search_tools)} from config, {len(db_search_tools)} from database)"
)

if llm_router is not None and search_tools:
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")
elif llm_router is not None:
verbose_proxy_logger.debug("No search tools found in config or database, skipping router update")
else:
verbose_proxy_logger.debug(
"No search tools found in database, keeping config-loaded search tools (if any)"
"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(str(e))
)

@staticmethod
def _merge_config_and_db_search_tools(
config_search_tools: list[SearchToolTypedDict],
db_search_tools: list[dict[str, Any]],
) -> list[dict[str, Any]]:
db_tool_names = {tool.get("search_tool_name") for tool in db_search_tools}
return [
*[
dict(config_search_tool)
for config_search_tool in config_search_tools
if config_search_tool.get("search_tool_name") not in db_tool_names
],
*db_search_tools,
]

async def _init_pass_through_endpoints_in_db(self):
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
initialize_pass_through_endpoints_in_db,
Expand Down
Loading
Loading