From 92c758104a28360eb6c98c5f6aac215312a52283 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich <4537920+yudelevi@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:15:28 -0700 Subject: [PATCH 1/3] feat(search): add APISerpent (apiserpent.com) as search provider APISerpent is a multi-engine SERP API covering Google, Bing, Yahoo, and DuckDuckGo. It exposes two endpoints, quick search (/api/search/quick) and deep search (/api/search), both billed at $0.60 per 1k searches. Both are surfaced under a single `apiserpent` provider; callers select the deep endpoint with `deep=True`, following the way Linkup and Tavily ship two search setups under one provider. All supported parameters and their defaults live in a single APISerpentSearchParams dataclass, which enforces the documented bounds (num 1 to 100, pages 1 to 10) and types the constrained string params (engine, safe, freshness, format) as Literals. --- litellm/llms/apiserpent/__init__.py | 1 + litellm/llms/apiserpent/search/__init__.py | 8 + litellm/llms/apiserpent/search/defaults.py | 68 +++++ .../llms/apiserpent/search/transformation.py | 180 ++++++++++++ ...odel_prices_and_context_window_backup.json | 16 ++ litellm/types/utils.py | 1 + litellm/utils.py | 4 + model_prices_and_context_window.json | 16 ++ .../enforce_llms_folder_style.py | 1 + tests/search_tests/test_apiserpent_search.py | 261 ++++++++++++++++++ 10 files changed, 556 insertions(+) create mode 100644 litellm/llms/apiserpent/__init__.py create mode 100644 litellm/llms/apiserpent/search/__init__.py create mode 100644 litellm/llms/apiserpent/search/defaults.py create mode 100644 litellm/llms/apiserpent/search/transformation.py create mode 100644 tests/search_tests/test_apiserpent_search.py diff --git a/litellm/llms/apiserpent/__init__.py b/litellm/llms/apiserpent/__init__.py new file mode 100644 index 00000000000..2edf992adc2 --- /dev/null +++ b/litellm/llms/apiserpent/__init__.py @@ -0,0 +1 @@ +"""APISerpent integration for LiteLLM.""" diff --git a/litellm/llms/apiserpent/search/__init__.py b/litellm/llms/apiserpent/search/__init__.py new file mode 100644 index 00000000000..4e9f88a2f1d --- /dev/null +++ b/litellm/llms/apiserpent/search/__init__.py @@ -0,0 +1,8 @@ +""" +APISerpent Search API module. +""" + +from litellm.llms.apiserpent.search.defaults import APISerpentSearchParams +from litellm.llms.apiserpent.search.transformation import APISerpentSearchConfig + +__all__ = ["APISerpentSearchConfig", "APISerpentSearchParams"] diff --git a/litellm/llms/apiserpent/search/defaults.py b/litellm/llms/apiserpent/search/defaults.py new file mode 100644 index 00000000000..bf7a20c7f42 --- /dev/null +++ b/litellm/llms/apiserpent/search/defaults.py @@ -0,0 +1,68 @@ +""" +Default parameter values and shared constants for APISerpent search. + +Single source of truth for the supported request parameters and their +package-level defaults. See https://apiserpent.com/docs. +""" + +from dataclasses import asdict, dataclass +from typing import Dict, Literal, Optional + +SearchEngine = Literal["google", "bing", "yahoo", "ddg"] +SafeSearch = Literal["off", "moderate", "strict"] +Freshness = Literal["h", "1h", "d", "1d", "7d", "w", "m", "1m", "y", "1y"] +ResponseFormat = Literal["full", "simple"] + +NUM_MIN = 1 +NUM_MIN_DEEP = 10 +NUM_MAX = 100 +PAGES_MIN = 1 +PAGES_MAX = 10 + + +@dataclass(frozen=True) +class APISerpentSearchParams: + """ + Supported APISerpent search parameters with package defaults. + + Fields defaulting to ``None`` are only sent when the caller provides them; + the rest are always sent so behavior is deterministic regardless of any + server-side defaults. + """ + + engine: SearchEngine = "google" + country: str = "us" + num: int = 10 + format: ResponseFormat = "full" + pages: Optional[int] = None + freshness: Optional[Freshness] = None + safe: Optional[SafeSearch] = None + language: Optional[str] = None + pixel_position: Optional[bool] = None + + def __post_init__(self) -> None: + if not NUM_MIN <= self.num <= NUM_MAX: + raise ValueError( + f"num must be between {NUM_MIN} and {NUM_MAX}, got {self.num}" + ) + if self.pages is not None and not PAGES_MIN <= self.pages <= PAGES_MAX: + raise ValueError( + f"pages must be between {PAGES_MIN} and {PAGES_MAX}, got {self.pages}" + ) + + def to_request_params(self) -> Dict: + """Return non-None fields as request params, booleans lowercased.""" + params: Dict = {} + for key, value in asdict(self).items(): + if value is None: + continue + params[key] = str(value).lower() if isinstance(value, bool) else value + return params + + @classmethod + def field_names(cls) -> set: + return set(cls.__dataclass_fields__.keys()) + + +QUICK_SEARCH_PATH = "/api/search/quick" +DEEP_SEARCH_PATH = "/api/search" diff --git a/litellm/llms/apiserpent/search/transformation.py b/litellm/llms/apiserpent/search/transformation.py new file mode 100644 index 00000000000..d62f1bf8831 --- /dev/null +++ b/litellm/llms/apiserpent/search/transformation.py @@ -0,0 +1,180 @@ +""" +Calls APISerpent's search endpoints to search Google, Bing, Yahoo, or DuckDuckGo. + +Two endpoints under one provider, selected via the ``deep`` boolean param: +- ``deep=False`` (default) -> quick search (/api/search/quick) +- ``deep=True`` -> deep search (/api/search) + +APISerpent API Reference: https://apiserpent.com/docs +""" + +from typing import Dict, List, Literal, Optional, Union, cast +from urllib.parse import urlencode + +import httpx + +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.apiserpent.search.defaults import ( + DEEP_SEARCH_PATH, + NUM_MAX, + NUM_MIN, + NUM_MIN_DEEP, + QUICK_SEARCH_PATH, + APISerpentSearchParams, +) +from litellm.llms.base_llm.search.transformation import ( + BaseSearchConfig, + SearchResponse, + SearchResult, +) +from litellm.secret_managers.main import get_secret_str + +DEEP_SEARCH_PARAM = "deep" +APISERPENT_BASE = "https://apiserpent.com" +APISERPENT_PARAMS_KEY = "_apiserpent_params" + + +class APISerpentSearchConfig(BaseSearchConfig): + @staticmethod + def ui_friendly_name() -> str: + return "APISerpent" + + def get_http_method(self) -> Literal["GET", "POST"]: + return "GET" + + @staticmethod + def _is_deep_search(optional_params: dict) -> bool: + return bool(optional_params.get(DEEP_SEARCH_PARAM)) + + def validate_environment( + self, + headers: Dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + **kwargs, + ) -> Dict: + api_key = api_key or get_secret_str("APISERPENT_API_KEY") + if not api_key: + raise ValueError( + "APISERPENT_API_KEY is not set. Set `APISERPENT_API_KEY` environment variable." + ) + headers["X-API-Key"] = api_key + headers["Content-Type"] = "application/json" + return headers + + def get_complete_url( + self, + api_base: Optional[str], + optional_params: dict, + data: Optional[Union[Dict, List[Dict]]] = None, + **kwargs, + ) -> str: + """ + Build the search URL. APISerpent uses GET, so the transformed request is + serialized into the query string. The endpoint path is chosen by the + ``deep`` param unless an explicit ``api_base`` override is provided. + """ + explicit_base = api_base or get_secret_str("APISERPENT_API_BASE") + if explicit_base: + base = explicit_base.rstrip("/") + else: + path = ( + DEEP_SEARCH_PATH + if self._is_deep_search(optional_params) + else QUICK_SEARCH_PATH + ) + base = f"{APISERPENT_BASE}{path}" + + if data and isinstance(data, dict) and APISERPENT_PARAMS_KEY in data: + query_string = urlencode(data[APISERPENT_PARAMS_KEY], doseq=True) + return f"{base}?{query_string}" + + return base + + def transform_search_request( + self, + query: Union[str, List[str]], + optional_params: dict, + **kwargs, + ) -> Dict: + """ + Transform a unified search request into APISerpent query params. + + Unified spec mappings: + - query -> q + - max_results -> num (clamped to the endpoint's valid range) + - country -> country (lowercased) + - search_domain_filter -> site: clauses appended to q + + All other APISerpent params (engine, language, freshness, safe, pages, + format, pixel_position) pass through, defaulting via APISerpentSearchParams. + """ + if isinstance(query, list): + query = " ".join(query) + + is_deep = self._is_deep_search(optional_params) + + overrides: Dict = {} + if "max_results" in optional_params: + num_min = NUM_MIN_DEEP if is_deep else NUM_MIN + overrides["num"] = max( + num_min, min(optional_params["max_results"], NUM_MAX) + ) + if "country" in optional_params: + overrides["country"] = cast(str, optional_params["country"]).lower() + + for param, value in optional_params.items(): + if param in APISerpentSearchParams.field_names() and param not in overrides: + overrides[param] = value + + params = {**APISerpentSearchParams(**overrides).to_request_params(), "q": query} + + if "search_domain_filter" in optional_params: + domains = optional_params["search_domain_filter"] + if isinstance(domains, list) and len(domains) > 0: + params["q"] = self._append_domain_filters(str(params["q"]), domains) + + return {APISERPENT_PARAMS_KEY: params} + + @staticmethod + def _append_domain_filters(query: str, domains: List[str]) -> str: + domain_clauses = " 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: Optional[LiteLLMLoggingObj], + **kwargs, + ) -> SearchResponse: + """ + Transform APISerpent response to the unified SearchResponse format. + + Full format nests results under ``results.organic[]``; simple format + returns a flat ``results[]`` array. Both expose title/url/snippet. + """ + response_json = raw_response.json() + + raw_results = response_json.get("results", {}) + organic = ( + raw_results.get("organic", []) + if isinstance(raw_results, dict) + else raw_results + ) + + results: List[SearchResult] = [] + for result in organic: + results.append( + SearchResult( + title=result.get("title", ""), + url=result.get("url", ""), + snippet=result.get("snippet", ""), + date=result.get("date"), + last_updated=None, + ) + ) + + return SearchResponse( + results=results, + object="search", + ) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index ce6d4ac824c..3f7378ab2f1 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -13380,6 +13380,22 @@ "notes": "Serper Google Search API. Pricing: $1.00/1k queries (Starter), $0.75/1k (Standard), $0.50/1k (Scale), $0.30/1k (Ultimate)." } }, + "apiserpent/search": { + "input_cost_per_query": 0.0006, + "litellm_provider": "apiserpent", + "mode": "search", + "metadata": { + "notes": "APISerpent quick search (/api/search/quick), multi-engine (Google, Bing, Yahoo, DuckDuckGo). Pricing: $0.60/1k searches." + } + }, + "apiserpent/deep_search": { + "input_cost_per_query": 0.0006, + "litellm_provider": "apiserpent", + "mode": "search", + "metadata": { + "notes": "APISerpent deep search (/api/search), multi-engine (Google, Bing, Yahoo, DuckDuckGo). Pricing: $0.60/1k searches." + } + }, "elevenlabs/scribe_v1": { "input_cost_per_second": 6.11e-05, "litellm_provider": "elevenlabs", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 5574d616fac..5d45041ec6a 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3404,6 +3404,7 @@ class SearchProviders(str, Enum): DUCKDUCKGO = "duckduckgo" SEARCHAPI = "searchapi" SERPER = "serper" + APISERPENT = "apiserpent" # Create a set of all search provider values for quick lookup diff --git a/litellm/utils.py b/litellm/utils.py index 5a9dccc089e..39daa986851 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9389,6 +9389,9 @@ def get_provider_search_config( """ Get Search configuration for a given provider. """ + from litellm.llms.apiserpent.search.transformation import ( + APISerpentSearchConfig, + ) from litellm.llms.brave.search.transformation import BraveSearchConfig from litellm.llms.dataforseo.search.transformation import DataForSEOSearchConfig from litellm.llms.duckduckgo.search.transformation import DuckDuckGoSearchConfig @@ -9419,6 +9422,7 @@ def get_provider_search_config( SearchProviders.DUCKDUCKGO: DuckDuckGoSearchConfig, SearchProviders.SEARCHAPI: SearchAPIConfig, SearchProviders.SERPER: SerperSearchConfig, + SearchProviders.APISERPENT: APISerpentSearchConfig, } config_class = PROVIDER_TO_CONFIG_MAP.get(provider, None) if config_class is None: diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 80c2f32dc70..2b38b3e9945 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -13380,6 +13380,22 @@ "notes": "Serper Google Search API. Pricing: $1.00/1k queries (Starter), $0.75/1k (Standard), $0.50/1k (Scale), $0.30/1k (Ultimate)." } }, + "apiserpent/search": { + "input_cost_per_query": 0.0006, + "litellm_provider": "apiserpent", + "mode": "search", + "metadata": { + "notes": "APISerpent quick search (/api/search/quick), multi-engine (Google, Bing, Yahoo, DuckDuckGo). Pricing: $0.60/1k searches." + } + }, + "apiserpent/deep_search": { + "input_cost_per_query": 0.0006, + "litellm_provider": "apiserpent", + "mode": "search", + "metadata": { + "notes": "APISerpent deep search (/api/search), multi-engine (Google, Bing, Yahoo, DuckDuckGo). Pricing: $0.60/1k searches." + } + }, "elevenlabs/scribe_v1": { "input_cost_per_second": 6.11e-05, "litellm_provider": "elevenlabs", diff --git a/tests/code_coverage_tests/enforce_llms_folder_style.py b/tests/code_coverage_tests/enforce_llms_folder_style.py index 370ff13e029..43ab81b6c60 100644 --- a/tests/code_coverage_tests/enforce_llms_folder_style.py +++ b/tests/code_coverage_tests/enforce_llms_folder_style.py @@ -19,6 +19,7 @@ "duckduckgo", "searchapi", "serper", + "apiserpent", ] ALLOWED_FILES_IN_LLMS_FOLDER = [ diff --git a/tests/search_tests/test_apiserpent_search.py b/tests/search_tests/test_apiserpent_search.py new file mode 100644 index 00000000000..1c42d6f77b6 --- /dev/null +++ b/tests/search_tests/test_apiserpent_search.py @@ -0,0 +1,261 @@ +""" +Tests for APISerpent search API integration (quick + deep search). +""" + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import parse_qs, urlparse + +import pytest + +sys.path.insert(0, os.path.abspath("../..")) + +import litellm +from litellm.llms.apiserpent.search.defaults import APISerpentSearchParams +from litellm.llms.apiserpent.search.transformation import APISerpentSearchConfig +from litellm.llms.base_llm.search.transformation import SearchResponse + + +def _params(config, query, optional_params): + return config.transform_search_request( + query=query, optional_params=optional_params + )["_apiserpent_params"] + + +class TestAPISerpentDefaults: + def test_defaults_applied(self): + params = APISerpentSearchParams().to_request_params() + assert params["engine"] == "google" + assert params["country"] == "us" + assert params["num"] == 10 + assert params["format"] == "full" + # None-valued optionals are omitted + assert "freshness" not in params + assert "pixel_position" not in params + + def test_bool_lowercased(self): + params = APISerpentSearchParams(pixel_position=True).to_request_params() + assert params["pixel_position"] == "true" + + @pytest.mark.parametrize("num", [0, 101, 500]) + def test_num_out_of_range_raises(self, num): + with pytest.raises(ValueError, match="num must be between 1 and 100"): + APISerpentSearchParams(num=num) + + @pytest.mark.parametrize("pages", [0, 11, 50]) + def test_pages_out_of_range_raises(self, pages): + with pytest.raises(ValueError, match="pages must be between 1 and 10"): + APISerpentSearchParams(pages=pages) + + def test_valid_bounds_accepted(self): + params = APISerpentSearchParams(num=100, pages=10).to_request_params() + assert params["num"] == 100 + assert params["pages"] == 10 + + +class TestAPISerpentConfig: + def test_ui_friendly_name(self): + assert APISerpentSearchConfig().ui_friendly_name() == "APISerpent" + + def test_get_http_method(self): + assert APISerpentSearchConfig().get_http_method() == "GET" + + @patch("litellm.llms.apiserpent.search.transformation.get_secret_str") + def test_validate_environment_with_api_key(self, mock_get_secret): + mock_get_secret.return_value = None + headers = APISerpentSearchConfig().validate_environment( + {}, api_key="test-api-key" + ) + assert headers["X-API-Key"] == "test-api-key" + assert headers["Content-Type"] == "application/json" + + @patch("litellm.llms.apiserpent.search.transformation.get_secret_str") + def test_validate_environment_without_api_key(self, mock_get_secret): + mock_get_secret.return_value = None + with pytest.raises(ValueError, match="APISERPENT_API_KEY is not set"): + APISerpentSearchConfig().validate_environment({}) + + def test_transform_request_basic_applies_defaults(self): + params = _params(APISerpentSearchConfig(), "test query", {}) + assert params["q"] == "test query" + assert params["engine"] == "google" + assert params["num"] == 10 + + def test_transform_request_list_query_joined(self): + assert _params(APISerpentSearchConfig(), ["foo", "bar"], {})["q"] == "foo bar" + + def test_quick_num_clamped(self): + config = APISerpentSearchConfig() + assert _params(config, "q", {"max_results": 250})["num"] == 100 + assert _params(config, "q", {"max_results": 0})["num"] == 1 + + def test_deep_num_floor_is_10(self): + config = APISerpentSearchConfig() + params = _params(config, "q", {"deep": True, "max_results": 5}) + assert params["num"] == 10 + + def test_country_lowercased(self): + assert ( + _params(APISerpentSearchConfig(), "q", {"country": "US"})["country"] == "us" + ) + + def test_engine_and_optional_passthrough(self): + params = _params( + APISerpentSearchConfig(), + "q", + {"engine": "bing", "language": "es", "freshness": "d", "safe": "strict"}, + ) + assert params["engine"] == "bing" + assert params["language"] == "es" + assert params["freshness"] == "d" + assert params["safe"] == "strict" + + def test_pixel_position_passthrough_lowercased(self): + params = _params(APISerpentSearchConfig(), "q", {"pixel_position": True}) + assert params["pixel_position"] == "true" + + def test_domain_filter(self): + params = _params( + APISerpentSearchConfig(), + "machine learning", + {"search_domain_filter": ["arxiv.org", "nature.com"]}, + ) + assert "site:arxiv.org" in params["q"] + assert "site:nature.com" in params["q"] + assert "machine learning" in params["q"] + + def test_get_complete_url_quick_path(self): + config = APISerpentSearchConfig() + data = {"_apiserpent_params": {"q": "test", "num": 5}} + url = config.get_complete_url(api_base=None, optional_params={}, data=data) + parsed = urlparse(url) + assert ( + f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + == "https://apiserpent.com/api/search/quick" + ) + assert parse_qs(parsed.query)["q"] == ["test"] + + def test_get_complete_url_deep_path(self): + config = APISerpentSearchConfig() + data = {"_apiserpent_params": {"q": "test"}} + url = config.get_complete_url( + api_base=None, optional_params={"deep": True}, data=data + ) + parsed = urlparse(url) + assert ( + f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + == "https://apiserpent.com/api/search" + ) + + def test_transform_response_full_format(self): + raw_response = MagicMock() + raw_response.json.return_value = { + "success": True, + "results": { + "organic": [ + {"title": "R1", "url": "https://example.com/1", "snippet": "S1"}, + {"title": "R2", "url": "https://example.com/2", "snippet": "S2"}, + ] + }, + } + response = APISerpentSearchConfig().transform_search_response( + raw_response=raw_response, logging_obj=None + ) + assert isinstance(response, SearchResponse) + assert len(response.results) == 2 + assert response.results[0].title == "R1" + assert response.results[0].url == "https://example.com/1" + + def test_transform_response_simple_format(self): + raw_response = MagicMock() + raw_response.json.return_value = { + "success": True, + "results": [{"position": 1, "title": "R1", "url": "https://example.com/1"}], + } + response = APISerpentSearchConfig().transform_search_response( + raw_response=raw_response, logging_obj=None + ) + assert len(response.results) == 1 + assert response.results[0].title == "R1" + + def test_transform_response_empty(self): + raw_response = MagicMock() + raw_response.json.return_value = {"success": True, "results": {}} + response = APISerpentSearchConfig().transform_search_response( + raw_response=raw_response, logging_obj=None + ) + assert len(response.results) == 0 + + +class TestAPISerpentSearchIntegration: + @staticmethod + def _mock_response(): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "success": True, + "results": { + "organic": [ + { + "title": "Test Result", + "url": "https://example.com", + "snippet": "A snippet", + } + ] + }, + } + return mock_response + + @pytest.mark.asyncio + async def test_asearch_quick_default(self): + os.environ["APISERPENT_API_KEY"] = "test-api-key" + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get", + new_callable=AsyncMock, + ) as mock_get: + mock_get.return_value = self._mock_response() + + response = await litellm.asearch( + query="latest developments in AI", + search_provider="apiserpent", + max_results=5, + country="US", + ) + + parsed = urlparse(mock_get.call_args.kwargs["url"]) + assert ( + f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + == "https://apiserpent.com/api/search/quick" + ) + qs = parse_qs(parsed.query) + assert qs["q"] == ["latest developments in AI"] + assert qs["num"] == ["5"] + assert qs["country"] == ["us"] + assert mock_get.call_args.kwargs["headers"]["X-API-Key"] == "test-api-key" + + assert response.object == "search" + assert response.results[0].title == "Test Result" + + @pytest.mark.asyncio + async def test_asearch_deep(self): + os.environ["APISERPENT_API_KEY"] = "test-api-key" + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get", + new_callable=AsyncMock, + ) as mock_get: + mock_get.return_value = self._mock_response() + + await litellm.asearch( + query="climate research", + search_provider="apiserpent", + deep=True, + max_results=40, + ) + + parsed = urlparse(mock_get.call_args.kwargs["url"]) + assert ( + f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + == "https://apiserpent.com/api/search" + ) + assert parse_qs(parsed.query)["num"] == ["40"] From 8bbc2b7a48a92d279e9a614588c43fb263dbb73c Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich <4537920+yudelevi@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:35:18 -0700 Subject: [PATCH 2/3] address review: null results, idempotent api_base, test coverage Greptile fixes: coerce a null `results` payload to an empty list so error responses don't raise (P1); always apply the quick/deep path suffix so an api_base / APISERPENT_API_BASE host override still routes correctly, using an endswith guard to stay idempotent across the handler's double call into get_complete_url (P2); document why the deep-search num floor isn't enforced in the dataclass (P2). Move the test suite from tests/search_tests to tests/test_litellm/llms/apiserpent so the unit-test/coverage job (`pytest tests/test_litellm`) actually exercises it; the package now reports 100% patch coverage. Adds regression tests for the null-results and api_base-routing fixes. --- litellm/llms/apiserpent/search/defaults.py | 2 + .../llms/apiserpent/search/transformation.py | 28 +++++++------ .../apiserpent}/test_apiserpent_search.py | 40 +++++++++++++++++-- 3 files changed, 53 insertions(+), 17 deletions(-) rename tests/{search_tests => test_litellm/llms/apiserpent}/test_apiserpent_search.py (86%) diff --git a/litellm/llms/apiserpent/search/defaults.py b/litellm/llms/apiserpent/search/defaults.py index bf7a20c7f42..219178587d6 100644 --- a/litellm/llms/apiserpent/search/defaults.py +++ b/litellm/llms/apiserpent/search/defaults.py @@ -41,6 +41,8 @@ class APISerpentSearchParams: pixel_position: Optional[bool] = None def __post_init__(self) -> None: + # num's deep-search floor (NUM_MIN_DEEP) is endpoint-specific and enforced + # in the transform layer; here we only bound the absolute range. if not NUM_MIN <= self.num <= NUM_MAX: raise ValueError( f"num must be between {NUM_MIN} and {NUM_MAX}, got {self.num}" diff --git a/litellm/llms/apiserpent/search/transformation.py b/litellm/llms/apiserpent/search/transformation.py index d62f1bf8831..1eb7d34c875 100644 --- a/litellm/llms/apiserpent/search/transformation.py +++ b/litellm/llms/apiserpent/search/transformation.py @@ -71,19 +71,21 @@ def get_complete_url( ) -> str: """ Build the search URL. APISerpent uses GET, so the transformed request is - serialized into the query string. The endpoint path is chosen by the - ``deep`` param unless an explicit ``api_base`` override is provided. + serialized into the query string. The endpoint path (quick vs deep) is + always applied; an ``api_base`` / ``APISERPENT_API_BASE`` override only + changes the host. The ``endswith`` guard keeps this idempotent, since the + handler re-invokes this method with the already-resolved URL as api_base. """ - explicit_base = api_base or get_secret_str("APISERPENT_API_BASE") - if explicit_base: - base = explicit_base.rstrip("/") - else: - path = ( - DEEP_SEARCH_PATH - if self._is_deep_search(optional_params) - else QUICK_SEARCH_PATH - ) - base = f"{APISERPENT_BASE}{path}" + base = ( + api_base or get_secret_str("APISERPENT_API_BASE") or APISERPENT_BASE + ).rstrip("/") + path = ( + DEEP_SEARCH_PATH + if self._is_deep_search(optional_params) + else QUICK_SEARCH_PATH + ) + if not base.endswith(path): + base = f"{base}{path}" if data and isinstance(data, dict) and APISERPENT_PARAMS_KEY in data: query_string = urlencode(data[APISERPENT_PARAMS_KEY], doseq=True) @@ -155,7 +157,7 @@ def transform_search_response( """ response_json = raw_response.json() - raw_results = response_json.get("results", {}) + raw_results = response_json.get("results") or {} organic = ( raw_results.get("organic", []) if isinstance(raw_results, dict) diff --git a/tests/search_tests/test_apiserpent_search.py b/tests/test_litellm/llms/apiserpent/test_apiserpent_search.py similarity index 86% rename from tests/search_tests/test_apiserpent_search.py rename to tests/test_litellm/llms/apiserpent/test_apiserpent_search.py index 1c42d6f77b6..32838701949 100644 --- a/tests/search_tests/test_apiserpent_search.py +++ b/tests/test_litellm/llms/apiserpent/test_apiserpent_search.py @@ -3,14 +3,11 @@ """ import os -import sys from unittest.mock import AsyncMock, MagicMock, patch from urllib.parse import parse_qs, urlparse import pytest -sys.path.insert(0, os.path.abspath("../..")) - import litellm from litellm.llms.apiserpent.search.defaults import APISerpentSearchParams from litellm.llms.apiserpent.search.transformation import APISerpentSearchConfig @@ -30,7 +27,6 @@ def test_defaults_applied(self): assert params["country"] == "us" assert params["num"] == 10 assert params["format"] == "full" - # None-valued optionals are omitted assert "freshness" not in params assert "pixel_position" not in params @@ -148,6 +144,33 @@ def test_get_complete_url_deep_path(self): == "https://apiserpent.com/api/search" ) + def test_explicit_api_base_swaps_host_and_keeps_routing(self): + config = APISerpentSearchConfig() + url = config.get_complete_url( + api_base="https://staging.apiserpent.com", + optional_params={"deep": True}, + data={"_apiserpent_params": {"q": "x"}}, + ) + parsed = urlparse(url) + assert ( + f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + == "https://staging.apiserpent.com/api/search" + ) + + def test_get_complete_url_is_idempotent(self): + """The handler re-invokes get_complete_url with the resolved URL as api_base.""" + config = APISerpentSearchConfig() + resolved = config.get_complete_url( + api_base=None, optional_params={"deep": True}, data=None + ) + again = config.get_complete_url( + api_base=resolved, + optional_params={"deep": True}, + data={"_apiserpent_params": {"q": "x"}}, + ) + assert again == "https://apiserpent.com/api/search?q=x" + assert "/api/search/api/search" not in again + def test_transform_response_full_format(self): raw_response = MagicMock() raw_response.json.return_value = { @@ -187,6 +210,15 @@ def test_transform_response_empty(self): ) assert len(response.results) == 0 + def test_transform_response_null_results(self): + """An error response with `results: null` must not raise.""" + raw_response = MagicMock() + raw_response.json.return_value = {"success": False, "results": None} + response = APISerpentSearchConfig().transform_search_response( + raw_response=raw_response, logging_obj=None + ) + assert response.results == [] + class TestAPISerpentSearchIntegration: @staticmethod From 39e1ec967caf6a83233b8dc6c54432acb1a3725b Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich <4537920+yudelevi@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:45:17 -0700 Subject: [PATCH 3/3] register apiserpent in provider_endpoints_support.json The check_provider_folders_documented CI gate requires every litellm/llms folder to have an entry; add apiserpent with a search endpoint, mirroring the serper and tavily entries. --- provider_endpoints_support.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 388752b032e..4c70f419829 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -2154,6 +2154,13 @@ "search": true } }, + "apiserpent": { + "display_name": "APISerpent (`apiserpent`)", + "url": "https://docs.litellm.ai/docs/search/apiserpent", + "endpoints": { + "search": true + } + }, "triton": { "display_name": "Triton (`triton`)", "url": "https://docs.litellm.ai/docs/providers/triton-inference-server",