diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index fb29a92e306..2606882c78f 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -318,6 +318,7 @@ jobs: tests/unit/llms/inception tests/unit/llms/infinity tests/unit/llms/jina_ai + tests/unit/llms/keenable tests/unit/llms/lambda_ai tests/unit/llms/langflow tests/unit/llms/langgraph diff --git a/litellm/llms/keenable/search/__init__.py b/litellm/llms/keenable/search/__init__.py new file mode 100644 index 00000000000..322ee824809 --- /dev/null +++ b/litellm/llms/keenable/search/__init__.py @@ -0,0 +1,7 @@ +""" +Keenable Search API module. +""" + +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..9ac4cb32e62 --- /dev/null +++ b/litellm/llms/keenable/search/transformation.py @@ -0,0 +1,169 @@ +""" +Calls Keenable's /v1/search endpoint to search the web. + +Keenable API Reference: https://docs.keenable.ai/api-reference/search +""" + +from collections.abc import Iterable, Mapping +from typing import Final, TypedDict + +import httpx +from pydantic import ConfigDict, TypeAdapter +from typing_extensions import NotRequired, ReadOnly + +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.litellm_core_utils.url_utils import ensure_api_route +from litellm.llms.base_llm.search.transformation import ( + BaseSearchConfig, + SearchResponse, + SearchResult, +) +from litellm.secret_managers.main import get_secret_str + +_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True)) +_JSON_VALUES: Final = TypeAdapter(Iterable[object], config=ConfigDict(hide_input_in_errors=True)) +_UNIFIED_PARAMS: Final = frozenset({"max_results", "search_domain_filter", "country", "max_tokens_per_page"}) + + +class KeenableSearchRequest(TypedDict): + """ + Keenable Search API request format. + Based on: https://docs.keenable.ai/api-reference/search + """ + + query: ReadOnly[str] + mode: NotRequired[ReadOnly[str]] + site: NotRequired[ReadOnly[str]] + acquired_after: NotRequired[ReadOnly[str]] + acquired_before: NotRequired[ReadOnly[str]] + published_after: NotRequired[ReadOnly[str]] + published_before: NotRequired[ReadOnly[str]] + query_time: NotRequired[ReadOnly[str]] + snippet_max_length: NotRequired[ReadOnly[int]] + max_results: NotRequired[ReadOnly[int]] + + +def _first_domain(domains: object) -> str | None: + if isinstance(domains, list) and domains and isinstance(domains[0], str) and domains[0]: + return domains[0] + if isinstance(domains, str) and domains: + return domains + return None + + +def _search_result(raw_result: object) -> SearchResult: + result: Final = _JSON_OBJECT.validate_python(raw_result) + return SearchResult.model_validate( + { + "title": result.get("title", ""), + "url": result.get("url", ""), + "snippet": result.get("snippet") or result.get("description") or "", + "date": result.get("published_at"), + "last_updated": result.get("acquired_at"), + } + ) + + +class KeenableSearchConfig(BaseSearchConfig): + KEENABLE_API_BASE = "https://api.keenable.ai/v1" + + @staticmethod + def ui_friendly_name() -> str: + return "Keenable" + + def validate_environment( + self, + headers: dict, + api_key: str | None = None, + api_base: str | None = None, + **kwargs, + ) -> dict: + """ + Authenticated calls send ``X-API-Key``. Without a key, Keenable's public + search tier still works and requires ``X-Keenable-Title`` instead. + """ + api_key = self.resolve_server_api_key( + caller_api_key=api_key, + caller_api_base=api_base, + key_env_vars=("KEENABLE_API_KEY",), + base_env_var="KEENABLE_API_BASE", + default_api_base=self.KEENABLE_API_BASE, + ) + headers["Content-Type"] = "application/json" + if api_key: + headers["X-API-Key"] = api_key + else: + headers["X-Keenable-Title"] = get_secret_str("KEENABLE_TITLE") or "LiteLLM" + return headers + + def get_complete_url( + self, + api_base: str | None, + optional_params: dict, + data: dict | list[dict] | None = None, + **kwargs, + ) -> str: + api_key: Final = kwargs.get("api_key") or get_secret_str("KEENABLE_API_KEY") + route: Final = "search" if api_key else "search/public" + return ensure_api_route(api_base or get_secret_str("KEENABLE_API_BASE") or self.KEENABLE_API_BASE, route) + + def transform_search_request( + self, + query: str | list[str], + optional_params: dict, + **kwargs, + ) -> dict: + """ + Transform Search request to Keenable API format. + + Transforms Perplexity unified spec parameters: + - query → query (same) + - max_results → max_results (same) + - search_domain_filter → site (first domain only; Keenable takes a single site) + - country → (not applicable, ignored) + - max_tokens_per_page → (not applicable, ignored; Keenable's snippet_max_length is + characters in 180–10000, not tokens) + + All other Keenable-specific parameters are passed through as-is. + """ + site: Final = _first_domain(optional_params.get("search_domain_filter")) + unified: Final[KeenableSearchRequest] = { + "query": " ".join(query) if isinstance(query, list) else query, + **({"max_results": optional_params["max_results"]} if "max_results" in optional_params else {}), + **({"site": site} if site is not None else {}), + } + passthrough: Final = { + param: value + for param, value in optional_params.items() + if param not in _UNIFIED_PARAMS and param not in unified + } + return {**unified, **passthrough} + + def transform_search_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + **kwargs, + ) -> SearchResponse: + """ + Transform Keenable API response to LiteLLM unified SearchResponse format. + + Keenable → LiteLLM mappings: + - results[].title → SearchResult.title + - results[].url → SearchResult.url + - results[].snippet → SearchResult.snippet + - results[].published_at → SearchResult.date + - results[].acquired_at → SearchResult.last_updated + - every top-level key except results → SearchResponse.provider_metadata + """ + response_json: Final = _JSON_OBJECT.validate_python(raw_response.json()) + return SearchResponse.model_validate( + { + "results": tuple( + _search_result(raw_result) + for raw_result in _JSON_VALUES.validate_python(response_json.get("results", [])) + ), + "object": "search", + "provider_metadata": {key: value for key, value in response_json.items() if key != "results"}, + } + ) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index b102d6e5a07..754cdaf0561 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -25096,6 +25096,16 @@ } ] }, + "keenable/search": { + "input_cost_per_query": 0.004, + "litellm_provider": "keenable", + "mode": "search", + "metadata": { + "notes": "Pay-as-you-go is $4 per 1,000 requests after 100K free/month (https://keenable.ai/pricing, retrieved 2026-11-02).", + "source": "https://keenable.ai/pricing", + "source_date": "2026-11-02" + } + }, "firecrawl/search": { "litellm_provider": "firecrawl", "mode": "search", diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index 00fee0a0d5a..da13fa5f2d0 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -1556,6 +1556,32 @@ "generateContent": false } }, + "keenable": { + "display_name": "Keenable (`keenable`)", + "url": "https://docs.keenable.ai/api-reference/search", + "endpoints": { + "chat_completions": false, + "responses": false, + "text_completion": false, + "embeddings": false, + "image_generations": false, + "image_edits": false, + "audio_transcriptions": false, + "audio_speech": false, + "rerank": false, + "ocr": false, + "search": true, + "moderations": false, + "realtime": false, + "video_generations": false, + "batches": false, + "messages": false, + "interactions": false, + "systemone": false, + "a2a": false, + "generateContent": false + } + }, "brave": { "display_name": "Brave Search (`brave`)", "url": "https://docs.litellm.ai/docs/search/brave", diff --git a/litellm/proxy/mcp_registry.json b/litellm/proxy/mcp_registry.json index 70e81f127c9..c767ae2929e 100644 --- a/litellm/proxy/mcp_registry.json +++ b/litellm/proxy/mcp_registry.json @@ -269,6 +269,19 @@ {"name": "EXA_API_KEY", "description": "Exa API Key", "secret": true} ] }, + { + "name": "keenable", + "title": "Keenable", + "description": "Web search and page fetch for AI agents", + "icon_url": "/ui/assets/logos/keenable.png", + "category": "Search", + "registry_url": null, + "transport": "http", + "url": "https://api.keenable.ai/mcp", + "env_vars": [ + {"name": "KEENABLE_API_KEY", "description": "Keenable API Key", "secret": true} + ] + }, { "name": "tavily", "title": "Tavily", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 755a0a8cc7a..d3cfed6c7c2 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4834,6 +4834,7 @@ class SearchProviders(str, Enum): TAVILY = "tavily" PARALLEL_AI = "parallel_ai" EXA_AI = "exa_ai" + KEENABLE = "keenable" BRAVE = "brave" GOOGLE_PSE = "google_pse" DATAFORSEO = "dataforseo" diff --git a/litellm/utils.py b/litellm/utils.py index 13f22ddc36e..4a915efae3a 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -11451,6 +11451,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 ( @@ -11470,6 +11471,7 @@ class ProviderConfigManager: SearchProviders.TAVILY: TavilySearchConfig, SearchProviders.PARALLEL_AI: ParallelAISearchConfig, SearchProviders.EXA_AI: ExaAISearchConfig, + SearchProviders.KEENABLE: KeenableSearchConfig, SearchProviders.BRAVE: BraveSearchConfig, SearchProviders.GOOGLE_PSE: GooglePSESearchConfig, SearchProviders.DATAFORSEO: DataForSEOSearchConfig, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index b102d6e5a07..754cdaf0561 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -25096,6 +25096,16 @@ } ] }, + "keenable/search": { + "input_cost_per_query": 0.004, + "litellm_provider": "keenable", + "mode": "search", + "metadata": { + "notes": "Pay-as-you-go is $4 per 1,000 requests after 100K free/month (https://keenable.ai/pricing, retrieved 2026-11-02).", + "source": "https://keenable.ai/pricing", + "source_date": "2026-11-02" + } + }, "firecrawl/search": { "litellm_provider": "firecrawl", "mode": "search", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 00fee0a0d5a..a22a52b7b95 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -1556,6 +1556,32 @@ "generateContent": false } }, + "keenable": { + "display_name": "Keenable (`keenable`)", + "url": "https://docs.keenable.ai/api-reference/search", + "endpoints": { + "chat_completions": false, + "responses": false, + "text_completion": false, + "embeddings": false, + "image_generations": false, + "image_edits": false, + "audio_transcriptions": false, + "audio_speech": false, + "rerank": false, + "ocr": false, + "search": true, + "moderations": false, + "realtime": false, + "video_generations": false, + "batches": false, + "messages": false, + "interactions": false, + "systemone": false, + "a2a": false, + "generateContent": false + } + }, "brave": { "display_name": "Brave Search (`brave`)", "url": "https://docs.litellm.ai/docs/search/brave", diff --git a/tests/code_coverage_tests/enforce_llms_folder_style.py b/tests/code_coverage_tests/enforce_llms_folder_style.py index a284cf9e1a9..9d8f5542027 100644 --- a/tests/code_coverage_tests/enforce_llms_folder_style.py +++ b/tests/code_coverage_tests/enforce_llms_folder_style.py @@ -10,6 +10,7 @@ SEARCH_PROVIDERS = [ "google_pse", "parallel_ai", "exa_ai", + "keenable", "brave", "firecrawl", "fastcrw", 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..b68d58c8cde 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 @@ -27,6 +27,7 @@ from litellm.llms.exa_ai.search.transformation import ExaAISearchConfig 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 ParallelAISearchConfig @@ -79,6 +80,7 @@ PROVIDERS: Tuple[ProviderSpec, ...] = ( (PerplexitySearchConfig, {"PERPLEXITYAI_API_KEY": "srv"}, "caller-key", {}), (APISerpentSearchConfig, {"APISERPENT_API_KEY": "srv"}, "caller-key", {}), (ExaAISearchConfig, {"EXA_API_KEY": "srv"}, "caller-key", {}), + (KeenableSearchConfig, {"KEENABLE_API_KEY": "srv"}, "caller-key", {}), (BraveSearchConfig, {"BRAVE_API_KEY": "srv"}, "caller-key", {}), (FirecrawlSearchConfig, {"FIRECRAWL_API_KEY": "srv"}, "caller-key", {}), (LinkupSearchConfig, {"LINKUP_API_KEY": "srv"}, "caller-key", {}), diff --git a/tests/unit/llms/keenable/search/test_transformation.py b/tests/unit/llms/keenable/search/test_transformation.py new file mode 100644 index 00000000000..de6e2d76fdb --- /dev/null +++ b/tests/unit/llms/keenable/search/test_transformation.py @@ -0,0 +1,179 @@ +import json +from typing import Final +from unittest.mock import Mock + +import httpx +import pytest +import respx + +import litellm +from litellm.llms.keenable.search.transformation import KeenableSearchConfig + +KEENABLE_SEARCH_URL: Final = "https://api.keenable.ai/v1/search" + + +@pytest.fixture +def keenable_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KEENABLE_API_KEY", "test-keenable-key") + monkeypatch.delenv("KEENABLE_API_BASE", raising=False) + + +@pytest.mark.usefixtures("keenable_api_key") +def test_search_maps_keenable_results_to_search_response(respx_mock: respx.MockRouter) -> None: + respx_mock.post(KEENABLE_SEARCH_URL).respond( + json={ + "query": "typescript best practices", + "mode": "pro", + "results": [ + { + "title": "TypeScript Best Practices 2026", + "url": "https://example.com/ts-best-practices", + "description": "", + "snippet": "Use strict mode and prefer interfaces.", + "published_at": "2026-01-15T10:30:00Z", + "acquired_at": "2026-01-16T08:12:34Z", + }, + { + "title": "Second", + "url": "https://example.com/second", + "snippet": "Second snippet.", + }, + ], + } + ) + + response: Final = litellm.search(query="typescript best practices", search_provider="keenable") + + assert response.object == "search" + assert len(response.results) == 2 + first: Final = response.results[0] + assert first.title == "TypeScript Best Practices 2026" + assert first.url == "https://example.com/ts-best-practices" + assert first.snippet == "Use strict mode and prefer interfaces." + assert first.date == "2026-01-15T10:30:00Z" + assert first.last_updated == "2026-01-16T08:12:34Z" + assert response.results[1].url == "https://example.com/second" + assert response.model_dump(mode="json")["provider_metadata"] == { + "query": "typescript best practices", + "mode": "pro", + } + + +@pytest.mark.usefixtures("keenable_api_key") +def test_search_sends_unified_params_and_api_key(respx_mock: respx.MockRouter) -> None: + route: Final = respx_mock.post(KEENABLE_SEARCH_URL).respond( + json={"results": [{"title": "ML", "url": "https://example.com/ml", "snippet": "Machine learning."}]} + ) + + response: Final = litellm.search( + query="machine learning", + search_provider="keenable", + max_results=5, + search_domain_filter=["arxiv.org", "nature.com"], + max_tokens_per_page=50, + ) + + assert json.loads(route.calls.last.request.content) == { + "query": "machine learning", + "max_results": 5, + "site": "arxiv.org", + } + assert route.calls.last.request.headers["x-api-key"] == "test-keenable-key" + assert [result.url for result in response.results] == ["https://example.com/ml"] + + +@pytest.mark.usefixtures("keenable_api_key") +def test_search_ignores_max_tokens_per_page(respx_mock: respx.MockRouter) -> None: + route: Final = respx_mock.post(KEENABLE_SEARCH_URL).respond(json={"results": []}) + + litellm.search(query="q", search_provider="keenable", max_tokens_per_page=50) + + assert "snippet_max_length" not in json.loads(route.calls.last.request.content) + + +@pytest.mark.usefixtures("keenable_api_key") +def test_search_joins_list_query_and_passes_keenable_params(respx_mock: respx.MockRouter) -> None: + route: Final = respx_mock.post(KEENABLE_SEARCH_URL).respond(json={"results": []}) + + litellm.search( + query=["AI news", "ML trends"], + search_provider="keenable", + mode="realtime", + published_after="7d", + ) + + assert json.loads(route.calls.last.request.content) == { + "query": "AI news ML trends", + "mode": "realtime", + "published_after": "7d", + } + + +def test_transform_search_response_falls_back_to_description_when_snippet_missing() -> None: + response: Final = KeenableSearchConfig().transform_search_response( + httpx.Response( + 200, + json={ + "results": [ + { + "title": "Legacy", + "url": "https://example.com/legacy", + "description": "old description field", + } + ] + }, + ), + logging_obj=Mock(), + ) + + assert response.results[0].snippet == "old description field" + + +def test_validate_environment_uses_title_header_when_keyless(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KEENABLE_API_KEY", raising=False) + monkeypatch.delenv("KEENABLE_TITLE", raising=False) + + headers: Final = KeenableSearchConfig().validate_environment(headers={}) + + assert headers["X-Keenable-Title"] == "LiteLLM" + assert "X-API-Key" not in headers + + +@pytest.mark.usefixtures("keenable_api_key") +@pytest.mark.parametrize( + "api_base", + [None, "https://api.keenable.ai/v1", "https://api.keenable.ai/v1/"], +) +def test_get_complete_url_appends_search_once(api_base: str | None, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KEENABLE_API_BASE", raising=False) + + assert KeenableSearchConfig().get_complete_url( + api_base=api_base, optional_params={}, api_key="test-keenable-key" + ) == ("https://api.keenable.ai/v1/search") + + +def test_get_complete_url_uses_public_route_when_keyless(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KEENABLE_API_KEY", raising=False) + monkeypatch.delenv("KEENABLE_API_BASE", raising=False) + + assert KeenableSearchConfig().get_complete_url(api_base=None, optional_params={}) == ( + "https://api.keenable.ai/v1/search/public" + ) + + +def test_search_keyless_hits_public_route_with_title_header( + monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter +) -> None: + monkeypatch.delenv("KEENABLE_API_KEY", raising=False) + monkeypatch.delenv("KEENABLE_API_BASE", raising=False) + monkeypatch.delenv("KEENABLE_TITLE", raising=False) + route: Final = respx_mock.post("https://api.keenable.ai/v1/search/public").respond( + json={"results": [{"title": "T", "url": "https://example.com", "snippet": "S"}]} + ) + + response: Final = litellm.search(query="q", search_provider="keenable") + + assert route.calls.last.request.headers["x-keenable-title"] == "LiteLLM" + assert "x-api-key" not in {k.lower(): v for k, v in route.calls.last.request.headers.items()} + assert [result.url for result in response.results] == ["https://example.com"] + 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 00000000000..5865601dd3d Binary files /dev/null and b/ui/litellm-dashboard/public/assets/logos/keenable.png differ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.test.tsx index f860b1a8bd4..c2c930c7549 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.test.tsx @@ -15,6 +15,12 @@ describe("SearchProviderLabel", () => { expect(img).toHaveAttribute("src", expect.stringContaining("exa_ai.png")); }); + it("renders the keenable logo file for the keenable slug", () => { + render(); + const img = screen.getByRole("img", { name: "Keenable logo" }); + expect(img).toHaveAttribute("src", expect.stringContaining("keenable.png")); + }); + it("renders the google_pse logo file for the google_pse slug", () => { render(); expect(screen.getByRole("img", { name: "Google PSE logo" })).toHaveAttribute( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx index e0f440b03c7..1ce4de2a593 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx @@ -33,6 +33,7 @@ import bingLogo from "../../../../../public/assets/logos/bing.png"; import dataforseoLogo from "../../../../../public/assets/logos/dataforseo.png"; import exaAiLogo from "../../../../../public/assets/logos/exa_ai.png"; import googlePseLogo from "../../../../../public/assets/logos/google_pse.png"; +import keenableLogo from "../../../../../public/assets/logos/keenable.png"; import nimbleLogo from "../../../../../public/assets/logos/nimble.png"; import parallelAiLogo from "../../../../../public/assets/logos/parallel_ai.png"; import perplexityLogo from "../../../../../public/assets/logos/perplexity.png"; @@ -44,6 +45,7 @@ const searchProviderLogoMap: Record = { tavily: tavilyLogo.src, parallel_ai: parallelAiLogo.src, exa_ai: exaAiLogo.src, + keenable: keenableLogo.src, google_pse: googlePseLogo.src, dataforseo: dataforseoLogo.src, nimble: nimbleLogo.src,