This commit is contained in:
Adarsh Divakaran 2026-08-26 13:35:10 -07:00 committed by GitHub
commit 09edbd3e50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 732 additions and 1 deletions

View 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

View 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",
)

View file

@ -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",

View file

@ -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",

View file

@ -3850,6 +3850,7 @@ class SearchProviders(str, Enum):
DUCKDUCKGO = "duckduckgo"
SEARCHAPI = "searchapi"
SERPER = "serper"
SERPAPI = "serpapi"
YOU_COM = "you_com"
APISERPENT = "apiserpent"
TINYFISH = "tinyfish"

View file

@ -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,

View file

@ -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",

View file

@ -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"

View file

@ -1225,6 +1225,7 @@ _LIVE_CALL_HOST_SUFFIXES = (
".azure.com",
".tavily.com",
".serper.dev",
".serpapi.com",
".searchapi.io",
".firecrawl.dev",
".exa.ai",

View file

@ -18,6 +18,7 @@ SEARCH_PROVIDERS = [
"duckduckgo",
"searchapi",
"serper",
"serpapi",
"apiserpent",
"tinyfish",
"nimble",

View file

@ -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.

View 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"