Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions litellm/llms/apiserpent/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""APISerpent integration for LiteLLM."""
8 changes: 8 additions & 0 deletions litellm/llms/apiserpent/search/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""
APISerpent Search API module.
"""

from litellm.llms.apiserpent.search.defaults import APISerpentSearchParams
from litellm.llms.apiserpent.search.transformation import APISerpentSearchConfig

__all__ = ["APISerpentSearchConfig", "APISerpentSearchParams"]
70 changes: 70 additions & 0 deletions litellm/llms/apiserpent/search/defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""
Default parameter values and shared constants for APISerpent search.

Single source of truth for the supported request parameters and their
package-level defaults. See https://apiserpent.com/docs.
"""

from dataclasses import asdict, dataclass
from typing import Dict, Literal, Optional

SearchEngine = Literal["google", "bing", "yahoo", "ddg"]
SafeSearch = Literal["off", "moderate", "strict"]
Freshness = Literal["h", "1h", "d", "1d", "7d", "w", "m", "1m", "y", "1y"]
ResponseFormat = Literal["full", "simple"]

NUM_MIN = 1
NUM_MIN_DEEP = 10
NUM_MAX = 100
PAGES_MIN = 1
PAGES_MAX = 10


@dataclass(frozen=True)
class APISerpentSearchParams:
"""
Supported APISerpent search parameters with package defaults.

Fields defaulting to ``None`` are only sent when the caller provides them;
the rest are always sent so behavior is deterministic regardless of any
server-side defaults.
"""

engine: SearchEngine = "google"
country: str = "us"
num: int = 10
format: ResponseFormat = "full"
pages: Optional[int] = None
freshness: Optional[Freshness] = None
safe: Optional[SafeSearch] = None
language: Optional[str] = None
pixel_position: Optional[bool] = None

def __post_init__(self) -> None:
# num's deep-search floor (NUM_MIN_DEEP) is endpoint-specific and enforced
# in the transform layer; here we only bound the absolute range.
if not NUM_MIN <= self.num <= NUM_MAX:
raise ValueError(
f"num must be between {NUM_MIN} and {NUM_MAX}, got {self.num}"
)
Comment thread
yudelevi marked this conversation as resolved.
if self.pages is not None and not PAGES_MIN <= self.pages <= PAGES_MAX:
raise ValueError(
f"pages must be between {PAGES_MIN} and {PAGES_MAX}, got {self.pages}"
)

def to_request_params(self) -> Dict:
"""Return non-None fields as request params, booleans lowercased."""
params: Dict = {}
for key, value in asdict(self).items():
if value is None:
continue
params[key] = str(value).lower() if isinstance(value, bool) else value
return params

@classmethod
def field_names(cls) -> set:
return set(cls.__dataclass_fields__.keys())


QUICK_SEARCH_PATH = "/api/search/quick"
DEEP_SEARCH_PATH = "/api/search"
182 changes: 182 additions & 0 deletions litellm/llms/apiserpent/search/transformation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""
Calls APISerpent's search endpoints to search Google, Bing, Yahoo, or DuckDuckGo.

Two endpoints under one provider, selected via the ``deep`` boolean param:
- ``deep=False`` (default) -> quick search (/api/search/quick)
- ``deep=True`` -> deep search (/api/search)

APISerpent API Reference: https://apiserpent.com/docs
"""

from typing import Dict, List, Literal, Optional, Union, cast
from urllib.parse import urlencode

import httpx

from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.apiserpent.search.defaults import (
DEEP_SEARCH_PATH,
NUM_MAX,
NUM_MIN,
NUM_MIN_DEEP,
QUICK_SEARCH_PATH,
APISerpentSearchParams,
)
from litellm.llms.base_llm.search.transformation import (
BaseSearchConfig,
SearchResponse,
SearchResult,
)
from litellm.secret_managers.main import get_secret_str

DEEP_SEARCH_PARAM = "deep"
APISERPENT_BASE = "https://apiserpent.com"
APISERPENT_PARAMS_KEY = "_apiserpent_params"


class APISerpentSearchConfig(BaseSearchConfig):
@staticmethod
def ui_friendly_name() -> str:
return "APISerpent"

def get_http_method(self) -> Literal["GET", "POST"]:
return "GET"

@staticmethod
def _is_deep_search(optional_params: dict) -> bool:
return bool(optional_params.get(DEEP_SEARCH_PARAM))

def validate_environment(
self,
headers: Dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
**kwargs,
) -> Dict:
api_key = api_key or get_secret_str("APISERPENT_API_KEY")
if not api_key:
raise ValueError(
"APISERPENT_API_KEY is not set. Set `APISERPENT_API_KEY` environment variable."
)
headers["X-API-Key"] = api_key
headers["Content-Type"] = "application/json"
return headers

def get_complete_url(
self,
api_base: Optional[str],
optional_params: dict,
data: Optional[Union[Dict, List[Dict]]] = None,
**kwargs,
) -> str:
"""
Build the search URL. APISerpent uses GET, so the transformed request is
serialized into the query string. The endpoint path (quick vs deep) is
always applied; an ``api_base`` / ``APISERPENT_API_BASE`` override only
changes the host. The ``endswith`` guard keeps this idempotent, since the
handler re-invokes this method with the already-resolved URL as api_base.
"""
base = (
api_base or get_secret_str("APISERPENT_API_BASE") or APISERPENT_BASE
).rstrip("/")
path = (
DEEP_SEARCH_PATH
if self._is_deep_search(optional_params)
else QUICK_SEARCH_PATH
)
if not base.endswith(path):
base = f"{base}{path}"

if data and isinstance(data, dict) and APISERPENT_PARAMS_KEY in data:
query_string = urlencode(data[APISERPENT_PARAMS_KEY], doseq=True)
return f"{base}?{query_string}"

return base

def transform_search_request(
self,
query: Union[str, List[str]],
optional_params: dict,
**kwargs,
) -> Dict:
"""
Transform a unified search request into APISerpent query params.

Unified spec mappings:
- query -> q
- max_results -> num (clamped to the endpoint's valid range)
- country -> country (lowercased)
- search_domain_filter -> site: clauses appended to q

All other APISerpent params (engine, language, freshness, safe, pages,
format, pixel_position) pass through, defaulting via APISerpentSearchParams.
"""
if isinstance(query, list):
query = " ".join(query)

is_deep = self._is_deep_search(optional_params)

overrides: Dict = {}
if "max_results" in optional_params:
num_min = NUM_MIN_DEEP if is_deep else NUM_MIN
overrides["num"] = max(
num_min, min(optional_params["max_results"], NUM_MAX)
)
if "country" in optional_params:
overrides["country"] = cast(str, optional_params["country"]).lower()

for param, value in optional_params.items():
if param in APISerpentSearchParams.field_names() and param not in overrides:
overrides[param] = value

params = {**APISerpentSearchParams(**overrides).to_request_params(), "q": query}

if "search_domain_filter" in optional_params:
domains = optional_params["search_domain_filter"]
if isinstance(domains, list) and len(domains) > 0:
params["q"] = self._append_domain_filters(str(params["q"]), domains)

return {APISERPENT_PARAMS_KEY: params}

@staticmethod
def _append_domain_filters(query: str, domains: List[str]) -> str:
domain_clauses = " OR ".join(f"site:{domain}" for domain in domains)
return f"({query}) ({domain_clauses})"

def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: Optional[LiteLLMLoggingObj],
**kwargs,
) -> SearchResponse:
"""
Transform APISerpent response to the unified SearchResponse format.

Full format nests results under ``results.organic[]``; simple format
returns a flat ``results[]`` array. Both expose title/url/snippet.
"""
response_json = raw_response.json()

raw_results = response_json.get("results") or {}
organic = (
raw_results.get("organic", [])
if isinstance(raw_results, dict)
else raw_results
)

results: List[SearchResult] = []
for result in organic:
results.append(
SearchResult(
title=result.get("title", ""),
url=result.get("url", ""),
snippet=result.get("snippet", ""),
date=result.get("date"),
last_updated=None,
)
)

return SearchResponse(
results=results,
object="search",
)
16 changes: 16 additions & 0 deletions litellm/model_prices_and_context_window_backup.json
Original file line number Diff line number Diff line change
Expand Up @@ -13380,6 +13380,22 @@
"notes": "Serper Google Search API. Pricing: $1.00/1k queries (Starter), $0.75/1k (Standard), $0.50/1k (Scale), $0.30/1k (Ultimate)."
}
},
"apiserpent/search": {
"input_cost_per_query": 0.0006,
"litellm_provider": "apiserpent",
"mode": "search",
"metadata": {
"notes": "APISerpent quick search (/api/search/quick), multi-engine (Google, Bing, Yahoo, DuckDuckGo). Pricing: $0.60/1k searches."
}
},
"apiserpent/deep_search": {
"input_cost_per_query": 0.0006,
"litellm_provider": "apiserpent",
"mode": "search",
"metadata": {
"notes": "APISerpent deep search (/api/search), multi-engine (Google, Bing, Yahoo, DuckDuckGo). Pricing: $0.60/1k searches."
}
},
"elevenlabs/scribe_v1": {
"input_cost_per_second": 6.11e-05,
"litellm_provider": "elevenlabs",
Expand Down
1 change: 1 addition & 0 deletions litellm/types/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3404,6 +3404,7 @@ class SearchProviders(str, Enum):
DUCKDUCKGO = "duckduckgo"
SEARCHAPI = "searchapi"
SERPER = "serper"
APISERPENT = "apiserpent"


# Create a set of all search provider values for quick lookup
Expand Down
4 changes: 4 additions & 0 deletions litellm/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9389,6 +9389,9 @@ def get_provider_search_config(
"""
Get Search configuration for a given provider.
"""
from litellm.llms.apiserpent.search.transformation import (
APISerpentSearchConfig,
)
from litellm.llms.brave.search.transformation import BraveSearchConfig
from litellm.llms.dataforseo.search.transformation import DataForSEOSearchConfig
from litellm.llms.duckduckgo.search.transformation import DuckDuckGoSearchConfig
Expand Down Expand Up @@ -9419,6 +9422,7 @@ def get_provider_search_config(
SearchProviders.DUCKDUCKGO: DuckDuckGoSearchConfig,
SearchProviders.SEARCHAPI: SearchAPIConfig,
SearchProviders.SERPER: SerperSearchConfig,
SearchProviders.APISERPENT: APISerpentSearchConfig,
}
config_class = PROVIDER_TO_CONFIG_MAP.get(provider, None)
if config_class is None:
Expand Down
16 changes: 16 additions & 0 deletions model_prices_and_context_window.json
Original file line number Diff line number Diff line change
Expand Up @@ -13380,6 +13380,22 @@
"notes": "Serper Google Search API. Pricing: $1.00/1k queries (Starter), $0.75/1k (Standard), $0.50/1k (Scale), $0.30/1k (Ultimate)."
}
},
"apiserpent/search": {
"input_cost_per_query": 0.0006,
"litellm_provider": "apiserpent",
"mode": "search",
"metadata": {
"notes": "APISerpent quick search (/api/search/quick), multi-engine (Google, Bing, Yahoo, DuckDuckGo). Pricing: $0.60/1k searches."
}
},
"apiserpent/deep_search": {
"input_cost_per_query": 0.0006,
"litellm_provider": "apiserpent",
"mode": "search",
"metadata": {
"notes": "APISerpent deep search (/api/search), multi-engine (Google, Bing, Yahoo, DuckDuckGo). Pricing: $0.60/1k searches."
}
},
"elevenlabs/scribe_v1": {
"input_cost_per_second": 6.11e-05,
"litellm_provider": "elevenlabs",
Expand Down
7 changes: 7 additions & 0 deletions provider_endpoints_support.json
Original file line number Diff line number Diff line change
Expand Up @@ -2154,6 +2154,13 @@
"search": true
}
},
"apiserpent": {
"display_name": "APISerpent (`apiserpent`)",
"url": "https://docs.litellm.ai/docs/search/apiserpent",
"endpoints": {
"search": true
}
},
"triton": {
"display_name": "Triton (`triton`)",
"url": "https://docs.litellm.ai/docs/providers/triton-inference-server",
Expand Down
1 change: 1 addition & 0 deletions tests/code_coverage_tests/enforce_llms_folder_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"duckduckgo",
"searchapi",
"serper",
"apiserpent",
]

ALLOWED_FILES_IN_LLMS_FOLDER = [
Expand Down
Loading
Loading