From d2b55428fdcced79e8b2111be28271f782f4797a Mon Sep 17 00:00:00 2001 From: Ilya Bogin Date: Tue, 6 Oct 2026 09:07:46 +0300 Subject: [PATCH] feat(search): add Keenable as a search provider --- litellm/llms/keenable/__init__.py | 3 + litellm/llms/keenable/search/__init__.py | 3 + .../llms/keenable/search/transformation.py | 270 ++++++++++++++++++ ...odel_prices_and_context_window_backup.json | 8 + litellm/types/utils.py | 1 + litellm/utils.py | 2 + model_prices_and_context_window.json | 8 + provider_endpoints_support.json | 7 + .../enforce_llms_folder_style.py | 1 + .../search/test_base_search_transformation.py | 42 +-- tests/unit/llms/keenable/__init__.py | 0 tests/unit/llms/keenable/search/__init__.py | 0 .../test_keenable_search_transformation.py | 238 +++++++++++++++ .../public/assets/logos/keenable.png | Bin 0 -> 6464 bytes .../_components/CreateSearchTools.tsx | 2 + 15 files changed, 555 insertions(+), 30 deletions(-) create mode 100644 litellm/llms/keenable/__init__.py create mode 100644 litellm/llms/keenable/search/__init__.py create mode 100644 litellm/llms/keenable/search/transformation.py create mode 100644 tests/unit/llms/keenable/__init__.py create mode 100644 tests/unit/llms/keenable/search/__init__.py create mode 100644 tests/unit/llms/keenable/search/test_keenable_search_transformation.py create mode 100644 ui/litellm-dashboard/public/assets/logos/keenable.png diff --git a/litellm/llms/keenable/__init__.py b/litellm/llms/keenable/__init__.py new file mode 100644 index 00000000000..8db461ddc5e --- /dev/null +++ b/litellm/llms/keenable/__init__.py @@ -0,0 +1,3 @@ +from litellm.llms.keenable.search.transformation import KeenableSearchConfig + +__all__ = ("KeenableSearchConfig",) diff --git a/litellm/llms/keenable/search/__init__.py b/litellm/llms/keenable/search/__init__.py new file mode 100644 index 00000000000..8db461ddc5e --- /dev/null +++ b/litellm/llms/keenable/search/__init__.py @@ -0,0 +1,3 @@ +from litellm.llms.keenable.search.transformation import KeenableSearchConfig + +__all__ = ("KeenableSearchConfig",) diff --git a/litellm/llms/keenable/search/transformation.py b/litellm/llms/keenable/search/transformation.py new file mode 100644 index 00000000000..b68c3460fe3 --- /dev/null +++ b/litellm/llms/keenable/search/transformation.py @@ -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}", + 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)) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 5f4b42bc923..5ba62b2e2f8 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index d3fe65037e2..89a0a5e2ca4 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4224,6 +4224,7 @@ class SearchProviders(str, Enum): DATAFORSEO = "dataforseo" FIRECRAWL = "firecrawl" FASTCRW = "fastcrw" + KEENABLE = "keenable" SEARXNG = "searxng" LINKUP = "linkup" DUCKDUCKGO = "duckduckgo" diff --git a/litellm/utils.py b/litellm/utils.py index a2dac2fcf8d..395c5bbdfa4 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9823,6 +9823,7 @@ class ProviderConfigManager: 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 ( @@ -9846,6 +9847,7 @@ class ProviderConfigManager: SearchProviders.DATAFORSEO: DataForSEOSearchConfig, SearchProviders.FIRECRAWL: FirecrawlSearchConfig, SearchProviders.FASTCRW: FastCRWSearchConfig, + SearchProviders.KEENABLE: KeenableSearchConfig, SearchProviders.SEARXNG: SearXNGSearchConfig, SearchProviders.LINKUP: LinkupSearchConfig, SearchProviders.DUCKDUCKGO: DuckDuckGoSearchConfig, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 5f4b42bc923..5ba62b2e2f8 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 00904219b81..31cb0d7d673 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -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", diff --git a/tests/code_coverage_tests/enforce_llms_folder_style.py b/tests/code_coverage_tests/enforce_llms_folder_style.py index a284cf9e1a9..424f1236485 100644 --- a/tests/code_coverage_tests/enforce_llms_folder_style.py +++ b/tests/code_coverage_tests/enforce_llms_folder_style.py @@ -13,6 +13,7 @@ SEARCH_PROVIDERS = [ "brave", "firecrawl", "fastcrw", + "keenable", "searxng", "linkup", "duckduckgo", diff --git a/tests/unit/llms/base_llm/search/test_base_search_transformation.py b/tests/unit/llms/base_llm/search/test_base_search_transformation.py index e6aad7688d1..122bfa109de 100644 --- a/tests/unit/llms/base_llm/search/test_base_search_transformation.py +++ b/tests/unit/llms/base_llm/search/test_base_search_transformation.py @@ -25,6 +25,7 @@ from litellm.llms.brave.search.transformation import BraveSearchConfig from litellm.llms.dataforseo.search.transformation import DataForSEOSearchConfig from litellm.llms.exa_ai.search.transformation import ExaAISearchConfig from litellm.llms.fastcrw.search.transformation import FastCRWSearchConfig +from litellm.llms.keenable.search.transformation import KeenableSearchConfig from litellm.llms.firecrawl.search.transformation import FirecrawlSearchConfig from litellm.llms.google_pse.search.transformation import GooglePSESearchConfig from litellm.llms.linkup.search.transformation import LinkupSearchConfig @@ -100,6 +101,7 @@ PROVIDERS: Tuple[ProviderSpec, ...] = ( ), (TinyfishSearchConfig, {"TINYFISH_API_KEY": "srv"}, "caller-key", {}), (FastCRWSearchConfig, {"CRW_API_KEY": "srv"}, "caller-key", {}), + (KeenableSearchConfig, {"KEENABLE_API_KEY": "srv"}, "caller-key", {}), (NimbleSearchConfig, {"NIMBLE_API_KEY": "srv"}, "caller-key", {}), (BingGroundingSearchConfig, {"BING_GROUNDING_TOKEN": "srv"}, "caller-key", {}), ) @@ -107,9 +109,7 @@ PROVIDERS: Tuple[ProviderSpec, ...] = ( _IDS = tuple(spec[0].__name__ for spec in PROVIDERS) -@pytest.mark.parametrize( - "config_cls, server_env, caller_key, extra_env", PROVIDERS, ids=_IDS -) +@pytest.mark.parametrize("config_cls, server_env, caller_key, extra_env", PROVIDERS, ids=_IDS) def test_server_secret_refused_for_caller_api_base( config_cls: Type[BaseSearchConfig], server_env: Dict[str, str], @@ -124,9 +124,7 @@ def test_server_secret_refused_for_caller_api_base( config_cls().validate_environment(headers={}, api_base=ATTACKER_BASE) -@pytest.mark.parametrize( - "config_cls, server_env, caller_key, extra_env", PROVIDERS, ids=_IDS -) +@pytest.mark.parametrize("config_cls, server_env, caller_key, extra_env", PROVIDERS, ids=_IDS) def test_caller_supplied_key_is_honored_for_custom_api_base( config_cls: Type[BaseSearchConfig], server_env: Dict[str, str], @@ -139,14 +137,10 @@ def test_caller_supplied_key_is_honored_for_custom_api_base( # An explicit caller key is the caller's own credential, so pointing it at # the caller's own host must be allowed. - config_cls().validate_environment( - headers={}, api_key=caller_key, api_base=ATTACKER_BASE - ) + config_cls().validate_environment(headers={}, api_key=caller_key, api_base=ATTACKER_BASE) -@pytest.mark.parametrize( - "config_cls, server_env, caller_key, extra_env", PROVIDERS, ids=_IDS -) +@pytest.mark.parametrize("config_cls, server_env, caller_key, extra_env", PROVIDERS, ids=_IDS) def test_server_secret_used_without_caller_api_base( config_cls: Type[BaseSearchConfig], server_env: Dict[str, str], @@ -167,9 +161,7 @@ def test_keyless_provider_allows_caller_api_base( ) -> None: monkeypatch.delenv("SEARXNG_API_KEY", raising=False) - headers = SearXNGSearchConfig().validate_environment( - headers={}, api_base="https://my-searxng.internal" - ) + headers = SearXNGSearchConfig().validate_environment(headers={}, api_base="https://my-searxng.internal") assert "Authorization" not in headers @@ -182,9 +174,7 @@ def test_operator_env_base_override_is_trusted( # Mirrors the second validate_environment call in the search handler, which # receives the already-resolved operator base as api_base. - headers = SerperSearchConfig().validate_environment( - headers={}, api_base="https://serper.internal.corp/search" - ) + headers = SerperSearchConfig().validate_environment(headers={}, api_base="https://serper.internal.corp/search") assert headers["X-API-KEY"] == "srv" @@ -200,9 +190,7 @@ class TestResolveServerApiKey: ) assert result == "mine" - def test_returns_none_when_no_server_secret( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_returns_none_when_no_server_secret(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("SEARXNG_API_KEY", raising=False) result = BaseSearchConfig().resolve_server_api_key( caller_api_key=None, @@ -228,14 +216,10 @@ class TestResolveServerApiKey: class TestIsTrustedSearchApiBase: def test_matches_default_host(self) -> None: - assert _is_trusted_search_api_base( - "https://google.serper.dev/search", "https://google.serper.dev", None - ) + assert _is_trusted_search_api_base("https://google.serper.dev/search", "https://google.serper.dev", None) def test_foreign_host_untrusted(self) -> None: - assert not _is_trusted_search_api_base( - ATTACKER_BASE, "https://google.serper.dev", None - ) + assert not _is_trusted_search_api_base(ATTACKER_BASE, "https://google.serper.dev", None) def test_env_override_host_trusted(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("SERPER_API_BASE", "https://serper.internal.corp") @@ -248,9 +232,7 @@ class TestIsTrustedSearchApiBase: def test_schemeless_candidate_untrusted(self) -> None: # Without a scheme urlsplit puts the value in the path, leaving an empty # netloc; an unparseable host must never be treated as trusted. - assert not _is_trusted_search_api_base( - "attacker.example.com", "https://google.serper.dev", None - ) + assert not _is_trusted_search_api_base("attacker.example.com", "https://google.serper.dev", None) @pytest.mark.asyncio diff --git a/tests/unit/llms/keenable/__init__.py b/tests/unit/llms/keenable/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/keenable/search/__init__.py b/tests/unit/llms/keenable/search/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/keenable/search/test_keenable_search_transformation.py b/tests/unit/llms/keenable/search/test_keenable_search_transformation.py new file mode 100644 index 00000000000..dfe2c7de13b --- /dev/null +++ b/tests/unit/llms/keenable/search/test_keenable_search_transformation.py @@ -0,0 +1,238 @@ +import json +from unittest.mock import Mock, patch + +import pytest + +import litellm +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.keenable.search.transformation import KeenableSearchConfig + +DEFAULT_ROOT = "https://api.keenable.ai/v1" + + +@pytest.fixture(autouse=True) +def _no_server_key(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("KEENABLE_API_KEY", raising=False) + + +def _config() -> KeenableSearchConfig: + return KeenableSearchConfig() + + +def _resp(payload, status_code: int = 200): + r = Mock() + r.status_code = status_code + r.headers = {} + r.content = (payload if isinstance(payload, str) else json.dumps(payload)).encode() + return r + + +def _result(**overrides): + base = { + "title": "Test Title", + "url": "https://example.com", + "description": "", + "snippet": "Test page text", + "published_at": "2026-01-15T10:30:00Z", + "acquired_at": "2026-01-16T08:12:34Z", + } + return {**base, **overrides} + + +def test_ui_friendly_name(): + assert _config().ui_friendly_name() == "Keenable" + + +def test_keyless_request_names_the_app_and_targets_the_public_endpoint(): + headers = _config().validate_environment({}) + + assert "Authorization" not in headers + assert headers["X-Keenable-Title"] == "litellm" + assert headers["Content-Type"] == "application/json" + assert _config().get_complete_url(None, {}) == f"{DEFAULT_ROOT}/search/public" + + +def test_caller_key_authenticates_and_targets_the_keyed_endpoint(): + headers = _config().validate_environment({}, api_key="caller-key") + + assert headers["Authorization"] == "Bearer caller-key" + assert headers["X-Keenable-Title"] == "litellm" + assert _config().get_complete_url(None, {}, api_key="caller-key") == f"{DEFAULT_ROOT}/search" + + +def test_server_key_authenticates_and_targets_the_keyed_endpoint(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("KEENABLE_API_KEY", "env-key") + + assert _config().validate_environment({})["Authorization"] == "Bearer env-key" + assert _config().get_complete_url(None, {}) == f"{DEFAULT_ROOT}/search" + + +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" + + +@pytest.mark.parametrize( + "api_base", + [ + "https://self-hosted.local/v1", + "https://self-hosted.local/v1/", + "https://self-hosted.local/v1/search", + "https://self-hosted.local/v1/search/public", + "https://self-hosted.local/v1/search/public/", + ], +) +@pytest.mark.parametrize( + "api_key, path", + [(None, "/search/public"), ("caller-key", "/search")], +) +def test_get_complete_url_is_idempotent_and_the_key_picks_the_path(api_base: str, api_key, path: str): + # search/main.py builds the URL, then the http handler calls get_complete_url again with + # that URL as api_base, so feeding the result back in must not change it. + url = _config().get_complete_url(api_base, {}, api_key=api_key) + + assert url == f"https://self-hosted.local/v1{path}" + assert _config().get_complete_url(url, {}, api_key=api_key) == url + + +def test_request_maps_query_and_max_results_and_forwards_provider_params(): + body = _config().transform_search_request( + ["latest", "rust release"], + { + "max_results": 7, + "country": "US", + "max_tokens_per_page": 512, + "published_after": "7d", + "snippet_max_length": 500, + }, + ) + + assert body == { + "query": "latest rust release", + "max_results": 7, + "published_after": "7d", + "snippet_max_length": 500, + } + + +def test_single_domain_filter_uses_the_site_field(): + body = _config().transform_search_request("asyncio", {"search_domain_filter": ["docs.python.org"]}) + + assert body == {"query": "asyncio", "site": "docs.python.org"} + + +def test_several_domains_and_exclusions_become_query_clauses(): + body = _config().transform_search_request( + "asyncio", + {"search_domain_filter": ["realpython.com", "docs.python.org", "-medium.com", "-"]}, + ) + + assert body == {"query": "asyncio (site:realpython.com OR site:docs.python.org) -site:medium.com"} + + +def test_single_domain_with_exclusion_combines_site_and_clause(): + body = _config().transform_search_request("asyncio", {"search_domain_filter": ["docs.python.org", "-medium.com"]}) + + assert body == {"query": "asyncio -site:medium.com", "site": "docs.python.org"} + + +def test_explicit_site_wins_over_the_derived_one(): + body = _config().transform_search_request( + "asyncio", {"search_domain_filter": ["docs.python.org"], "site": "peps.python.org"} + ) + + assert body["site"] == "peps.python.org" + + +def test_malformed_domain_filter_is_ignored(): + body = _config().transform_search_request("asyncio", {"search_domain_filter": "docs.python.org"}) + + assert body == {"query": "asyncio"} + + +def test_response_maps_snippet_and_dates(): + response = _config().transform_search_response( + _resp({"query": "q", "results": [_result(), _result(snippet="", description="Only a description")]}), + logging_obj=Mock(), + ) + + first, second = response.results + assert (first.title, first.url, first.snippet) == ("Test Title", "https://example.com", "Test page text") + assert first.date == "2026-01-15T10:30:00Z" + assert first.last_updated == "2026-01-16T08:12:34Z" + assert second.snippet == "Only a description" + + +def test_degraded_result_maps_to_empty_strings(): + response = _config().transform_search_response(_resp({"results": [{}]}), logging_obj=Mock()) + + only = response.results[0] + assert (only.title, only.url, only.snippet, only.date, only.last_updated) == ("", "", "", None, None) + + +def test_empty_result_list_is_a_successful_search(): + assert _config().transform_search_response(_resp({"results": []}), logging_obj=Mock()).results == [] + + +@pytest.mark.parametrize("payload", [{}, {"results": None}, "bad gateway"]) +def test_body_that_is_not_a_search_response_raises(payload): + with pytest.raises(BaseLLMException, match="Keenable Search: response does not match"): + _config().transform_search_response(_resp(payload, status_code=200), logging_obj=Mock()) + + +def test_error_class_surfaces_the_api_message(): + error = _config().get_error_class( + '{"error": "Invalid parameter", "message": "\\"max_results\\": must be an integer between 1 and 50"}', + 400, + {}, + ) + + assert error.status_code == 400 + assert error.message.startswith('Keenable Search: "max_results": must be an integer between 1 and 50.') + assert "KEENABLE_API_KEY" not in error.message + + +def test_rate_limit_error_points_at_the_api_key(): + error = _config().get_error_class( + '{"error": "Rate limit exceeded", "message": "Public API hourly limit reached"}', 429, {} + ) + + assert error.status_code == 429 + assert "Public API hourly limit reached" in error.message + assert "set KEENABLE_API_KEY" in error.message + + +def test_non_json_error_body_is_kept(): + assert "upstream timeout" in _config().get_error_class("upstream timeout", 502, {}).message + + +@pytest.mark.parametrize( + "api_key, path, auth", + [(None, "/search/public", None), ("caller-key", "/search", "Bearer caller-key")], +) +def test_litellm_search_end_to_end(api_key, path: str, auth): + with patch( + "litellm.llms.custom_httpx.http_handler.HTTPHandler.post", + return_value=_resp({"query": "q", "results": [_result()]}), + ) as mock_post: + response = litellm.search( + query="rust release", + search_provider="keenable", + api_key=api_key, + max_results=3, + ) + + call = mock_post.call_args.kwargs + assert call["url"] == f"{DEFAULT_ROOT}{path}" + assert call["headers"]["X-Keenable-Title"] == "litellm" + assert call["headers"].get("Authorization") == auth + assert call["json"] == {"query": "rust release", "max_results": 3} + assert response.results[0].snippet == "Test page text" diff --git a/ui/litellm-dashboard/public/assets/logos/keenable.png b/ui/litellm-dashboard/public/assets/logos/keenable.png new file mode 100644 index 0000000000000000000000000000000000000000..7b9f4a6942bd86f187b87e8ce1bf77712e4fe505 GIT binary patch literal 6464 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 = { exa_ai: exaAiLogo.src, google_pse: googlePseLogo.src, dataforseo: dataforseoLogo.src, + keenable: keenableLogo.src, nimble: nimbleLogo.src, bing_grounding: bingLogo.src, };