From e75c3a09cf228cc2ef8adf096bfa4006ae18c4bd Mon Sep 17 00:00:00 2001 From: Adarsh Divakaran Date: Wed, 12 Aug 2026 18:27:41 +0530 Subject: [PATCH 1/4] feat(search): add SerpApi search provider --- litellm/llms/serpapi/search/__init__.py | 5 + litellm/llms/serpapi/search/transformation.py | 300 +++++++++++++ ...odel_prices_and_context_window_backup.json | 8 + .../provider_endpoints_support_backup.json | 7 + litellm/types/utils.py | 1 + litellm/utils.py | 2 + model_prices_and_context_window.json | 8 + provider_endpoints_support.json | 7 + tests/_vcr_conftest_common.py | 1 + .../enforce_llms_folder_style.py | 1 + tests/search_tests/conftest.py | 2 +- tests/search_tests/test_serpapi_search.py | 402 ++++++++++++++++++ .../search/test_base_search_transformation.py | 4 + 13 files changed, 747 insertions(+), 1 deletion(-) create mode 100644 litellm/llms/serpapi/search/__init__.py create mode 100644 litellm/llms/serpapi/search/transformation.py create mode 100644 tests/search_tests/test_serpapi_search.py 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..5900a61cde4 --- /dev/null +++ b/litellm/llms/serpapi/search/transformation.py @@ -0,0 +1,300 @@ +""" +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, +) +from litellm.secret_managers.main import get_secret_str + +_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, + api_base: str | None, + ) -> str: + """ + Resolve a caller or configured SerpApi key for the target API base. + """ + resolved_key: Final = self.resolve_server_api_key( + caller_api_key=api_key, + caller_api_base=api_base, + key_env_vars=("SERPAPI_KEY", "SERPAPI_API_KEY"), + base_env_var="SERPAPI_API_BASE", + 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, api_base=api_base) + 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 = api_base or get_secret_str("SERPAPI_API_BASE") or 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, api_base=api_base) + 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 b288269b0a2..7cc8d00b365 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -16271,6 +16271,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 dd7712aabca..f84482e62bb 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -2119,6 +2119,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 d9ef538d530..6b9c59d6fb9 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3755,6 +3755,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 79372f00284..25a2c03556a 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9070,6 +9070,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 @@ -9090,6 +9091,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 b288269b0a2..7cc8d00b365 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -16271,6 +16271,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 65db63dc045..1ceb9ee16f4 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -2405,6 +2405,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 2cbd445365e..b05b437cf16 100644 --- a/tests/code_coverage_tests/enforce_llms_folder_style.py +++ b/tests/code_coverage_tests/enforce_llms_folder_style.py @@ -20,6 +20,7 @@ SEARCH_PROVIDERS = [ "duckduckgo", "searchapi", "serper", + "serpapi", "apiserpent", "tinyfish", ] diff --git a/tests/search_tests/conftest.py b/tests/search_tests/conftest.py index 78ba19a7724..e93d9d3da50 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/search_tests/test_serpapi_search.py b/tests/search_tests/test_serpapi_search.py new file mode 100644 index 00000000000..abd2da79f72 --- /dev/null +++ b/tests/search_tests/test_serpapi_search.py @@ -0,0 +1,402 @@ +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[2] / "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_rejects_server_key_for_untrusted_api_base( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("SERPAPI_API_KEY", raising=False) + monkeypatch.setenv("SERPAPI_KEY", "server-api-key") + config = SerpApiSearchConfig() + + with pytest.raises(ValueError, match="Refusing to send"): + config.validate_environment( + headers={}, + api_base="https://example.com/search.json", + ) + + +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() -> None: + model_info = get_model_info( + model="serpapi/search", + custom_llm_provider="serpapi", + ) + + assert model_info.get("input_cost_per_query") == 0.025 + assert model_info["litellm_provider"] == "serpapi" + assert model_info["mode"] == "search" diff --git a/tests/test_litellm/llms/base_llm/search/test_base_search_transformation.py b/tests/test_litellm/llms/base_llm/search/test_base_search_transformation.py index a1353d57038..1f05914ea00 100644 --- a/tests/test_litellm/llms/base_llm/search/test_base_search_transformation.py +++ b/tests/test_litellm/llms/base_llm/search/test_base_search_transformation.py @@ -31,6 +31,7 @@ from litellm.llms.parallel_ai.search.transformation import ParallelAISearchConfi 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 @@ -50,6 +51,7 @@ _BASE_ENV_VARS = ( "FIRECRAWL_API_BASE", "LINKUP_API_BASE", "SEARCHAPI_API_BASE", + "SERPAPI_API_BASE", "GOOGLE_PSE_API_BASE", "PARALLEL_AI_API_BASE", "YOUCOM_API_BASE", @@ -79,6 +81,7 @@ PROVIDERS: Tuple[ProviderSpec, ...] = ( (FirecrawlSearchConfig, {"FIRECRAWL_API_KEY": "srv"}, "caller-key", {}), (LinkupSearchConfig, {"LINKUP_API_KEY": "srv"}, "caller-key", {}), (SearchAPIConfig, {"SEARCHAPI_API_KEY": "srv"}, "caller-key", {}), + (SerpApiSearchConfig, {"SERPAPI_KEY": "srv"}, "caller-key", {}), ( GooglePSESearchConfig, {"GOOGLE_PSE_API_KEY": "srv"}, @@ -282,6 +285,7 @@ async def test_asearch_does_not_leak_server_key_to_caller_api_base( "provider, key_env, server_key, extra_env", [ ("searchapi", "SEARCHAPI_API_KEY", "sk-server-searchapi", {}), + ("serpapi", "SERPAPI_KEY", "sk-server-serpapi", {}), ( "google_pse", "GOOGLE_PSE_API_KEY", From 2fd7755ad2c96e7a9e30a511ac4e691defd96f43 Mon Sep 17 00:00:00 2001 From: Adarsh Divakaran Date: Fri, 14 Aug 2026 15:44:44 +0530 Subject: [PATCH 2/4] test(search): make SerpApi cost metadata test independent --- tests/search_tests/test_serpapi_search.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/search_tests/test_serpapi_search.py b/tests/search_tests/test_serpapi_search.py index abd2da79f72..aa06fce80c4 100644 --- a/tests/search_tests/test_serpapi_search.py +++ b/tests/search_tests/test_serpapi_search.py @@ -391,10 +391,15 @@ def test_serpapi_unrecognized_response_shape_raises() -> None: assert exc_info.value.status_code == 200 -def test_serpapi_search_cost_metadata() -> None: +def test_serpapi_search_cost_metadata(monkeypatch: pytest.MonkeyPatch) -> None: + local_model_cost = TypeAdapter(dict[str, object]).validate_json( + (Path(__file__).parents[2] / "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 From 10f3fd678b8b1ac170ee1e7ac17af6e083fd24ce Mon Sep 17 00:00:00 2001 From: Adarsh Divakaran Date: Fri, 14 Aug 2026 15:45:13 +0530 Subject: [PATCH 3/4] test(search): move SerpApi tests into provider CI --- .../llms/serpapi}/test_serpapi_search.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename tests/{search_tests => test_litellm/llms/serpapi}/test_serpapi_search.py (99%) diff --git a/tests/search_tests/test_serpapi_search.py b/tests/test_litellm/llms/serpapi/test_serpapi_search.py similarity index 99% rename from tests/search_tests/test_serpapi_search.py rename to tests/test_litellm/llms/serpapi/test_serpapi_search.py index aa06fce80c4..cba8e12832d 100644 --- a/tests/search_tests/test_serpapi_search.py +++ b/tests/test_litellm/llms/serpapi/test_serpapi_search.py @@ -24,7 +24,7 @@ async def test_serpapi_asearch_uses_get_handler( 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[2] / "model_prices_and_context_window.json").read_text() + (Path(__file__).parents[4] / "model_prices_and_context_window.json").read_text() ) monkeypatch.setattr(litellm, "model_cost", local_model_cost) mock_response = httpx.Response( @@ -393,7 +393,7 @@ def test_serpapi_unrecognized_response_shape_raises() -> None: def test_serpapi_search_cost_metadata(monkeypatch: pytest.MonkeyPatch) -> None: local_model_cost = TypeAdapter(dict[str, object]).validate_json( - (Path(__file__).parents[2] / "model_prices_and_context_window.json").read_text() + (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( From 28d468174af74ac5b0a7057d092e12eae5e014f7 Mon Sep 17 00:00:00 2001 From: Adarsh Divakaran Date: Fri, 14 Aug 2026 15:47:41 +0530 Subject: [PATCH 4/4] fix(search): remove SerpApi API base overrides --- litellm/llms/serpapi/search/transformation.py | 14 ++++++-------- .../search/test_base_search_transformation.py | 4 ---- .../llms/serpapi/test_serpapi_search.py | 14 -------------- 3 files changed, 6 insertions(+), 26 deletions(-) diff --git a/litellm/llms/serpapi/search/transformation.py b/litellm/llms/serpapi/search/transformation.py index 5900a61cde4..cb46dcd37cc 100644 --- a/litellm/llms/serpapi/search/transformation.py +++ b/litellm/llms/serpapi/search/transformation.py @@ -19,7 +19,6 @@ from litellm.llms.base_llm.search.transformation import ( SearchResponse, SearchResult, ) -from litellm.secret_managers.main import get_secret_str _SERPAPI_PARAMS_KEY: Final = "_serpapi_params" _SERPAPI_REQUEST_KEYS: Final = frozenset(("engine", "q", "num", "gl")) @@ -73,16 +72,15 @@ class SerpApiSearchConfig(BaseSearchConfig): def _resolve_api_key( self, api_key: str | None, - api_base: str | None, ) -> str: """ - Resolve a caller or configured SerpApi key for the target API base. + Resolve a caller or configured SerpApi key. """ resolved_key: Final = self.resolve_server_api_key( caller_api_key=api_key, - caller_api_base=api_base, + caller_api_base=None, key_env_vars=("SERPAPI_KEY", "SERPAPI_API_KEY"), - base_env_var="SERPAPI_API_BASE", + base_env_var=None, default_api_base=self.SERPAPI_API_BASE, ) if not resolved_key: @@ -99,7 +97,7 @@ class SerpApiSearchConfig(BaseSearchConfig): """ Validate SerpApi credentials and return request headers. """ - self._resolve_api_key(api_key=api_key, api_base=api_base) + self._resolve_api_key(api_key=api_key) resolved_headers: Final = MappingProxyType({**headers, "Content-Type": "application/json"}) return resolved_headers.copy() @@ -118,7 +116,7 @@ class SerpApiSearchConfig(BaseSearchConfig): SerpApi uses GET requests and includes api_key in query params. """ - resolved_base: Final = api_base or get_secret_str("SERPAPI_API_BASE") or self.SERPAPI_API_BASE + resolved_base: Final = self.SERPAPI_API_BASE if not isinstance(data, Mapping) or _SERPAPI_PARAMS_KEY not in data: return resolved_base @@ -131,7 +129,7 @@ class SerpApiSearchConfig(BaseSearchConfig): 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, api_base=api_base) + resolved_key: Final = self._resolve_api_key(api_key=api_key) query_params: Final = tuple( ( key, diff --git a/tests/test_litellm/llms/base_llm/search/test_base_search_transformation.py b/tests/test_litellm/llms/base_llm/search/test_base_search_transformation.py index 1f05914ea00..a1353d57038 100644 --- a/tests/test_litellm/llms/base_llm/search/test_base_search_transformation.py +++ b/tests/test_litellm/llms/base_llm/search/test_base_search_transformation.py @@ -31,7 +31,6 @@ from litellm.llms.parallel_ai.search.transformation import ParallelAISearchConfi 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 @@ -51,7 +50,6 @@ _BASE_ENV_VARS = ( "FIRECRAWL_API_BASE", "LINKUP_API_BASE", "SEARCHAPI_API_BASE", - "SERPAPI_API_BASE", "GOOGLE_PSE_API_BASE", "PARALLEL_AI_API_BASE", "YOUCOM_API_BASE", @@ -81,7 +79,6 @@ PROVIDERS: Tuple[ProviderSpec, ...] = ( (FirecrawlSearchConfig, {"FIRECRAWL_API_KEY": "srv"}, "caller-key", {}), (LinkupSearchConfig, {"LINKUP_API_KEY": "srv"}, "caller-key", {}), (SearchAPIConfig, {"SEARCHAPI_API_KEY": "srv"}, "caller-key", {}), - (SerpApiSearchConfig, {"SERPAPI_KEY": "srv"}, "caller-key", {}), ( GooglePSESearchConfig, {"GOOGLE_PSE_API_KEY": "srv"}, @@ -285,7 +282,6 @@ async def test_asearch_does_not_leak_server_key_to_caller_api_base( "provider, key_env, server_key, extra_env", [ ("searchapi", "SEARCHAPI_API_KEY", "sk-server-searchapi", {}), - ("serpapi", "SERPAPI_KEY", "sk-server-serpapi", {}), ( "google_pse", "GOOGLE_PSE_API_KEY", diff --git a/tests/test_litellm/llms/serpapi/test_serpapi_search.py b/tests/test_litellm/llms/serpapi/test_serpapi_search.py index cba8e12832d..072964f6cc3 100644 --- a/tests/test_litellm/llms/serpapi/test_serpapi_search.py +++ b/tests/test_litellm/llms/serpapi/test_serpapi_search.py @@ -210,20 +210,6 @@ def test_serpapi_rejects_unencodable_passthrough_param( assert str(exc_info.value) == "Invalid SerpApi URL parameter value for: nested" -def test_serpapi_rejects_server_key_for_untrusted_api_base( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.delenv("SERPAPI_API_KEY", raising=False) - monkeypatch.setenv("SERPAPI_KEY", "server-api-key") - config = SerpApiSearchConfig() - - with pytest.raises(ValueError, match="Refusing to send"): - config.validate_environment( - headers={}, - api_base="https://example.com/search.json", - ) - - def test_serpapi_missing_api_key(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("SERPAPI_KEY", raising=False) monkeypatch.delenv("SERPAPI_API_KEY", raising=False)