mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
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:
parent
11a02b9581
commit
2db40a7b9c
12 changed files with 730 additions and 0 deletions
3
litellm/llms/search1api/__init__.py
Normal file
3
litellm/llms/search1api/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from litellm.llms.search1api.search.transformation import Search1APISearchConfig
|
||||
|
||||
__all__ = ("Search1APISearchConfig",)
|
||||
3
litellm/llms/search1api/search/__init__.py
Normal file
3
litellm/llms/search1api/search/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from litellm.llms.search1api.search.transformation import Search1APISearchConfig
|
||||
|
||||
__all__ = ("Search1APISearchConfig",)
|
||||
254
litellm/llms/search1api/search/transformation.py
Normal file
254
litellm/llms/search1api/search/transformation.py
Normal 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
|
||||
}
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ SEARCH_PROVIDERS = [
|
|||
"apiserpent",
|
||||
"tinyfish",
|
||||
"nimble",
|
||||
"search1api",
|
||||
]
|
||||
|
||||
ALLOWED_FILES_IN_LLMS_FOLDER = [
|
||||
|
|
|
|||
166
tests/search_tests/test_search1api_search.py
Normal file
166
tests/search_tests/test_search1api_search.py
Normal 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)
|
||||
|
|
@ -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", {}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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={}))
|
||||
Loading…
Add table
Reference in a new issue