mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
feat(websearch): let the model emit objective + multi-query search shape for providers that support it
The intercepted web search tool only carries a single query string, so search providers whose APIs take a natural-language objective plus multiple keyword queries (documented best practice for Parallel AI's v1 search) always receive a degraded single-query request. Widen the tool's input schema with optional objective and search_queries fields (query stays required), and forward the richer shape from the interception handler only to providers whose search config reports supports_rich_search_input(). Every other provider, and every model that keeps emitting just query, is byte-for-byte unchanged. - BaseSearchConfig.supports_rich_search_input() defaults False; ParallelAISearchConfig overrides True - handler trims search_queries to five (the provider cap) and never overrides an objective configured on the search tool's litellm_params - mocked tests cover schema exposure, extraction validation, provider gating, and the unchanged single-string path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d963e9fa6e
commit
5ef05b97c5
6 changed files with 750 additions and 178 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,50 @@ from typing import Any, Final
|
|||
|
||||
from litellm.constants import LITELLM_WEB_SEARCH_TOOL_NAME
|
||||
|
||||
_WEB_SEARCH_TOOL_DESCRIPTION: Final = (
|
||||
"Search the web for information. Use this when you need current "
|
||||
"information or answers to questions that require up-to-date data."
|
||||
)
|
||||
|
||||
|
||||
def _web_search_input_schema() -> dict[str, object]:
|
||||
"""
|
||||
JSON schema for the web search tool's input, shared by every tool format.
|
||||
|
||||
``query`` stays required so providers and callers that only understand a
|
||||
single query string keep working unchanged. ``objective`` and
|
||||
``search_queries`` are optional richer inputs; they are forwarded only to
|
||||
search providers that support them (see
|
||||
``BaseSearchConfig.supports_rich_search_input``).
|
||||
"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query to execute",
|
||||
},
|
||||
"objective": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Natural-language description of the goal behind the "
|
||||
"search, including any source or freshness requirements."
|
||||
),
|
||||
},
|
||||
"search_queries": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
"Two to five short keyword queries (3-6 words each) "
|
||||
"covering different angles of the objective, e.g. varying "
|
||||
"names, synonyms, or phrasings. Provide together with "
|
||||
"objective for the best results."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
|
||||
def get_litellm_web_search_tool() -> dict[str, object]:
|
||||
"""
|
||||
|
|
@ -33,20 +77,8 @@ def get_litellm_web_search_tool() -> dict[str, object]:
|
|||
"""
|
||||
return {
|
||||
"name": LITELLM_WEB_SEARCH_TOOL_NAME,
|
||||
"description": (
|
||||
"Search the web for information. Use this when you need current "
|
||||
"information or answers to questions that require up-to-date data."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query to execute",
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
"description": _WEB_SEARCH_TOOL_DESCRIPTION,
|
||||
"input_schema": _web_search_input_schema(),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -65,20 +97,8 @@ def get_litellm_web_search_tool_openai() -> dict[str, object]:
|
|||
"type": "function",
|
||||
"function": {
|
||||
"name": LITELLM_WEB_SEARCH_TOOL_NAME,
|
||||
"description": (
|
||||
"Search the web for information. Use this when you need current "
|
||||
"information or answers to questions that require up-to-date data."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query to execute",
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
"description": _WEB_SEARCH_TOOL_DESCRIPTION,
|
||||
"parameters": _web_search_input_schema(),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -98,20 +118,8 @@ def get_litellm_web_search_tool_responses() -> dict[str, object]:
|
|||
return {
|
||||
"type": "function",
|
||||
"name": LITELLM_WEB_SEARCH_TOOL_NAME,
|
||||
"description": (
|
||||
"Search the web for information. Use this when you need current "
|
||||
"information or answers to questions that require up-to-date data."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query to execute",
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
"description": _WEB_SEARCH_TOOL_DESCRIPTION,
|
||||
"parameters": _web_search_input_schema(),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,18 @@ class BaseSearchConfig:
|
|||
"""
|
||||
return "Unknown Search Provider"
|
||||
|
||||
def supports_rich_search_input(self) -> bool:
|
||||
"""
|
||||
Whether this provider's search API accepts a natural-language
|
||||
objective plus multiple keyword queries in one request.
|
||||
|
||||
Integrations that collect the richer shape (e.g. websearch
|
||||
interception) forward ``query`` as a list plus an ``objective``
|
||||
optional param to providers that return True; every other provider
|
||||
keeps receiving the single query string.
|
||||
"""
|
||||
return False
|
||||
|
||||
def get_http_method(self) -> Literal["GET", "POST"]:
|
||||
"""
|
||||
Get HTTP method for search requests.
|
||||
|
|
@ -185,12 +197,20 @@ class BaseSearchConfig:
|
|||
|
||||
def sign_request(
|
||||
self,
|
||||
headers: dict[str, str], # mutable-ok: matches the request header dict every other hook on this base takes
|
||||
optional_params: dict[str, object], # mutable-ok: matches every other hook on this base
|
||||
request_data: dict[str, object] | list[dict[str, object]], # mutable-ok: transform_search_request's body
|
||||
headers: dict[
|
||||
str, str
|
||||
], # mutable-ok: matches the request header dict every other hook on this base takes
|
||||
optional_params: dict[
|
||||
str, object
|
||||
], # mutable-ok: matches every other hook on this base
|
||||
request_data: (
|
||||
dict[str, object] | list[dict[str, object]]
|
||||
), # mutable-ok: transform_search_request's body
|
||||
api_base: str,
|
||||
api_key: str | None = None,
|
||||
) -> tuple[dict[str, str], bytes | None]: # mutable-ok: the handler passes these headers straight to httpx
|
||||
) -> tuple[
|
||||
dict[str, str], bytes | None
|
||||
]: # mutable-ok: the handler passes these headers straight to httpx
|
||||
"""
|
||||
OPTIONAL
|
||||
|
||||
|
|
@ -250,7 +270,9 @@ class BaseSearchConfig:
|
|||
Returns:
|
||||
Dict with request data
|
||||
"""
|
||||
raise NotImplementedError("transform_search_request must be implemented by provider")
|
||||
raise NotImplementedError(
|
||||
"transform_search_request must be implemented by provider"
|
||||
)
|
||||
|
||||
def transform_search_response(
|
||||
self,
|
||||
|
|
@ -262,7 +284,9 @@ class BaseSearchConfig:
|
|||
Transform provider-specific Search response to standard format.
|
||||
Override in provider-specific implementations.
|
||||
"""
|
||||
raise NotImplementedError("transform_search_response must be implemented by provider")
|
||||
raise NotImplementedError(
|
||||
"transform_search_response must be implemented by provider"
|
||||
)
|
||||
|
||||
def get_error_class(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -90,6 +90,11 @@ class ParallelAISearchConfig(BaseSearchConfig):
|
|||
def ui_friendly_name() -> str:
|
||||
return "Parallel AI"
|
||||
|
||||
def supports_rich_search_input(self) -> bool:
|
||||
# The v1 search API takes `objective` + multiple `search_queries`
|
||||
# natively; sending both is the documented best practice.
|
||||
return True
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict,
|
||||
|
|
@ -105,7 +110,9 @@ class ParallelAISearchConfig(BaseSearchConfig):
|
|||
default_api_base=self.PARALLEL_AI_API_BASE,
|
||||
)
|
||||
if not resolved_api_key:
|
||||
raise ValueError("PARALLEL_API_KEY is not set. Set `PARALLEL_API_KEY` environment variable.")
|
||||
raise ValueError(
|
||||
"PARALLEL_API_KEY is not set. Set `PARALLEL_API_KEY` environment variable."
|
||||
)
|
||||
headers["x-api-key"] = resolved_api_key
|
||||
headers["Content-Type"] = "application/json"
|
||||
return headers
|
||||
|
|
@ -117,7 +124,11 @@ class ParallelAISearchConfig(BaseSearchConfig):
|
|||
data: dict | list[dict] | None = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
resolved_api_base: Final = api_base or get_secret_str("PARALLEL_AI_API_BASE") or self.PARALLEL_AI_API_BASE
|
||||
resolved_api_base: Final = (
|
||||
api_base
|
||||
or get_secret_str("PARALLEL_AI_API_BASE")
|
||||
or self.PARALLEL_AI_API_BASE
|
||||
)
|
||||
|
||||
trimmed: Final = resolved_api_base.rstrip("/")
|
||||
if trimmed.endswith("/v1/search"):
|
||||
|
|
@ -184,7 +195,9 @@ class ParallelAISearchConfig(BaseSearchConfig):
|
|||
advanced_settings["location"] = params.pop("location")
|
||||
|
||||
if "max_chars_per_result" in params:
|
||||
advanced_settings["excerpt_settings"] = {"max_chars_per_result": params.pop("max_chars_per_result")}
|
||||
advanced_settings["excerpt_settings"] = {
|
||||
"max_chars_per_result": params.pop("max_chars_per_result")
|
||||
}
|
||||
|
||||
if "fetch_policy" in params:
|
||||
advanced_settings["fetch_policy"] = params.pop("fetch_policy")
|
||||
|
|
@ -277,4 +290,6 @@ class ParallelAISearchConfig(BaseSearchConfig):
|
|||
}
|
||||
)
|
||||
|
||||
return SearchResponse.model_validate(MappingProxyType({"results": results, "object": "search", **extra_fields}))
|
||||
return SearchResponse.model_validate(
|
||||
MappingProxyType({"results": results, "object": "search", **extra_fields})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,22 @@ class AnthropicServerToolUseBlock(BaseModel):
|
|||
input: AnthropicSearchQuery
|
||||
|
||||
|
||||
class RichWebSearchInput(TypedDict, total=False):
|
||||
"""
|
||||
Optional richer search shape a model may emit alongside ``query``.
|
||||
|
||||
Collected from the intercepted tool call and forwarded only to search
|
||||
providers whose config reports ``supports_rich_search_input()``; every
|
||||
other provider keeps receiving the single ``query`` string.
|
||||
"""
|
||||
|
||||
objective: str
|
||||
"""Natural-language description of the goal behind the search."""
|
||||
|
||||
search_queries: list[str]
|
||||
"""Two to five short keyword queries covering different angles."""
|
||||
|
||||
|
||||
class WebSearchInterceptionConfig(TypedDict, total=False):
|
||||
"""
|
||||
Configuration parameters for WebSearchInterceptionLogger.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
"""
|
||||
Unit tests for the rich web-search input shape (objective + search_queries).
|
||||
|
||||
The intercepted web search tool exposes optional `objective` and
|
||||
`search_queries` fields alongside the required single `query` string. The
|
||||
handler forwards the richer shape only to search providers whose config
|
||||
reports supports_rich_search_input(); every other provider keeps receiving
|
||||
the single query string the model also provided.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.websearch_interception.handler import (
|
||||
WebSearchInterceptionLogger,
|
||||
)
|
||||
from litellm.integrations.websearch_interception.tools import (
|
||||
get_litellm_web_search_tool,
|
||||
get_litellm_web_search_tool_openai,
|
||||
get_litellm_web_search_tool_responses,
|
||||
)
|
||||
from litellm.llms.base_llm.search.transformation import BaseSearchConfig, SearchResponse
|
||||
from litellm.llms.parallel_ai.search.transformation import ParallelAISearchConfig
|
||||
|
||||
RICH_INPUT = {
|
||||
"query": "stripe node sdk v14 authentication",
|
||||
"objective": "Find the current authentication flow for the Stripe Node SDK v14",
|
||||
"search_queries": ["stripe node sdk v14 auth", "stripe api key rotation node"],
|
||||
}
|
||||
|
||||
|
||||
def _search_response() -> SearchResponse:
|
||||
return SearchResponse(object="search", results=[])
|
||||
|
||||
|
||||
def _mock_router(search_provider: str) -> MagicMock:
|
||||
"""Router stub exposing one configured search tool."""
|
||||
router = MagicMock()
|
||||
router.search_tools = [
|
||||
{
|
||||
"search_tool_name": "test-search",
|
||||
"litellm_params": {
|
||||
"search_provider": search_provider,
|
||||
"api_key": "sk-test",
|
||||
},
|
||||
}
|
||||
]
|
||||
return router
|
||||
|
||||
|
||||
class TestToolSchema:
|
||||
def test_all_formats_expose_rich_fields_and_keep_query_required(self):
|
||||
anthropic_schema = get_litellm_web_search_tool()["input_schema"]
|
||||
openai_schema = get_litellm_web_search_tool_openai()["function"]["parameters"]
|
||||
responses_schema = get_litellm_web_search_tool_responses()["parameters"]
|
||||
|
||||
for schema in (anthropic_schema, openai_schema, responses_schema):
|
||||
assert schema["required"] == ["query"]
|
||||
assert "objective" in schema["properties"]
|
||||
assert "search_queries" in schema["properties"]
|
||||
assert schema["properties"]["search_queries"]["type"] == "array"
|
||||
|
||||
|
||||
class TestRichInputExtraction:
|
||||
def test_extracts_objective_and_queries(self):
|
||||
rich = WebSearchInterceptionLogger._rich_search_input(RICH_INPUT)
|
||||
assert rich == {
|
||||
"objective": RICH_INPUT["objective"],
|
||||
"search_queries": RICH_INPUT["search_queries"],
|
||||
}
|
||||
|
||||
def test_returns_none_when_only_query_present(self):
|
||||
assert (
|
||||
WebSearchInterceptionLogger._rich_search_input({"query": "plain"}) is None
|
||||
)
|
||||
|
||||
def test_returns_none_for_non_mapping_input(self):
|
||||
assert WebSearchInterceptionLogger._rich_search_input(None) is None
|
||||
assert WebSearchInterceptionLogger._rich_search_input("query") is None
|
||||
|
||||
def test_drops_invalid_queries_and_caps_at_five(self):
|
||||
rich = WebSearchInterceptionLogger._rich_search_input(
|
||||
{
|
||||
"query": "q",
|
||||
"search_queries": ["a", "", 3, "b", "c", "d", "e", "f"],
|
||||
}
|
||||
)
|
||||
assert rich == {"search_queries": ["a", "b", "c", "d", "e"]}
|
||||
|
||||
def test_ignores_string_valued_search_queries(self):
|
||||
# A string is a Sequence; it must not be treated as a list of queries.
|
||||
assert (
|
||||
WebSearchInterceptionLogger._rich_search_input(
|
||||
{"query": "q", "search_queries": "not a list"}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
class TestProviderSupport:
|
||||
def test_parallel_ai_supports_rich_input(self):
|
||||
assert ParallelAISearchConfig().supports_rich_search_input() is True
|
||||
|
||||
def test_base_config_defaults_to_unsupported(self):
|
||||
assert BaseSearchConfig().supports_rich_search_input() is False
|
||||
|
||||
def test_unknown_provider_is_unsupported(self):
|
||||
assert WebSearchInterceptionLogger._provider_supports_rich_search(None) is False
|
||||
assert (
|
||||
WebSearchInterceptionLogger._provider_supports_rich_search("not_a_provider")
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
class TestExecuteSearchShape:
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_shape_reaches_supporting_provider(self, monkeypatch):
|
||||
"""Parallel AI receives the query list plus objective."""
|
||||
import litellm
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
logger = WebSearchInterceptionLogger()
|
||||
mock_asearch = AsyncMock(return_value=_search_response())
|
||||
monkeypatch.setattr(proxy_server, "llm_router", _mock_router("parallel_ai"))
|
||||
monkeypatch.setattr(litellm, "asearch", mock_asearch)
|
||||
|
||||
rich = WebSearchInterceptionLogger._rich_search_input(RICH_INPUT)
|
||||
await logger._execute_search(RICH_INPUT["query"], rich=rich)
|
||||
|
||||
call_kwargs = mock_asearch.await_args.kwargs
|
||||
assert call_kwargs["query"] == RICH_INPUT["search_queries"]
|
||||
assert call_kwargs["objective"] == RICH_INPUT["objective"]
|
||||
assert call_kwargs["search_provider"] == "parallel_ai"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_string_only_provider_keeps_single_query(self, monkeypatch):
|
||||
"""A provider without rich support receives the plain query string."""
|
||||
import litellm
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
logger = WebSearchInterceptionLogger()
|
||||
mock_asearch = AsyncMock(return_value=_search_response())
|
||||
monkeypatch.setattr(proxy_server, "llm_router", _mock_router("perplexity"))
|
||||
monkeypatch.setattr(litellm, "asearch", mock_asearch)
|
||||
|
||||
rich = WebSearchInterceptionLogger._rich_search_input(RICH_INPUT)
|
||||
await logger._execute_search(RICH_INPUT["query"], rich=rich)
|
||||
|
||||
call_kwargs = mock_asearch.await_args.kwargs
|
||||
assert call_kwargs["query"] == RICH_INPUT["query"]
|
||||
assert "objective" not in call_kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_string_callers_unchanged(self, monkeypatch):
|
||||
"""No rich input: behavior is identical to before for any provider."""
|
||||
import litellm
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
logger = WebSearchInterceptionLogger()
|
||||
mock_asearch = AsyncMock(return_value=_search_response())
|
||||
monkeypatch.setattr(proxy_server, "llm_router", _mock_router("parallel_ai"))
|
||||
monkeypatch.setattr(litellm, "asearch", mock_asearch)
|
||||
|
||||
await logger._execute_search("plain query")
|
||||
|
||||
call_kwargs = mock_asearch.await_args.kwargs
|
||||
assert call_kwargs["query"] == "plain query"
|
||||
assert "objective" not in call_kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configured_objective_not_overwritten(self, monkeypatch):
|
||||
"""An objective set on the search tool's litellm_params wins over the model's."""
|
||||
import litellm
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
logger = WebSearchInterceptionLogger()
|
||||
router = _mock_router("parallel_ai")
|
||||
router.search_tools[0]["litellm_params"]["objective"] = "configured objective"
|
||||
mock_asearch = AsyncMock(return_value=_search_response())
|
||||
monkeypatch.setattr(proxy_server, "llm_router", router)
|
||||
monkeypatch.setattr(litellm, "asearch", mock_asearch)
|
||||
|
||||
rich = WebSearchInterceptionLogger._rich_search_input(RICH_INPUT)
|
||||
await logger._execute_search(RICH_INPUT["query"], rich=rich)
|
||||
|
||||
call_kwargs = mock_asearch.await_args.kwargs
|
||||
assert call_kwargs["objective"] == "configured objective"
|
||||
Loading…
Add table
Reference in a new issue