diff --git a/litellm/llms/search1api/__init__.py b/litellm/llms/search1api/__init__.py new file mode 100644 index 00000000000..74fea1f3403 --- /dev/null +++ b/litellm/llms/search1api/__init__.py @@ -0,0 +1,3 @@ +from litellm.llms.search1api.search.transformation import Search1APISearchConfig + +__all__ = ("Search1APISearchConfig",) diff --git a/litellm/llms/search1api/search/__init__.py b/litellm/llms/search1api/search/__init__.py new file mode 100644 index 00000000000..74fea1f3403 --- /dev/null +++ b/litellm/llms/search1api/search/__init__.py @@ -0,0 +1,3 @@ +from litellm.llms.search1api.search.transformation import Search1APISearchConfig + +__all__ = ("Search1APISearchConfig",) diff --git a/litellm/llms/search1api/search/transformation.py b/litellm/llms/search1api/search/transformation.py new file mode 100644 index 00000000000..c160fd0ea7a --- /dev/null +++ b/litellm/llms/search1api/search/transformation.py @@ -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 + } + ) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d8a8f84b032..ab6efeaf066 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 569fce4f7b8..e6e2350cbf5 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -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 diff --git a/litellm/utils.py b/litellm/utils.py index ba456fc353b..a9a74f78f86 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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: diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index d8a8f84b032..ab6efeaf066 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index ebc220b3496..41fb6963b9f 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -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", diff --git a/tests/code_coverage_tests/enforce_llms_folder_style.py b/tests/code_coverage_tests/enforce_llms_folder_style.py index a284cf9e1a9..6cd2e648561 100644 --- a/tests/code_coverage_tests/enforce_llms_folder_style.py +++ b/tests/code_coverage_tests/enforce_llms_folder_style.py @@ -21,6 +21,7 @@ SEARCH_PROVIDERS = [ "apiserpent", "tinyfish", "nimble", + "search1api", ] ALLOWED_FILES_IN_LLMS_FOLDER = [ diff --git a/tests/search_tests/test_search1api_search.py b/tests/search_tests/test_search1api_search.py new file mode 100644 index 00000000000..8b58a45fa0f --- /dev/null +++ b/tests/search_tests/test_search1api_search.py @@ -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) 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 e6aad7688d1..63450e28423 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.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", {}), ) diff --git a/tests/test_litellm/llms/search1api/search/test_search1api_search_transformation.py b/tests/test_litellm/llms/search1api/search/test_search1api_search_transformation.py new file mode 100644 index 00000000000..19fdd73af7b --- /dev/null +++ b/tests/test_litellm/llms/search1api/search/test_search1api_search_transformation.py @@ -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", + [ + "502 Bad Gateway", # 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", ["502 Bad Gateway", '{"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={}))