diff --git a/litellm/llms/serpapi/search/__init__.py b/litellm/llms/serpapi/search/__init__.py new file mode 100644 index 00000000000..7da6d8d11d3 --- /dev/null +++ b/litellm/llms/serpapi/search/__init__.py @@ -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 diff --git a/litellm/llms/serpapi/search/transformation.py b/litellm/llms/serpapi/search/transformation.py new file mode 100644 index 00000000000..cb46dcd37cc --- /dev/null +++ b/litellm/llms/serpapi/search/transformation.py @@ -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", + ) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d9a13ef1b98..4d6e4c21581 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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", diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index 86c14fb4cd8..8f30d0752be 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -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", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 73f46bd2181..ecb4257303c 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3850,6 +3850,7 @@ class SearchProviders(str, Enum): DUCKDUCKGO = "duckduckgo" SEARCHAPI = "searchapi" SERPER = "serper" + SERPAPI = "serpapi" YOU_COM = "you_com" APISERPENT = "apiserpent" TINYFISH = "tinyfish" diff --git a/litellm/utils.py b/litellm/utils.py index c2770a1a26d..8243d68c218 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index d9a13ef1b98..4d6e4c21581 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 1d8d374c2c4..794b110b865 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -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" diff --git a/tests/_vcr_conftest_common.py b/tests/_vcr_conftest_common.py index 4d5a73779ea..c0b9d51410c 100644 --- a/tests/_vcr_conftest_common.py +++ b/tests/_vcr_conftest_common.py @@ -1225,6 +1225,7 @@ _LIVE_CALL_HOST_SUFFIXES = ( ".azure.com", ".tavily.com", ".serper.dev", + ".serpapi.com", ".searchapi.io", ".firecrawl.dev", ".exa.ai", diff --git a/tests/code_coverage_tests/enforce_llms_folder_style.py b/tests/code_coverage_tests/enforce_llms_folder_style.py index a284cf9e1a9..cba1c9b0270 100644 --- a/tests/code_coverage_tests/enforce_llms_folder_style.py +++ b/tests/code_coverage_tests/enforce_llms_folder_style.py @@ -18,6 +18,7 @@ SEARCH_PROVIDERS = [ "duckduckgo", "searchapi", "serper", + "serpapi", "apiserpent", "tinyfish", "nimble", diff --git a/tests/search_tests/conftest.py b/tests/search_tests/conftest.py index deef6527a8f..a7958cb1ab5 100644 --- a/tests/search_tests/conftest.py +++ b/tests/search_tests/conftest.py @@ -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. diff --git a/tests/test_litellm/llms/serpapi/test_serpapi_search.py b/tests/test_litellm/llms/serpapi/test_serpapi_search.py new file mode 100644 index 00000000000..072964f6cc3 --- /dev/null +++ b/tests/test_litellm/llms/serpapi/test_serpapi_search.py @@ -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="Bad Gateway", + 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"