Skip to content
Open
3 changes: 3 additions & 0 deletions litellm/llms/keenable/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from litellm.llms.keenable.search.transformation import KeenableSearchConfig

__all__ = ("KeenableSearchConfig",)
3 changes: 3 additions & 0 deletions litellm/llms/keenable/search/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from litellm.llms.keenable.search.transformation import KeenableSearchConfig

__all__ = ("KeenableSearchConfig",)
270 changes: 270 additions & 0 deletions litellm/llms/keenable/search/transformation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
"""
Calls Keenable's /v1/search endpoint to search the web.

Keenable works without an API key: keyless requests go to /v1/search/public, which is rate
limited per IP and identifies the calling app by the X-Keenable-Title header. With an API key
the same body goes to /v1/search, which has higher limits.

Keenable API Reference: https://docs.keenable.ai/api-reference/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 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.types.llms.base import LiteLLMBaseModel

if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj

_KEENABLE_DOCS_URL: Final = "https://docs.keenable.ai/api-reference/search"

# Sent on every request. The keyless endpoint rejects a request without it (400), and it
# names the calling software only, never the user.
_APP_TITLE: Final = "litellm"


class _KeenableResult(LiteLLMBaseModel):
"""One entry of Keenable'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
url: str | None = None
# Usually empty. The page text is in `snippet`.
description: str | None = None
snippet: str | None = None
published_at: str | None = None
acquired_at: str | None = None


class _KeenableSearchResponse(LiteLLMBaseModel):
"""Keenable's /v1/search response envelope."""

model_config = ConfigDict(extra="ignore", frozen=True)

# Required: a search with no hits returns `[]`, so a null or absent `results` means the
# body is not a search response and must not be reported as a successful empty search.
results: tuple[_KeenableResult, ...]


class _ErrorEnvelope(LiteLLMBaseModel):
"""Keenable reports errors as `{"error": "Invalid parameter", "message": "..."}`."""

model_config = ConfigDict(extra="ignore", frozen=True)

error: str | None = None
message: str | None = None


_DomainListAdapter: Final = TypeAdapter(tuple[str, ...])

_NOTHING: Final[Mapping[str, object]] = MappingProxyType({})


def _optional(key: str, value: object) -> Mapping[str, object]:
"""A one-entry mapping to spread into a payload, or nothing when the value is absent."""
return MappingProxyType({key: value}) if value is not None else _NOTHING


class KeenableSearchConfig(BaseSearchConfig):
KEENABLE_API_BASE = "https://api.keenable.ai/v1"

@staticmethod
def ui_friendly_name() -> str:
return "Keenable"

def _resolve_api_key(self, api_key: str | None, api_base: str | None) -> str | None:
"""The caller's key, else KEENABLE_API_KEY, else None for a keyless call."""
return self.resolve_server_api_key(
caller_api_key=api_key,
caller_api_base=api_base,
key_env_vars=("KEENABLE_API_KEY",),
base_env_var=None,
default_api_base=self.KEENABLE_API_BASE,
)

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. No API key is required.

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.
"""
resolved_api_key: Final = self._resolve_api_key(api_key, api_base)
return {
**headers,
"Content-Type": "application/json",
"X-Keenable-Title": _APP_TITLE,
**_optional("Authorization", f"Bearer {resolved_api_key}" if resolved_api_key else None),
}

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:
"""
The keyed and keyless endpoints take the same body and differ only by path, so the key
decides it: /search with a key, /search/public without. ``api_base`` is the version
root (https://api.keenable.ai/v1); a base that already ends in either path is accepted.
"""
caller_api_key: Final = kwargs.get("api_key")
resolved_api_key: Final = self._resolve_api_key(
caller_api_key if isinstance(caller_api_key, str) else None, api_base
)
root: Final = (
(api_base or self.KEENABLE_API_BASE).rstrip("/").removesuffix("/search/public").removesuffix("/search")
)
return f"{root}/search" if resolved_api_key else f"{root}/search/public"

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 Keenable API format.

- query -> query (a list is joined with spaces; Keenable takes a single string)
- max_results -> max_results (sent unclamped so Keenable's own 1-50 validation reports the error)
- search_domain_filter -> `site` for a single domain, `site:` / `-site:` clauses in the query
otherwise, following the Perplexity unified spec where a `-` prefix excludes a domain
- country, max_tokens_per_page -> dropped (no Keenable equivalent)

Everything else is forwarded as-is, so the rest of Keenable's surface (date filters,
snippet_max_length) stays reachable without LiteLLM tracking it.
"""
unified_params: Final = self.get_supported_perplexity_optional_params()
include, exclude = _split_domains(optional_params.get("search_domain_filter"))
site: Final = include[0] if len(include) == 1 else None

# Spread after the derived `site` so an explicitly supplied one wins.
passthrough: Final = MappingProxyType(
{param: value for param, value in optional_params.items() if param not in unified_params}
)

return {
**_optional("site", site),
**passthrough,
"query": _query_with_domain_clauses(
" ".join(query) if isinstance(query, list) else query,
include=() if site else include,
exclude=exclude,
),
**_optional("max_results", optional_params.get("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 Keenable API response to LiteLLM unified SearchResponse format.

`snippet` carries the page text and `description` is usually empty, so the snippet comes
first. `date` is the page's `published_at` and `last_updated` is `acquired_at`, when
Keenable last fetched the page. A body that does not match the documented schema raises
an attributed error rather than being reported as a successful empty search.
"""
try:
parsed: Final = _KeenableSearchResponse.model_validate_json(raw_response.content)
except ValidationError as e:
raise self.get_error_class(
error_message=f"response does not match the documented /v1/search schema: {e}",
Comment on lines +219 to +223

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 API errors lose their message

For a non-2xx POST response, the search handler passes Keenable’s error body to this success-response parser. An error such as “Public API hourly limit reached” has no results field, so callers see a response-schema mismatch instead of the useful rate-limit message. This makes failed searches harder to diagnose.

status_code=raw_response.status_code,
headers=dict(raw_response.headers),
)

return SearchResponse(
results=[
SearchResult(
title=result.title or "",
url=result.url or "",
snippet=result.snippet or result.description or "",
date=result.published_at,
last_updated=result.acquired_at,
)
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(". ")
# Keyless calls share one per-IP budget, which a proxy serving many users exhausts first.
hint: Final = (
" Without an API key, requests share a per-IP limit; set KEENABLE_API_KEY to raise it."
if status_code == 429
else ""
)
return BaseLLMException(
status_code=status_code,
message=f"Keenable Search: {detail}.{hint} See {_KEENABLE_DOCS_URL} for details.",
headers=headers,
)


def _unwrap_error_detail(error_message: str) -> str:
"""
Surface the human-readable message inside Keenable'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 _split_domains(search_domain_filter: object) -> tuple[tuple[str, ...], tuple[str, ...]]:
"""
Split the unified `search_domain_filter` into included and excluded domains.

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 (), ()
return (
tuple(d for d in domains if d and not d.startswith("-")),
tuple(d[1:] for d in domains if d.startswith("-") and len(d) > 1),
)


def _query_with_domain_clauses(query: str, include: tuple[str, ...], exclude: tuple[str, ...]) -> str:
"""Append `(site:a OR site:b)` and `-site:c` clauses, which Keenable honors in the query."""
included: Final = (f"({' OR '.join(f'site:{d}' for d in include)})",) if include else ()
excluded: Final = tuple(f"-site:{d}" for d in exclude)
return " ".join((query, *included, *excluded))
8 changes: 8 additions & 0 deletions litellm/model_prices_and_context_window_backup.json
Original file line number Diff line number Diff line change
Expand Up @@ -22865,6 +22865,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."
}
},
"keenable/search": {
"input_cost_per_query": 0.004,
"litellm_provider": "keenable",
"mode": "search",
"metadata": {
"notes": "Keenable pay-as-you-go list price: $4 per 1,000 requests, after a free allowance of 100,000 requests a month per organization. Keyless requests to /v1/search/public are free and rate limited per IP."
}
},
"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 @@ -4224,6 +4224,7 @@ class SearchProviders(str, Enum):
DATAFORSEO = "dataforseo"
FIRECRAWL = "firecrawl"
FASTCRW = "fastcrw"
KEENABLE = "keenable"
SEARXNG = "searxng"
LINKUP = "linkup"
DUCKDUCKGO = "duckduckgo"
Expand Down
2 changes: 2 additions & 0 deletions litellm/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9823,6 +9823,7 @@ def get_provider_search_config(
from litellm.llms.fastcrw.search.transformation import FastCRWSearchConfig
from litellm.llms.firecrawl.search.transformation import FirecrawlSearchConfig
from litellm.llms.google_pse.search.transformation import GooglePSESearchConfig
from litellm.llms.keenable.search.transformation import KeenableSearchConfig
from litellm.llms.linkup.search.transformation import LinkupSearchConfig
from litellm.llms.nimble.search.transformation import NimbleSearchConfig
from litellm.llms.parallel_ai.search.transformation import (
Expand All @@ -9846,6 +9847,7 @@ def get_provider_search_config(
SearchProviders.DATAFORSEO: DataForSEOSearchConfig,
SearchProviders.FIRECRAWL: FirecrawlSearchConfig,
SearchProviders.FASTCRW: FastCRWSearchConfig,
SearchProviders.KEENABLE: KeenableSearchConfig,
SearchProviders.SEARXNG: SearXNGSearchConfig,
SearchProviders.LINKUP: LinkupSearchConfig,
SearchProviders.DUCKDUCKGO: DuckDuckGoSearchConfig,
Expand Down
8 changes: 8 additions & 0 deletions model_prices_and_context_window.json
Original file line number Diff line number Diff line change
Expand Up @@ -22865,6 +22865,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."
}
},
"keenable/search": {
"input_cost_per_query": 0.004,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Free searches accrue paid spend

When a request has no Keenable API key, it uses the free /search/public endpoint. This price still charges $0.004 per query because search cost calculation does not distinguish public from keyed requests. Keyless searches will therefore inflate reported spend and can consume proxy budgets without incurring a Keenable charge.

Comment thread
veria-ai[bot] marked this conversation as resolved.
Resolved
"litellm_provider": "keenable",
"mode": "search",
"metadata": {
"notes": "Keenable pay-as-you-go list price: $4 per 1,000 requests, after a free allowance of 100,000 requests a month per organization. Keyless requests to /v1/search/public are free and rate limited per IP."
}
},
"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 @@ -2619,6 +2619,13 @@
"search": true
}
},
"keenable": {
"display_name": "Keenable (`keenable`)",
"url": "https://docs.keenable.ai/api-reference/search",
"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 @@ -13,6 +13,7 @@
"brave",
"firecrawl",
"fastcrw",
"keenable",
"searxng",
"linkup",
"duckduckgo",
Expand Down
Loading
Loading