This commit is contained in:
Karl-Gustav Kallasmaa 2026-11-06 16:37:52 +09:00 • committed by GitHub
commit bc3a37ba99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 455 additions and 0 deletions

View file

@ -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

View file

@ -0,0 +1,7 @@
"""
Keenable Search API module.
"""
from litellm.llms.keenable.search.transformation import KeenableSearchConfig
__all__ = ["KeenableSearchConfig"]

View file

@ -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"},
}
)

View file

@ -25068,6 +25068,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",

View file

@ -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",

View file

@ -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",

View file

@ -4827,6 +4827,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"

View file

@ -11441,6 +11441,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 (
@ -11460,6 +11461,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,

View file

@ -25068,6 +25068,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",

View file

@ -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",

View file

@ -10,6 +10,7 @@ SEARCH_PROVIDERS = [
"google_pse",
"parallel_ai",
"exa_ai",
"keenable",
"brave",
"firecrawl",
"fastcrw",

View file

@ -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", {}),

View file

@ -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"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -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(<SearchProviderLabel providerName="keenable" displayName="Keenable" />);
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(<SearchProviderLabel providerName="google_pse" displayName="Google PSE" />);
expect(screen.getByRole("img", { name: "Google PSE logo" })).toHaveAttribute(

View file

@ -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<string, string> = {
tavily: tavilyLogo.src,
parallel_ai: parallelAiLogo.src,
exa_ai: exaAiLogo.src,
keenable: keenableLogo.src,
google_pse: googlePseLogo.src,
dataforseo: dataforseoLogo.src,
nimble: nimbleLogo.src,