mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
Merge 28d468174a into f6571a653f
This commit is contained in:
commit
09edbd3e50
12 changed files with 732 additions and 1 deletions
5
litellm/llms/serpapi/search/__init__.py
Normal file
5
litellm/llms/serpapi/search/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""SerpApi Search API module."""
|
||||
|
||||
from litellm.llms.serpapi.search.transformation import SerpApiSearchConfig
|
||||
|
||||
__all__ = ["SerpApiSearchConfig"] # mutable-ok: matches neighboring search provider export modules
|
||||
298
litellm/llms/serpapi/search/transformation.py
Normal file
298
litellm/llms/serpapi/search/transformation.py
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
"""
|
||||
Calls SerpApi's Search API endpoint.
|
||||
|
||||
SerpApi API Reference: https://serpapi.com/search-api
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.search.transformation import (
|
||||
BaseSearchConfig,
|
||||
SearchResponse,
|
||||
SearchResult,
|
||||
)
|
||||
|
||||
_SERPAPI_PARAMS_KEY: Final = "_serpapi_params"
|
||||
_SERPAPI_REQUEST_KEYS: Final = frozenset(("engine", "q", "num", "gl"))
|
||||
_SEARCH_RESULT_RESERVED_FIELDS: Final = frozenset(SearchResult.model_fields)
|
||||
_SerpApiUrlParams: Final = TypeAdapter(dict[str, str | int | float | bool | list[str]])
|
||||
_StringTuple: Final = TypeAdapter(tuple[str, ...])
|
||||
_StringFrozenSet: Final = TypeAdapter(frozenset[str])
|
||||
_SerpApiResultExtras: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
class _SerpApiOrganicResult(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="allow")
|
||||
|
||||
title: str | None = None
|
||||
link: str | None = None
|
||||
snippet: str | None = None
|
||||
date: str | None = None
|
||||
|
||||
|
||||
class _SerpApiSearchMetadata(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class _SerpApiSearchResponse(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
|
||||
organic_results: tuple[_SerpApiOrganicResult, ...] = ()
|
||||
search_metadata: _SerpApiSearchMetadata | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class SerpApiSearchConfig(BaseSearchConfig):
|
||||
SERPAPI_API_BASE: Final = "https://serpapi.com/search.json"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._max_results: int | None = None
|
||||
|
||||
@staticmethod
|
||||
def ui_friendly_name() -> str:
|
||||
return "SerpApi"
|
||||
|
||||
def get_http_method(self) -> Literal["GET", "POST"]:
|
||||
"""
|
||||
SerpApi uses GET requests for search.
|
||||
"""
|
||||
return "GET"
|
||||
|
||||
def _resolve_api_key(
|
||||
self,
|
||||
api_key: str | None,
|
||||
) -> str:
|
||||
"""
|
||||
Resolve a caller or configured SerpApi key.
|
||||
"""
|
||||
resolved_key: Final = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=None,
|
||||
key_env_vars=("SERPAPI_KEY", "SERPAPI_API_KEY"),
|
||||
base_env_var=None,
|
||||
default_api_base=self.SERPAPI_API_BASE,
|
||||
)
|
||||
if not resolved_key:
|
||||
raise ValueError("SERPAPI_KEY is not set. Set `SERPAPI_KEY` or `SERPAPI_API_KEY` environment variable.")
|
||||
return resolved_key
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: Mapping[str, str],
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
**kwargs: object, # kwargs-ok: BaseSearchConfig provider interface forwards extensible request options
|
||||
) -> dict[str, str]: # mutable-ok: BaseSearchConfig handler contract requires a mutable header dict
|
||||
"""
|
||||
Validate SerpApi credentials and return request headers.
|
||||
"""
|
||||
self._resolve_api_key(api_key=api_key)
|
||||
resolved_headers: Final = MappingProxyType({**headers, "Content-Type": "application/json"})
|
||||
return resolved_headers.copy()
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: str | None,
|
||||
optional_params: dict[str, object], # mutable-ok: mirrors the BaseSearchConfig override contract
|
||||
data: dict[str, object] # mutable-ok: mirrors the BaseSearchConfig override contract
|
||||
| list[dict[str, object]]
|
||||
| None = None,
|
||||
api_key: str | None = None,
|
||||
**kwargs: object, # kwargs-ok: BaseSearchConfig provider interface forwards extensible request options
|
||||
) -> str:
|
||||
"""
|
||||
Get complete URL for Search endpoint with query parameters.
|
||||
|
||||
SerpApi uses GET requests and includes api_key in query params.
|
||||
"""
|
||||
resolved_base: Final = self.SERPAPI_API_BASE
|
||||
if not isinstance(data, Mapping) or _SERPAPI_PARAMS_KEY not in data:
|
||||
return resolved_base
|
||||
|
||||
try:
|
||||
params: Final = _SerpApiUrlParams.validate_python(data[_SERPAPI_PARAMS_KEY])
|
||||
except ValidationError as exc:
|
||||
invalid_params: Final = ", ".join(
|
||||
sorted(frozenset(str(error["loc"][0]) for error in exc.errors() if error["loc"]))
|
||||
)
|
||||
raise ValueError(
|
||||
f"Invalid SerpApi URL parameter value for: {invalid_params or 'request parameters'}"
|
||||
) from None
|
||||
resolved_key: Final = self._resolve_api_key(api_key=api_key)
|
||||
query_params: Final = tuple(
|
||||
(
|
||||
key,
|
||||
str(value).lower() if isinstance(value, bool) else value,
|
||||
)
|
||||
for key, value in params.items()
|
||||
) + (("api_key", resolved_key),)
|
||||
query_string: Final = urlencode(query_params, doseq=True)
|
||||
separator: Final = "&" if "?" in resolved_base else "?"
|
||||
return f"{resolved_base}{separator}{query_string}"
|
||||
|
||||
def transform_search_request(
|
||||
self,
|
||||
query: str | Sequence[str],
|
||||
optional_params: Mapping[str, object],
|
||||
**kwargs: object, # kwargs-ok: BaseSearchConfig provider interface forwards extensible request options
|
||||
) -> dict[str, object]: # mutable-ok: BaseSearchConfig request contract requires a JSON dict
|
||||
"""
|
||||
Transform Search request to SerpApi format.
|
||||
|
||||
Transforms unified spec parameters:
|
||||
- query -> q
|
||||
- max_results -> num
|
||||
- search_domain_filter -> q (append site: filters)
|
||||
- country -> gl
|
||||
|
||||
Args:
|
||||
query: Search query (string or sequence of strings)
|
||||
optional_params: Optional parameters for the request
|
||||
|
||||
Returns:
|
||||
Dict containing SerpApi query parameters for URL construction
|
||||
"""
|
||||
base_query: Final = " ".join(query) if not isinstance(query, str) else query
|
||||
raw_domains: Final = optional_params.get("search_domain_filter")
|
||||
domains: Final = _StringTuple.validate_python(raw_domains or ())
|
||||
resolved_query: Final = self._append_domain_filters(base_query, domains) if domains else base_query
|
||||
|
||||
engine: Final = optional_params.get("engine")
|
||||
max_results: Final = optional_params.get("max_results")
|
||||
country: Final = optional_params.get("country")
|
||||
resolved_max_results: Final = (
|
||||
max_results
|
||||
if isinstance(max_results, int) and not isinstance(max_results, bool) and max_results > 0
|
||||
else None
|
||||
)
|
||||
self._max_results = resolved_max_results
|
||||
num_param: Final[Mapping[str, int]] = (
|
||||
MappingProxyType({"num": resolved_max_results})
|
||||
if resolved_max_results is not None
|
||||
else MappingProxyType({})
|
||||
)
|
||||
country_param: Final[Mapping[str, str]] = (
|
||||
MappingProxyType({"gl": country.lower()}) if isinstance(country, str) else MappingProxyType({})
|
||||
)
|
||||
supported_params: Final = _StringFrozenSet.validate_python(
|
||||
self.get_supported_perplexity_optional_params() # pyright: ignore[reportUnknownMemberType] # base returns bare set
|
||||
)
|
||||
passthrough_params: Final = MappingProxyType(
|
||||
{
|
||||
param: value
|
||||
for param, value in optional_params.items()
|
||||
if value is not None and param not in supported_params and param not in _SERPAPI_REQUEST_KEYS
|
||||
}
|
||||
)
|
||||
request_data: Final = MappingProxyType(
|
||||
{
|
||||
"engine": engine if isinstance(engine, str) else "google",
|
||||
"q": resolved_query,
|
||||
**num_param,
|
||||
**country_param,
|
||||
**passthrough_params,
|
||||
}
|
||||
)
|
||||
serializable_request_data: Final[object] = request_data.copy()
|
||||
request_payload: Final[MappingProxyType[str, object]] = MappingProxyType(
|
||||
{_SERPAPI_PARAMS_KEY: serializable_request_data}
|
||||
)
|
||||
return request_payload.copy()
|
||||
|
||||
@staticmethod
|
||||
def _append_domain_filters(query: str, domains: Sequence[str]) -> str:
|
||||
"""
|
||||
Add site: filters to restrict search to specific domains.
|
||||
"""
|
||||
domain_clauses: Final = " OR ".join(f"site:{domain}" for domain in domains)
|
||||
return f"({query}) ({domain_clauses})"
|
||||
|
||||
def transform_search_response(
|
||||
self,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: LiteLLMLoggingObj | None,
|
||||
**kwargs: object, # kwargs-ok: BaseSearchConfig provider interface forwards extensible response options
|
||||
) -> SearchResponse:
|
||||
"""
|
||||
Transform SerpApi response to LiteLLM unified SearchResponse format.
|
||||
|
||||
SerpApi -> LiteLLM mappings:
|
||||
- organic_results[].title -> SearchResult.title
|
||||
- organic_results[].link -> SearchResult.url
|
||||
- organic_results[].snippet -> SearchResult.snippet
|
||||
- organic_results[].date -> SearchResult.date
|
||||
|
||||
Args:
|
||||
raw_response: Raw httpx response from SerpApi
|
||||
logging_obj: Logging object for tracking
|
||||
|
||||
Returns:
|
||||
SearchResponse with standardized format
|
||||
"""
|
||||
response_headers: Final = raw_response.headers
|
||||
if not 200 <= raw_response.status_code < 300:
|
||||
raise BaseLLMException(
|
||||
message=raw_response.text,
|
||||
status_code=raw_response.status_code,
|
||||
headers=response_headers,
|
||||
)
|
||||
|
||||
try:
|
||||
payload: Final[object] = raw_response.json() # pyright: ignore[reportAny] # httpx returns Any
|
||||
except ValueError as exc:
|
||||
raise BaseLLMException(
|
||||
message=f"Expected a JSON body from SerpApi, got: {raw_response.text[:200]}",
|
||||
status_code=raw_response.status_code,
|
||||
headers=response_headers,
|
||||
) from exc
|
||||
|
||||
try:
|
||||
response: Final = _SerpApiSearchResponse.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
raise BaseLLMException(
|
||||
message=f"Unrecognized SerpApi response shape: {exc}",
|
||||
status_code=raw_response.status_code,
|
||||
headers=response_headers,
|
||||
) from exc
|
||||
if response.search_metadata is not None and response.search_metadata.status == "Error":
|
||||
raise BaseLLMException(
|
||||
message=response.error or raw_response.text,
|
||||
status_code=raw_response.status_code,
|
||||
headers=response_headers,
|
||||
)
|
||||
|
||||
results: Final = tuple(
|
||||
SearchResult(
|
||||
title=result.title or "",
|
||||
url=result.link or "",
|
||||
snippet=result.snippet or "",
|
||||
date=result.date,
|
||||
last_updated=None,
|
||||
**MappingProxyType(
|
||||
{
|
||||
key: value
|
||||
for key, value in _SerpApiResultExtras.validate_python(
|
||||
result.model_extra or MappingProxyType({})
|
||||
).items()
|
||||
if key not in _SEARCH_RESULT_RESERVED_FIELDS
|
||||
}
|
||||
),
|
||||
)
|
||||
for result in response.organic_results
|
||||
)
|
||||
limited_results: Final = results[: self._max_results] if self._max_results is not None else results
|
||||
return SearchResponse(
|
||||
results=list(limited_results), # mutable-ok: SearchResponse schema requires a list
|
||||
object="search",
|
||||
)
|
||||
|
|
@ -17202,6 +17202,14 @@
|
|||
"notes": "Serper Google Search API. Pricing: $1.00/1k queries (Starter), $0.75/1k (Standard), $0.50/1k (Scale), $0.30/1k (Ultimate)."
|
||||
}
|
||||
},
|
||||
"serpapi/search": {
|
||||
"input_cost_per_query": 0.025,
|
||||
"litellm_provider": "serpapi",
|
||||
"mode": "search",
|
||||
"metadata": {
|
||||
"notes": "SerpApi Search API. Default cost uses Starter pricing: $25/1k searches. Free: 250/month; Developer: $75/5k; Production: $150/15k; Big Data: $275/30k. Only successful searches count."
|
||||
}
|
||||
},
|
||||
"apiserpent/search": {
|
||||
"input_cost_per_query": 0.0006,
|
||||
"litellm_provider": "apiserpent",
|
||||
|
|
|
|||
|
|
@ -2153,6 +2153,13 @@
|
|||
"search": true
|
||||
}
|
||||
},
|
||||
"serpapi": {
|
||||
"display_name": "SerpApi (`serpapi`)",
|
||||
"url": "https://serpapi.com/search-api",
|
||||
"endpoints": {
|
||||
"search": true
|
||||
}
|
||||
},
|
||||
"triton": {
|
||||
"display_name": "Triton (`triton`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/triton-inference-server",
|
||||
|
|
|
|||
|
|
@ -3850,6 +3850,7 @@ class SearchProviders(str, Enum):
|
|||
DUCKDUCKGO = "duckduckgo"
|
||||
SEARCHAPI = "searchapi"
|
||||
SERPER = "serper"
|
||||
SERPAPI = "serpapi"
|
||||
YOU_COM = "you_com"
|
||||
APISERPENT = "apiserpent"
|
||||
TINYFISH = "tinyfish"
|
||||
|
|
|
|||
|
|
@ -9228,6 +9228,7 @@ class ProviderConfigManager:
|
|||
from litellm.llms.perplexity.search.transformation import PerplexitySearchConfig
|
||||
from litellm.llms.searchapi.search.transformation import SearchAPIConfig
|
||||
from litellm.llms.searxng.search.transformation import SearXNGSearchConfig
|
||||
from litellm.llms.serpapi.search.transformation import SerpApiSearchConfig
|
||||
from litellm.llms.serper.search.transformation import SerperSearchConfig
|
||||
from litellm.llms.tavily.search.transformation import TavilySearchConfig
|
||||
from litellm.llms.tinyfish.search.transformation import TinyfishSearchConfig
|
||||
|
|
@ -9248,6 +9249,7 @@ class ProviderConfigManager:
|
|||
SearchProviders.DUCKDUCKGO: DuckDuckGoSearchConfig,
|
||||
SearchProviders.SEARCHAPI: SearchAPIConfig,
|
||||
SearchProviders.SERPER: SerperSearchConfig,
|
||||
SearchProviders.SERPAPI: SerpApiSearchConfig,
|
||||
SearchProviders.YOU_COM: YouComSearchConfig,
|
||||
SearchProviders.APISERPENT: APISerpentSearchConfig,
|
||||
SearchProviders.TINYFISH: TinyfishSearchConfig,
|
||||
|
|
|
|||
|
|
@ -17202,6 +17202,14 @@
|
|||
"notes": "Serper Google Search API. Pricing: $1.00/1k queries (Starter), $0.75/1k (Standard), $0.50/1k (Scale), $0.30/1k (Ultimate)."
|
||||
}
|
||||
},
|
||||
"serpapi/search": {
|
||||
"input_cost_per_query": 0.025,
|
||||
"litellm_provider": "serpapi",
|
||||
"mode": "search",
|
||||
"metadata": {
|
||||
"notes": "SerpApi Search API. Default cost uses Starter pricing: $25/1k searches. Free: 250/month; Developer: $75/5k; Production: $150/15k; Big Data: $275/30k. Only successful searches count."
|
||||
}
|
||||
},
|
||||
"apiserpent/search": {
|
||||
"input_cost_per_query": 0.0006,
|
||||
"litellm_provider": "apiserpent",
|
||||
|
|
|
|||
|
|
@ -2439,6 +2439,13 @@
|
|||
"search": true
|
||||
}
|
||||
},
|
||||
"serpapi": {
|
||||
"display_name": "SerpApi (`serpapi`)",
|
||||
"url": "https://serpapi.com/search-api",
|
||||
"endpoints": {
|
||||
"search": true
|
||||
}
|
||||
},
|
||||
"you_com": {
|
||||
"display_name": "You.com (`you_com`)",
|
||||
"url": "https://docs.litellm.ai/docs/search/you_com"
|
||||
|
|
|
|||
|
|
@ -1225,6 +1225,7 @@ _LIVE_CALL_HOST_SUFFIXES = (
|
|||
".azure.com",
|
||||
".tavily.com",
|
||||
".serper.dev",
|
||||
".serpapi.com",
|
||||
".searchapi.io",
|
||||
".firecrawl.dev",
|
||||
".exa.ai",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ SEARCH_PROVIDERS = [
|
|||
"duckduckgo",
|
||||
"searchapi",
|
||||
"serper",
|
||||
"serpapi",
|
||||
"apiserpent",
|
||||
"tinyfish",
|
||||
"nimble",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
#
|
||||
# Wires search tests into the Redis-backed VCR cache so live provider
|
||||
# calls (Brave, DataForSEO, DuckDuckGo, Exa, Firecrawl, Google PSE,
|
||||
# Linkup, Parallel.ai, Perplexity, SearchAPI, Searxng, Serper, Tavily)
|
||||
# Linkup, Parallel.ai, Perplexity, SearchAPI, Searxng, SerpApi, Serper, Tavily)
|
||||
# are replayed for 24h. See tests/llm_translation/Readme.md for the
|
||||
# design overview.
|
||||
|
||||
|
|
|
|||
393
tests/test_litellm/llms/serpapi/test_serpapi_search.py
Normal file
393
tests/test_litellm/llms/serpapi/test_serpapi_search.py
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.search.transformation import SearchResponse
|
||||
from litellm.llms.serpapi.search.transformation import SerpApiSearchConfig
|
||||
from litellm.types.utils import SearchProviders
|
||||
from litellm.utils import ProviderConfigManager, get_model_info
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_serpapi_asearch_uses_get_handler(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("SERPAPI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("SERPAPI_KEY", "test-api-key")
|
||||
local_model_cost = TypeAdapter(dict[str, object]).validate_json(
|
||||
(Path(__file__).parents[4] / "model_prices_and_context_window.json").read_text()
|
||||
)
|
||||
monkeypatch.setattr(litellm, "model_cost", local_model_cost)
|
||||
mock_response = httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
"organic_results": [
|
||||
{
|
||||
"title": "Test Result",
|
||||
"link": "https://example.com/result",
|
||||
"snippet": "Test snippet",
|
||||
}
|
||||
]
|
||||
},
|
||||
request=httpx.Request("GET", "https://serpapi.com/search.json"),
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
) as mock_get,
|
||||
patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
) as mock_post,
|
||||
):
|
||||
search = cast(
|
||||
Callable[..., Awaitable[SearchResponse]],
|
||||
litellm.asearch,
|
||||
)
|
||||
response = await search(
|
||||
query="latest AI developments",
|
||||
search_provider="serpapi",
|
||||
engine="google_light",
|
||||
max_results=5,
|
||||
hl="en",
|
||||
)
|
||||
|
||||
mock_get.assert_awaited_once()
|
||||
mock_post.assert_not_awaited()
|
||||
request_args = mock_get.await_args
|
||||
assert request_args is not None
|
||||
request_url = cast(str, request_args.kwargs["url"])
|
||||
query_params = parse_qs(urlparse(request_url).query)
|
||||
assert query_params["q"] == ["latest AI developments"]
|
||||
assert query_params["api_key"] == ["test-api-key"]
|
||||
assert query_params["engine"] == ["google_light"]
|
||||
assert query_params["num"] == ["5"]
|
||||
assert query_params["hl"] == ["en"]
|
||||
assert response.results[0].title == "Test Result"
|
||||
assert response.results[0].url == "https://example.com/result"
|
||||
hidden_params = TypeAdapter(dict[str, object]).validate_python(getattr(response, "_hidden_params"))
|
||||
assert hidden_params["response_cost"] == 0.025
|
||||
|
||||
|
||||
def test_serpapi_search_request_and_response(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("SERPAPI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("SERPAPI_KEY", "test-api-key")
|
||||
config = ProviderConfigManager.get_provider_search_config(SearchProviders.SERPAPI)
|
||||
assert isinstance(config, SerpApiSearchConfig)
|
||||
|
||||
headers = config.validate_environment(headers={})
|
||||
data = config.transform_search_request(
|
||||
query="latest AI developments",
|
||||
optional_params={
|
||||
"engine": "google_light",
|
||||
"max_results": 5,
|
||||
"search_domain_filter": ["arxiv.org", "nature.com"],
|
||||
"country": "US",
|
||||
"max_tokens_per_page": 1024,
|
||||
"hl": "en",
|
||||
},
|
||||
)
|
||||
url = config.get_complete_url(
|
||||
api_base=None,
|
||||
optional_params={},
|
||||
data=data,
|
||||
)
|
||||
mock_response = httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
"organic_results": [
|
||||
{
|
||||
"title": "Test Result",
|
||||
"link": "https://example.com/result",
|
||||
"snippet": "Test snippet",
|
||||
"date": "Jul 23, 2026",
|
||||
}
|
||||
]
|
||||
},
|
||||
request=httpx.Request("GET", "https://serpapi.com/search.json"),
|
||||
)
|
||||
response = config.transform_search_response(
|
||||
raw_response=mock_response,
|
||||
logging_obj=None,
|
||||
)
|
||||
|
||||
parsed_url = urlparse(url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
assert headers == {"Content-Type": "application/json"}
|
||||
assert parsed_url.scheme == "https"
|
||||
assert parsed_url.netloc == "serpapi.com"
|
||||
assert parsed_url.path == "/search.json"
|
||||
assert query_params["api_key"] == ["test-api-key"]
|
||||
assert query_params["engine"] == ["google_light"]
|
||||
assert query_params["num"] == ["5"]
|
||||
assert query_params["gl"] == ["us"]
|
||||
assert query_params["hl"] == ["en"]
|
||||
assert "max_tokens_per_page" not in query_params
|
||||
assert "site:arxiv.org" in query_params["q"][0]
|
||||
assert "site:nature.com" in query_params["q"][0]
|
||||
assert json.loads(json.dumps(data)) == data
|
||||
assert response.object == "search"
|
||||
assert len(response.results) == 1
|
||||
assert response.results[0].title == "Test Result"
|
||||
assert response.results[0].url == "https://example.com/result"
|
||||
assert response.results[0].snippet == "Test snippet"
|
||||
assert response.results[0].date == "Jul 23, 2026"
|
||||
|
||||
|
||||
def test_serpapi_api_key_alias(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("SERPAPI_KEY", raising=False)
|
||||
monkeypatch.setenv("SERPAPI_API_KEY", "alias-api-key")
|
||||
config = SerpApiSearchConfig()
|
||||
data = config.transform_search_request(query=["test", "query"], optional_params={})
|
||||
|
||||
url = config.get_complete_url(
|
||||
api_base=None,
|
||||
optional_params={},
|
||||
data=data,
|
||||
)
|
||||
|
||||
query_params = parse_qs(urlparse(url).query)
|
||||
assert query_params["api_key"] == ["alias-api-key"]
|
||||
assert query_params["engine"] == ["google"]
|
||||
assert query_params["q"] == ["test query"]
|
||||
|
||||
|
||||
def test_serpapi_ui_friendly_name() -> None:
|
||||
assert SerpApiSearchConfig.ui_friendly_name() == "SerpApi"
|
||||
|
||||
|
||||
def test_serpapi_caller_api_key_overrides_environment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("SERPAPI_KEY", "environment-api-key")
|
||||
config = SerpApiSearchConfig()
|
||||
data = config.transform_search_request(query="test query", optional_params={})
|
||||
|
||||
url = config.get_complete_url(
|
||||
api_base=None,
|
||||
optional_params={},
|
||||
data=data,
|
||||
api_key="caller-api-key",
|
||||
)
|
||||
|
||||
query_params = parse_qs(urlparse(url).query)
|
||||
assert query_params["api_key"] == ["caller-api-key"]
|
||||
|
||||
|
||||
def test_serpapi_rejects_unencodable_passthrough_param(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("SERPAPI_KEY", "test-api-key")
|
||||
config = SerpApiSearchConfig()
|
||||
data = config.transform_search_request(
|
||||
query="test query",
|
||||
optional_params={"nested": {"x": 1}},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
config.get_complete_url(
|
||||
api_base=None,
|
||||
optional_params={},
|
||||
data=data,
|
||||
)
|
||||
|
||||
assert type(exc_info.value) is ValueError
|
||||
assert str(exc_info.value) == "Invalid SerpApi URL parameter value for: nested"
|
||||
|
||||
|
||||
def test_serpapi_missing_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("SERPAPI_KEY", raising=False)
|
||||
monkeypatch.delenv("SERPAPI_API_KEY", raising=False)
|
||||
|
||||
with pytest.raises(ValueError, match="SERPAPI_KEY is not set"):
|
||||
SerpApiSearchConfig().validate_environment(headers={})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{},
|
||||
{"organic_results": []},
|
||||
{
|
||||
"search_metadata": {"status": "Success"},
|
||||
"error": "Google Light hasn't returned any results for this query.",
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_serpapi_empty_results(payload: dict[str, object]) -> None:
|
||||
mock_response = httpx.Response(
|
||||
status_code=200,
|
||||
json=payload,
|
||||
request=httpx.Request("GET", "https://serpapi.com/search.json"),
|
||||
)
|
||||
|
||||
response = SerpApiSearchConfig().transform_search_response(
|
||||
raw_response=mock_response,
|
||||
logging_obj=None,
|
||||
)
|
||||
|
||||
assert response.results == []
|
||||
|
||||
|
||||
def test_serpapi_preserves_result_extra_fields() -> None:
|
||||
mock_response = httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
"organic_results": [
|
||||
{
|
||||
"title": "Test Result",
|
||||
"link": "https://example.com/result",
|
||||
"snippet": "Test snippet",
|
||||
"position": 1,
|
||||
"displayed_link": "example.com",
|
||||
"rich_snippet": {"top": {"extensions": ["Extra detail"]}},
|
||||
"url": "https://wrong.example/result",
|
||||
"last_updated": "Yesterday",
|
||||
}
|
||||
]
|
||||
},
|
||||
request=httpx.Request("GET", "https://serpapi.com/search.json"),
|
||||
)
|
||||
|
||||
response = SerpApiSearchConfig().transform_search_response(
|
||||
raw_response=mock_response,
|
||||
logging_obj=None,
|
||||
)
|
||||
|
||||
result = response.results[0]
|
||||
assert getattr(result, "position") == 1
|
||||
assert getattr(result, "displayed_link") == "example.com"
|
||||
assert getattr(result, "rich_snippet") == {"top": {"extensions": ["Extra detail"]}}
|
||||
assert result.url == "https://example.com/result"
|
||||
assert result.last_updated is None
|
||||
|
||||
|
||||
def test_serpapi_max_results_truncates_response() -> None:
|
||||
config = SerpApiSearchConfig()
|
||||
config.transform_search_request(
|
||||
query="test query",
|
||||
optional_params={"max_results": 3},
|
||||
)
|
||||
mock_response = httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
"organic_results": [
|
||||
{
|
||||
"title": f"Result {index}",
|
||||
"link": f"https://example.com/{index}",
|
||||
"snippet": f"Snippet {index}",
|
||||
}
|
||||
for index in range(5)
|
||||
]
|
||||
},
|
||||
request=httpx.Request("GET", "https://serpapi.com/search.json"),
|
||||
)
|
||||
|
||||
response = config.transform_search_response(
|
||||
raw_response=mock_response,
|
||||
logging_obj=None,
|
||||
)
|
||||
|
||||
assert len(response.results) == 3
|
||||
assert response.results[-1].title == "Result 2"
|
||||
|
||||
|
||||
def test_serpapi_non_success_response_raises() -> None:
|
||||
mock_response = httpx.Response(
|
||||
status_code=429,
|
||||
text="rate limited",
|
||||
headers={"Retry-After": "60"},
|
||||
request=httpx.Request("GET", "https://serpapi.com/search.json"),
|
||||
)
|
||||
|
||||
with pytest.raises(BaseLLMException) as exc_info:
|
||||
SerpApiSearchConfig().transform_search_response(
|
||||
raw_response=mock_response,
|
||||
logging_obj=None,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 429
|
||||
assert exc_info.value.message == "rate limited"
|
||||
|
||||
|
||||
def test_serpapi_non_json_response_raises() -> None:
|
||||
mock_response = httpx.Response(
|
||||
status_code=200,
|
||||
text="<html>Bad Gateway</html>",
|
||||
request=httpx.Request("GET", "https://serpapi.com/search.json"),
|
||||
)
|
||||
|
||||
with pytest.raises(BaseLLMException, match="Expected a JSON body from SerpApi") as exc_info:
|
||||
SerpApiSearchConfig().transform_search_response(
|
||||
raw_response=mock_response,
|
||||
logging_obj=None,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 200
|
||||
assert "Bad Gateway" in exc_info.value.message
|
||||
|
||||
|
||||
def test_serpapi_error_envelope_raises() -> None:
|
||||
mock_response = httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
"search_metadata": {"status": "Error"},
|
||||
"error": "Invalid API key",
|
||||
},
|
||||
request=httpx.Request("GET", "https://serpapi.com/search.json"),
|
||||
)
|
||||
|
||||
with pytest.raises(BaseLLMException, match="Invalid API key") as exc_info:
|
||||
SerpApiSearchConfig().transform_search_response(
|
||||
raw_response=mock_response,
|
||||
logging_obj=None,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 200
|
||||
|
||||
|
||||
def test_serpapi_unrecognized_response_shape_raises() -> None:
|
||||
mock_response = httpx.Response(
|
||||
status_code=200,
|
||||
json=[],
|
||||
request=httpx.Request("GET", "https://serpapi.com/search.json"),
|
||||
)
|
||||
|
||||
with pytest.raises(BaseLLMException, match="Unrecognized SerpApi response shape") as exc_info:
|
||||
SerpApiSearchConfig().transform_search_response(
|
||||
raw_response=mock_response,
|
||||
logging_obj=None,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 200
|
||||
|
||||
|
||||
def test_serpapi_search_cost_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
local_model_cost = TypeAdapter(dict[str, object]).validate_json(
|
||||
(Path(__file__).parents[4] / "model_prices_and_context_window.json").read_text()
|
||||
)
|
||||
monkeypatch.setattr(litellm, "model_cost", local_model_cost)
|
||||
model_info = get_model_info(
|
||||
model="serpapi/search",
|
||||
custom_llm_provider="serpapi",
|
||||
api_key="test-api-key",
|
||||
)
|
||||
|
||||
assert model_info.get("input_cost_per_query") == 0.025
|
||||
assert model_info["litellm_provider"] == "serpapi"
|
||||
assert model_info["mode"] == "search"
|
||||
Loading…
Add table
Reference in a new issue