mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
feat(search): add Keenable as a /v1/search provider
Wire search_provider=keenable to Keenable search API (keyed or public), with pricing, UI logo, MCP registry entry, and unit coverage.
This commit is contained in:
parent
1046716b6d
commit
d953cb3775
16 changed files with 455 additions and 0 deletions
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -301,6 +301,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
|
||||
|
|
|
|||
7
litellm/llms/keenable/search/__init__.py
Normal file
7
litellm/llms/keenable/search/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
Keenable Search API module.
|
||||
"""
|
||||
|
||||
from litellm.llms.keenable.search.transformation import KeenableSearchConfig
|
||||
|
||||
__all__ = ["KeenableSearchConfig"]
|
||||
169
litellm/llms/keenable/search/transformation.py
Normal file
169
litellm/llms/keenable/search/transformation.py
Normal 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"},
|
||||
}
|
||||
)
|
||||
|
|
@ -24657,6 +24657,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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -4736,6 +4736,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"
|
||||
|
|
|
|||
|
|
@ -11128,6 +11128,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 (
|
||||
|
|
@ -11147,6 +11148,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,
|
||||
|
|
|
|||
|
|
@ -24657,6 +24657,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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ SEARCH_PROVIDERS = [
|
|||
"google_pse",
|
||||
"parallel_ai",
|
||||
"exa_ai",
|
||||
"keenable",
|
||||
"brave",
|
||||
"firecrawl",
|
||||
"fastcrw",
|
||||
|
|
|
|||
|
|
@ -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", {}),
|
||||
|
|
|
|||
179
tests/unit/llms/keenable/search/test_transformation.py
Normal file
179
tests/unit/llms/keenable/search/test_transformation.py
Normal 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"]
|
||||
|
||||
BIN
ui/litellm-dashboard/public/assets/logos/keenable.png
Normal file
BIN
ui/litellm-dashboard/public/assets/logos/keenable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -31,6 +31,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";
|
||||
|
|
@ -42,6 +43,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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue