feat(search): add Search1API as a search provider

Adds search1api to the unified /search API: POST https://api.search1api.com/search
with Bearer auth. Unified params map to Search1API's own (max_results,
search_domain_filter -> include_sites/exclude_sites); country and
max_tokens_per_page have no equivalent and are dropped. Search1API's
search_service, time_range, language, include_sites and exclude_sites pass
through. crawl_results and image are rejected when enabled and dropped when
disabled: the unified response has no field for page text or image URLs and
each crawled page would bill a credit LiteLLM cannot track. Non-2xx bodies
surface Search1API's own error message. Pricing entry is the pay-as-you-go
list price of $0.001 per search.
This commit is contained in:
fatwang2 2026-09-03 23:52:16 +08:00
parent 11a02b9581
commit 2db40a7b9c
12 changed files with 730 additions and 0 deletions

View file

@ -0,0 +1,3 @@
from litellm.llms.search1api.search.transformation import Search1APISearchConfig
__all__ = ("Search1APISearchConfig",)

View file

@ -0,0 +1,3 @@
from litellm.llms.search1api.search.transformation import Search1APISearchConfig
__all__ = ("Search1APISearchConfig",)

View file

@ -0,0 +1,254 @@
"""
Calls Search1API's /search endpoint to search the web through Google, Bing, DuckDuckGo and other engines.
Search1API API Reference: https://s1.dev/docs/basic/search
"""
from __future__ import annotations
from collections.abc import Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Final
import httpx
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
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
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
_SEARCH1API_DOCS_URL: Final = "https://s1.dev/docs/basic/search"
_UNIFIED_DEFAULT_MAX_RESULTS: Final = 10
_UNSUPPORTED_PARAMS: Final = frozenset(("crawl_results", "image"))
class _Search1APIResult(BaseModel):
"""One entry of Search1API's `results` array. Every field is optional so a single degraded
result degrades to empty strings instead of failing the whole call."""
model_config = ConfigDict(extra="ignore", frozen=True)
title: str | None = None
link: str | None = None
snippet: str | None = None
class _Search1APISearchResponse(BaseModel):
"""Search1API's /search response envelope."""
model_config = ConfigDict(extra="ignore", frozen=True)
results: tuple[_Search1APIResult, ...]
class _ErrorEnvelope(BaseModel):
"""Search1API reports errors as `{"ok": false, "error": ..., "message": ...}`."""
model_config = ConfigDict(extra="ignore", frozen=True)
message: str | None = None
error: str | None = None
_DomainListAdapter: Final = TypeAdapter(tuple[str, ...])
_NOTHING: Final[Mapping[str, object]] = MappingProxyType({})
class Search1APISearchConfig(BaseSearchConfig):
SEARCH1API_API_BASE = "https://api.search1api.com"
@staticmethod
def ui_friendly_name() -> str:
return "Search1API"
def validate_environment(
self,
headers: dict[str, str], # mutable-ok: BaseSearchConfig.validate_environment signature
api_key: str | None = None,
api_base: str | None = None,
**kwargs: object, # kwargs-ok: BaseSearchConfig.validate_environment signature
) -> dict[str, str]: # mutable-ok: the http handler passes this straight to httpx as headers
"""
Validate environment and return headers.
Returns a new dict rather than mutating ``headers``: the http handler calls this
a second time after ``litellm/search/main.py`` already did, so it has to be idempotent.
``SEARCH1API_KEY`` is the name Search1API's own CLI, SDKs and MCP server read, so it is
honored as a fallback to the LiteLLM-style ``SEARCH1API_API_KEY``.
"""
resolved_api_key: Final = self.resolve_server_api_key(
caller_api_key=api_key,
caller_api_base=api_base,
key_env_vars=("SEARCH1API_API_KEY", "SEARCH1API_KEY"),
base_env_var="SEARCH1API_API_BASE",
default_api_base=self.SEARCH1API_API_BASE,
)
if not resolved_api_key:
raise ValueError("SEARCH1API_API_KEY is not set. Set `SEARCH1API_API_KEY` environment variable.")
return { # mutable-ok: httpx requires a plain dict of headers
**headers,
"Authorization": f"Bearer {resolved_api_key}",
"Content-Type": "application/json",
}
def get_complete_url(
self,
api_base: str | None,
optional_params: dict[str, object], # mutable-ok: BaseSearchConfig.get_complete_url signature
data: dict[str, object] | list[dict[str, object]] | None = None, # mutable-ok: base signature
**kwargs: object, # kwargs-ok: BaseSearchConfig.get_complete_url signature
) -> str:
resolved_base: Final = (api_base or get_secret_str("SEARCH1API_API_BASE") or self.SEARCH1API_API_BASE).rstrip(
"/"
)
if resolved_base.endswith("/search"):
return resolved_base
return f"{resolved_base}/search"
def transform_search_request(
self,
query: str | list[str], # mutable-ok: BaseSearchConfig.transform_search_request signature
optional_params: dict[str, object], # mutable-ok: base signature
**kwargs: object, # kwargs-ok: BaseSearchConfig.transform_search_request signature
) -> dict[str, object]: # mutable-ok: the http handler passes this straight to httpx as the JSON body
"""
Transform Search request to Search1API format.
- query -> query (a list is joined with spaces; Search1API takes a single string per search)
- max_results -> max_results (Search1API's own default is 5, so the unified spec's 10 is sent
explicitly when the caller gives none; anything else is passed unclamped so Search1API's
1-50 validation reports the error)
- search_domain_filter -> include_sites, with `-`-prefixed entries going to exclude_sites
- country, max_tokens_per_page -> dropped (no Search1API equivalent)
- crawl_results, image -> rejected when enabled, dropped when disabled: the unified response
has no field for fetched page text or image URLs, and each fetched page bills an extra
Search1API credit that LiteLLM cost tracking cannot see
Everything else (search_service, time_range, language, include_sites, exclude_sites) is forwarded
as-is, so the rest of Search1API's search surface stays reachable without LiteLLM tracking it.
An explicitly supplied include_sites or exclude_sites wins over search_domain_filter.
"""
enabled_unsupported: Final = tuple(sorted(param for param in _UNSUPPORTED_PARAMS if optional_params.get(param)))
if enabled_unsupported:
raise ValueError(
f"Search1API {', '.join(enabled_unsupported)} is not supported through LiteLLM's unified search: "
f"the response has no field for fetched page text or image URLs. "
f"Call Search1API's /crawl endpoint directly instead. See {_SEARCH1API_DOCS_URL} for details."
)
dropped: Final = self.get_supported_perplexity_optional_params() | _UNSUPPORTED_PARAMS
passthrough: Final = MappingProxyType(
{param: value for param, value in optional_params.items() if param not in dropped}
)
return { # mutable-ok: httpx requires a plain dict for the JSON body
**_site_filters(optional_params.get("search_domain_filter")),
**passthrough,
"query": " ".join(query) if isinstance(query, list) else query,
"max_results": optional_params.get("max_results", _UNIFIED_DEFAULT_MAX_RESULTS),
}
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
**kwargs: object, # kwargs-ok: BaseSearchConfig.transform_search_response signature
) -> SearchResponse:
"""
Transform Search1API response to LiteLLM unified SearchResponse format.
Search1API -> LiteLLM mappings:
- results[].title -> SearchResult.title
- results[].link -> SearchResult.url
- results[].snippet -> SearchResult.snippet
Search1API returns no publication date, so `date` stays None. A non-2xx body is surfaced through
get_error_class with Search1API's own message, and a 2xx body that does not match the documented
schema raises an attributed error rather than being reported as a successful empty search. Parsing
the response bytes rather than `.json()` covers the non-JSON case through that same path.
"""
if raw_response.status_code >= 400:
raise self.get_error_class(
error_message=raw_response.text,
status_code=raw_response.status_code,
headers=dict(raw_response.headers), # mutable-ok: BaseSearchConfig.get_error_class signature
)
try:
parsed: Final = _Search1APISearchResponse.model_validate_json(raw_response.content)
except ValidationError as e:
raise self.get_error_class(
error_message=f"response does not match the documented /search schema: {e}",
status_code=raw_response.status_code,
headers=dict(raw_response.headers), # mutable-ok: BaseSearchConfig.get_error_class signature
)
return SearchResponse(
results=[ # mutable-ok: SearchResponse.results is declared list[SearchResult]
SearchResult(
title=result.title or "",
url=result.link or "",
snippet=result.snippet or "",
date=None,
last_updated=None,
)
for result in parsed.results
],
object="search",
)
def get_error_class(
self,
error_message: str,
status_code: int,
headers: dict[str, str], # mutable-ok: BaseSearchConfig.get_error_class signature
) -> Exception:
detail: Final = _unwrap_error_detail(error_message).rstrip(". ")
return BaseLLMException(
status_code=status_code,
message=f"Search1API: {detail}. See {_SEARCH1API_DOCS_URL} for details.",
headers=headers,
)
def _unwrap_error_detail(error_message: str) -> str:
"""
Surface the human-readable message inside Search1API's error envelope.
Falls back to the raw body for anything else (CDN HTML pages, plain text, other shapes).
"""
try:
body: Final = _ErrorEnvelope.model_validate_json(error_message)
except ValidationError:
return error_message
return body.message or body.error or error_message
def _site_filters(search_domain_filter: object) -> Mapping[str, object]:
"""
Split the unified `search_domain_filter` into Search1API's include_sites/exclude_sites lists.
Follows the Perplexity unified spec, where a `-` prefix means "exclude this domain".
Anything that is not a list of strings is ignored rather than raising, since it only
ever narrows a search that is otherwise valid.
"""
try:
domains: Final = _DomainListAdapter.validate_python(search_domain_filter)
except ValidationError:
return _NOTHING
return MappingProxyType(
{
key: value
for key, value in (
("include_sites", tuple(d for d in domains if d and not d.startswith("-"))),
("exclude_sites", tuple(d[1:] for d in domains if d.startswith("-") and len(d) > 1)),
)
if value
}
)

View file

@ -19669,6 +19669,14 @@
"notes": "Nimble Search API pay-as-you-go list price: $5 per 1,000 searches, up to 100 results per search. Volume plans price differently."
}
},
"search1api/search": {
"input_cost_per_query": 0.001,
"litellm_provider": "search1api",
"mode": "search",
"metadata": {
"notes": "Search1API pay-as-you-go list price: $1 per 1,000 credits, 1 credit per search. Subscriptions go from $0.76/1k (monthly) down to $0.42/1k (Enterprise annual)."
}
},
"elevenlabs/scribe_v1": {
"input_cost_per_second": 6.11e-05,
"litellm_provider": "elevenlabs",

View file

@ -3957,6 +3957,7 @@ class SearchProviders(str, Enum):
AGENTCORE = "agentcore"
NIMBLE = "nimble"
BING_GROUNDING = "bing_grounding"
SEARCH1API = "search1api"
# Create a set of all search provider values for quick lookup

View file

@ -9324,6 +9324,7 @@ class ProviderConfigManager:
ParallelAISearchConfig,
)
from litellm.llms.perplexity.search.transformation import PerplexitySearchConfig
from litellm.llms.search1api.search.transformation import Search1APISearchConfig
from litellm.llms.searchapi.search.transformation import SearchAPIConfig
from litellm.llms.searxng.search.transformation import SearXNGSearchConfig
from litellm.llms.serper.search.transformation import SerperSearchConfig
@ -9352,6 +9353,7 @@ class ProviderConfigManager:
SearchProviders.AGENTCORE: AgentCoreSearchConfig,
SearchProviders.NIMBLE: NimbleSearchConfig,
SearchProviders.BING_GROUNDING: BingGroundingSearchConfig,
SearchProviders.SEARCH1API: Search1APISearchConfig,
}
config_class: Final = PROVIDER_TO_CONFIG_MAP.get(provider, None)
if config_class is None:

View file

@ -19669,6 +19669,14 @@
"notes": "Nimble Search API pay-as-you-go list price: $5 per 1,000 searches, up to 100 results per search. Volume plans price differently."
}
},
"search1api/search": {
"input_cost_per_query": 0.001,
"litellm_provider": "search1api",
"mode": "search",
"metadata": {
"notes": "Search1API pay-as-you-go list price: $1 per 1,000 credits, 1 credit per search. Subscriptions go from $0.76/1k (monthly) down to $0.42/1k (Enterprise annual)."
}
},
"elevenlabs/scribe_v1": {
"input_cost_per_second": 6.11e-05,
"litellm_provider": "elevenlabs",

View file

@ -2501,6 +2501,13 @@
"search": true
}
},
"search1api": {
"display_name": "Search1API (`search1api`)",
"url": "https://docs.litellm.ai/docs/search/search1api",
"endpoints": {
"search": true
}
},
"triton": {
"display_name": "Triton (`triton`)",
"url": "https://docs.litellm.ai/docs/providers/triton-inference-server",

View file

@ -21,6 +21,7 @@ SEARCH_PROVIDERS = [
"apiserpent",
"tinyfish",
"nimble",
"search1api",
]
ALLOWED_FILES_IN_LLMS_FOLDER = [

View file

@ -0,0 +1,166 @@
"""
Tests for Search1API Search integration.
"""
import json
import pytest
import litellm
from tests.search_tests.base_search_unit_tests import BaseSearchTest
SEARCH1API_SEARCH_URL = "https://api.search1api.com/search"
MOCK_SEARCH1API_RESPONSE = {
"searchParameters": {
"query": "search1api web search",
"search_service": "google",
"max_results": 2,
"crawl_results": 0,
"image": False,
"include_sites": ["s1.dev"],
"exclude_sites": ["spam.example"],
},
"results": [
{
"title": "Search1API: One API to search, crawl, and ingest",
"link": "https://s1.dev/",
"snippet": "Web search, news, crawling and extraction APIs for AI agents.",
},
{
"title": "Search1API Docs: Search",
"link": "https://s1.dev/docs/basic/search",
"snippet": "Search the web across multiple engines and return ranked results.",
},
],
}
@pytest.mark.skip(reason="Local only tested search providers")
class TestSearch1APISearch(BaseSearchTest):
"""
E2E tests for Search1API Search functionality that make real API calls.
Inherits from BaseSearchTest to run standard search tests.
"""
def get_search_provider(self) -> str:
return "search1api"
class TestSearch1APISearchTransformation:
"""
Full-stack tests through `litellm.search` / `litellm.asearch` with the HTTP boundary faked by respx.
Transformation details are unit-tested in tests/test_litellm/llms/search1api/search/.
"""
@pytest.fixture(autouse=True)
def _server_key(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("SEARCH1API_API_KEY", "test-api-key")
monkeypatch.delenv("SEARCH1API_KEY", raising=False)
monkeypatch.delenv("SEARCH1API_API_BASE", raising=False)
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
@pytest.mark.respx()
def test_search1api_search_request_and_response(self, respx_mock):
route = respx_mock.post(SEARCH1API_SEARCH_URL).respond(json=MOCK_SEARCH1API_RESPONSE)
response = litellm.search(
query="search1api web search",
search_provider="search1api",
max_results=2,
country="us",
search_domain_filter=["s1.dev", "-spam.example"],
)
assert route.called
request = route.calls.last.request
assert request.headers["Authorization"] == "Bearer test-api-key"
assert request.headers["Content-Type"] == "application/json"
request_body = json.loads(request.content)
assert request_body["query"] == "search1api web search"
assert request_body["max_results"] == 2
assert request_body["include_sites"] == ["s1.dev"]
assert request_body["exclude_sites"] == ["spam.example"]
assert "country" not in request_body
assert "search_domain_filter" not in request_body
assert response.object == "search"
assert len(response.results) == 2
assert response.results[0].title == "Search1API: One API to search, crawl, and ingest"
assert response.results[0].url == "https://s1.dev/"
assert response.results[0].snippet == "Web search, news, crawling and extraction APIs for AI agents."
assert response.results[0].date is None
@pytest.mark.respx()
def test_provider_specific_params_survive_to_the_wire(self, respx_mock):
"""Search1API-native params must not be eaten by `filter_out_litellm_params`."""
route = respx_mock.post(SEARCH1API_SEARCH_URL).respond(json=MOCK_SEARCH1API_RESPONSE)
litellm.search(
query="test query",
search_provider="search1api",
search_service="bing",
time_range="month",
language="de",
)
request_body = json.loads(route.calls.last.request.content)
assert request_body["search_service"] == "bing"
assert request_body["time_range"] == "month"
assert request_body["language"] == "de"
assert request_body["max_results"] == 10
@pytest.mark.respx()
def test_disabled_crawl_results_is_a_valid_search(self, respx_mock):
route = respx_mock.post(SEARCH1API_SEARCH_URL).respond(json=MOCK_SEARCH1API_RESPONSE)
response = litellm.search(query="test query", search_provider="search1api", crawl_results=0, image=False)
request_body = json.loads(route.calls.last.request.content)
assert "crawl_results" not in request_body
assert "image" not in request_body
assert len(response.results) == 2
@pytest.mark.respx()
def test_crawl_results_is_rejected_before_any_request_is_made(self, respx_mock):
"""A rejected param must fail before hitting the wire, so no Search1API credit is spent."""
with pytest.raises(Exception, match="crawl_results"):
litellm.search(query="test query", search_provider="search1api", crawl_results=1)
assert len(respx_mock.calls) == 0
@pytest.mark.respx()
def test_search1api_error_envelope_is_surfaced(self, respx_mock):
"""Verbatim shape of a Search1API 402; the caller sees Search1API's message and status, not a generic 500."""
respx_mock.post(SEARCH1API_SEARCH_URL).respond(
status_code=402,
json={"ok": False, "error": "Payment Required", "message": "Insufficient credits"},
)
with pytest.raises(Exception, match="Search1API: Insufficient credits") as excinfo:
litellm.search(query="test query", search_provider="search1api")
assert excinfo.value.status_code == 402
@pytest.mark.asyncio
@pytest.mark.respx()
async def test_search1api_asearch(self, respx_mock):
route = respx_mock.post(SEARCH1API_SEARCH_URL).respond(json=MOCK_SEARCH1API_RESPONSE)
response = await litellm.asearch(
query="latest ai developments",
search_provider="search1api",
search_service="duckduckgo",
)
assert json.loads(route.calls.last.request.content)["search_service"] == "duckduckgo"
assert len(response.results) == 2
@pytest.mark.respx()
def test_search1api_search_tracks_cost(self, respx_mock):
respx_mock.post(SEARCH1API_SEARCH_URL).respond(json=MOCK_SEARCH1API_RESPONSE)
response = litellm.search(query="pricing check", search_provider="search1api")
assert response._hidden_params["response_cost"] == pytest.approx(0.001)

View file

@ -31,6 +31,7 @@ from litellm.llms.linkup.search.transformation import LinkupSearchConfig
from litellm.llms.nimble.search.transformation import NimbleSearchConfig
from litellm.llms.parallel_ai.search.transformation import ParallelAISearchConfig
from litellm.llms.perplexity.search.transformation import PerplexitySearchConfig
from litellm.llms.search1api.search.transformation import Search1APISearchConfig
from litellm.llms.searchapi.search.transformation import SearchAPIConfig
from litellm.llms.searxng.search.transformation import SearXNGSearchConfig
from litellm.llms.serper.search.transformation import SerperSearchConfig
@ -60,6 +61,7 @@ _BASE_ENV_VARS = (
"TINYFISH_API_BASE",
"CRW_API_BASE",
"NIMBLE_API_BASE",
"SEARCH1API_API_BASE",
"BING_GROUNDING_PROJECT_ENDPOINT",
)
@ -101,6 +103,7 @@ PROVIDERS: Tuple[ProviderSpec, ...] = (
(TinyfishSearchConfig, {"TINYFISH_API_KEY": "srv"}, "caller-key", {}),
(FastCRWSearchConfig, {"CRW_API_KEY": "srv"}, "caller-key", {}),
(NimbleSearchConfig, {"NIMBLE_API_KEY": "srv"}, "caller-key", {}),
(Search1APISearchConfig, {"SEARCH1API_API_KEY": "srv"}, "caller-key", {}),
(BingGroundingSearchConfig, {"BING_GROUNDING_TOKEN": "srv"}, "caller-key", {}),
)

View file

@ -0,0 +1,274 @@
import json
from unittest.mock import Mock
import pytest
from litellm.llms.search1api.search.transformation import Search1APISearchConfig
def _config() -> Search1APISearchConfig:
return Search1APISearchConfig()
def _resp(payload, status_code: int = 200):
r = Mock()
r.status_code = status_code
r.headers = {}
r.text = payload if isinstance(payload, str) else json.dumps(payload)
r.content = r.text.encode()
return r
def _result(**overrides):
base = {
"title": "Test Title",
"link": "https://example.com",
"snippet": "Test snippet",
}
return {**base, **overrides}
def _payload(*results, **extra):
return {"searchParameters": {"query": "q"}, "results": list(results), **extra}
def test_ui_friendly_name():
assert _config().ui_friendly_name() == "Search1API"
def test_validate_environment_with_explicit_key():
headers = _config().validate_environment({}, api_key="explicit-key")
assert headers["Authorization"] == "Bearer explicit-key"
assert headers["Content-Type"] == "application/json"
def test_validate_environment_reads_env_key(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("SEARCH1API_KEY", raising=False)
monkeypatch.setenv("SEARCH1API_API_KEY", "env-key")
assert _config().validate_environment({})["Authorization"] == "Bearer env-key"
def test_validate_environment_falls_back_to_search1api_key(monkeypatch: pytest.MonkeyPatch):
"""`SEARCH1API_KEY` is what Search1API's own CLI/SDKs read, so a user who already has it set is not asked twice."""
monkeypatch.delenv("SEARCH1API_API_KEY", raising=False)
monkeypatch.setenv("SEARCH1API_KEY", "cli-key")
assert _config().validate_environment({})["Authorization"] == "Bearer cli-key"
def test_validate_environment_prefers_litellm_style_key(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("SEARCH1API_API_KEY", "litellm-key")
monkeypatch.setenv("SEARCH1API_KEY", "cli-key")
assert _config().validate_environment({})["Authorization"] == "Bearer litellm-key"
def test_validate_environment_missing_key_raises(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("SEARCH1API_API_KEY", raising=False)
monkeypatch.delenv("SEARCH1API_KEY", raising=False)
with pytest.raises(ValueError, match="SEARCH1API_API_KEY"):
_config().validate_environment({})
def test_validate_environment_does_not_mutate_and_is_idempotent():
"""The http handler re-runs validate_environment after search/main.py already did."""
config = _config()
caller_headers = {"X-Custom": "keep-me"}
once = config.validate_environment(caller_headers, api_key="k")
twice = config.validate_environment(once, api_key="k")
assert caller_headers == {"X-Custom": "keep-me"}
assert once == twice
assert once["X-Custom"] == "keep-me"
def test_get_complete_url_default_base(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("SEARCH1API_API_BASE", raising=False)
assert _config().get_complete_url(None, {}) == "https://api.search1api.com/search"
def test_get_complete_url_reads_env_base(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("SEARCH1API_API_BASE", "https://env-base.local")
assert _config().get_complete_url(None, {}) == "https://env-base.local/search"
@pytest.mark.parametrize(
"api_base",
[
"https://self-hosted.local",
"https://self-hosted.local/",
"https://self-hosted.local/search",
"https://self-hosted.local/search/",
],
)
def test_get_complete_url_appends_search_exactly_once(api_base: str):
assert _config().get_complete_url(api_base, {}) == "https://self-hosted.local/search"
def test_transform_search_request_joins_list_query():
assert _config().transform_search_request(["foo", "bar"], {})["query"] == "foo bar"
def test_transform_search_request_defaults_max_results_to_unified_spec():
"""Search1API defaults to 5 results; the unified spec documents 10, so 10 is sent explicitly."""
assert _config().transform_search_request("q", {})["max_results"] == 10
def test_transform_search_request_max_results_is_not_clamped():
"""Search1API validates 1-50 itself; a clearer error beats silently rewriting the request."""
assert _config().transform_search_request("q", {"max_results": 500})["max_results"] == 500
@pytest.mark.parametrize("dropped", ["country", "max_tokens_per_page"])
def test_transform_search_request_drops_params_without_equivalent(dropped: str):
assert dropped not in _config().transform_search_request("q", {dropped: "US"})
def test_transform_search_request_splits_domain_filter():
data = _config().transform_search_request("q", {"search_domain_filter": ["arxiv.org", "-spam.com", "nature.com"]})
assert data["include_sites"] == ("arxiv.org", "nature.com")
assert data["exclude_sites"] == ("spam.com",)
assert "search_domain_filter" not in data
def test_transform_search_request_omits_empty_site_lists():
data = _config().transform_search_request("q", {"search_domain_filter": ["arxiv.org"]})
assert data["include_sites"] == ("arxiv.org",)
assert "exclude_sites" not in data
def test_transform_search_request_ignores_non_list_domain_filter():
assert "include_sites" not in _config().transform_search_request("q", {"search_domain_filter": "arxiv.org"})
@pytest.mark.parametrize("native_key", ["include_sites", "exclude_sites"])
def test_transform_search_request_native_sites_win(native_key: str):
"""An explicit provider-native value must not be silently clobbered by the unified param."""
data = _config().transform_search_request(
"q",
{"search_domain_filter": ["derived.com", "-derived-ex.com"], native_key: ["native.com"]},
)
assert data[native_key] == ["native.com"]
def test_transform_search_request_forwards_provider_params():
data = _config().transform_search_request(
"q",
{"search_service": "bing", "time_range": "month", "language": "de"},
)
assert data["search_service"] == "bing"
assert data["time_range"] == "month"
assert data["language"] == "de"
@pytest.mark.parametrize("param", ["crawl_results", "image"])
def test_transform_search_request_rejects_params_the_unified_response_cannot_carry(param: str):
"""Fetched page text and image URLs have no field in the unified response, and every crawled page
bills a Search1API credit LiteLLM would not track, so these must fail loudly instead of silently
costing money for output that is thrown away."""
with pytest.raises(ValueError, match=param):
_config().transform_search_request("q", {param: 1})
@pytest.mark.parametrize("param, value", [("crawl_results", 0), ("image", False)])
def test_transform_search_request_drops_disabled_unsupported_params(param: str, value):
"""Explicitly disabling crawling or images is a valid search and must not be rejected."""
data = _config().transform_search_request("q", {param: value, "search_service": "bing"})
assert param not in data
assert data["search_service"] == "bing"
def test_transform_search_response_maps_fields():
resp = _config().transform_search_response(_resp(_payload(_result())), logging_obj=Mock())
assert resp.object == "search"
assert resp.results[0].title == "Test Title"
assert resp.results[0].url == "https://example.com"
assert resp.results[0].snippet == "Test snippet"
assert resp.results[0].date is None
def test_transform_search_response_ignores_fields_outside_the_unified_shape():
"""`content` and `images` only appear for params this adapter rejects; a caller-supplied `api_base`
proxy may still return them and they must not leak into the unified response."""
resp = _config().transform_search_response(
_resp(_payload(_result(content="Full page text"), images=["https://img.example/1.png"])),
logging_obj=Mock(),
)
assert not hasattr(resp.results[0], "content")
assert not hasattr(resp, "images")
def test_transform_search_response_preserves_order():
resp = _config().transform_search_response(
_resp(_payload(*(_result(title=t) for t in ("first", "second", "third")))),
logging_obj=Mock(),
)
assert [r.title for r in resp.results] == ["first", "second", "third"]
def test_transform_search_response_degraded_result_does_not_fail_the_call():
resp = _config().transform_search_response(
_resp(_payload({"link": "https://example.com"}, _result())), logging_obj=Mock()
)
assert len(resp.results) == 2
assert resp.results[0].title == ""
assert resp.results[0].snippet == ""
assert resp.results[1].title == "Test Title"
def test_transform_search_response_zero_hits():
"""A search with no hits really does come back as `"results": []` with HTTP 200."""
assert _config().transform_search_response(_resp(_payload()), logging_obj=Mock()).results == []
@pytest.mark.parametrize(
"body",
[
"<html>502 Bad Gateway</html>", # non-JSON body
'{"results": ["garbage"]}', # right key, wrong element shape
'{"results": {"unexpected": "shape"}}',
'{"results": null}', # must not degrade to a successful empty search
"{}", # ditto for an absent key
],
)
def test_transform_search_response_malformed_body_raises_instead_of_reporting_empty(body: str):
"""A 2xx body LiteLLM cannot parse must not be reported as a successful zero-result search."""
with pytest.raises(Exception, match="Search1API"):
_config().transform_search_response(_resp(body), logging_obj=Mock())
def test_transform_search_response_non_2xx_surfaces_search1api_message():
"""When a non-2xx body reaches the transform, the user sees Search1API's own message, not schema output."""
body = '{"ok":false,"error":"Payment Required","message":"Insufficient credits"}'
with pytest.raises(Exception, match=r"^Search1API: Insufficient credits\. See ") as excinfo:
_config().transform_search_response(_resp(body, status_code=402), logging_obj=Mock())
assert excinfo.value.status_code == 402
def test_get_error_class_attributes_the_provider():
error = _config().get_error_class(error_message="quota exceeded", status_code=429, headers={})
assert error.status_code == 429
assert "Search1API: quota exceeded" in str(error)
assert "s1.dev/docs" in str(error)
def test_get_error_class_unwraps_search1api_message_envelope():
"""Verbatim shape of a Search1API 401; the raw JSON envelope should not reach the user."""
error = _config().get_error_class(
error_message='{"ok":false,"error":"Unauthorized","message":"Unauthorized: Invalid bearer credential"}',
status_code=401,
headers={},
)
assert (
str(error) == "Search1API: Unauthorized: Invalid bearer credential. "
"See https://s1.dev/docs/basic/search for details."
)
def test_get_error_class_falls_back_to_error_field():
error = _config().get_error_class(error_message='{"ok":false,"error":"Payment Required"}', status_code=402, headers={})
assert str(error).startswith("Search1API: Payment Required.")
@pytest.mark.parametrize("body", ["<html>502 Bad Gateway</html>", '{"message": null}'])
def test_get_error_class_falls_back_to_the_raw_body(body: str):
assert f"Search1API: {body}." in str(_config().get_error_class(body, status_code=500, headers={}))