feat(web): register Keenable provider in WebUI, docs, and tests

Bring the Keenable search provider in line with the established
multi-file provider pattern so it surfaces everywhere the others do:

- settings_api.py: register in the web-search provider options so it
  appears in the WebUI settings dropdown (credential: api_key, optional).
- WebUI provider-brand: add keenable brand entry + brand test.
- docs/configuration.md: provider table row + config example.
- Harden _search_keenable: honor config.timeout and return explicit
  messages on HTTP status errors (429 / other), matching peer providers.
- Add env-key and HTTP-error tests; add the websocket settings whitelist
  assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilya Gusev
2026-06-18 00:08:52 +08:00
committed by Xubin Ren
co-authored by Claude Opus 4.8
parent 4f6e5e9cb8
commit fa3e902ee8
7 changed files with 57 additions and 2 deletions
+17
View File
@@ -1456,6 +1456,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
| `olostep` | `apiKey` | `OLOSTEP_API_KEY` | No | | `olostep` | `apiKey` | `OLOSTEP_API_KEY` | No |
| `bocha` | `apiKey` | `BOCHA_API_KEY` | Free tier (1M calls for startups) | | `bocha` | `apiKey` | `BOCHA_API_KEY` | Free tier (1M calls for startups) |
| `volcengine` | `apiKey` | `VOLCENGINE_SEARCH_API_KEY` or `WEB_SEARCH_API_KEY` | Monthly quota, then paid | | `volcengine` | `apiKey` | `VOLCENGINE_SEARCH_API_KEY` or `WEB_SEARCH_API_KEY` | Monthly quota, then paid |
| `keenable` | `apiKey` (optional) | `KEENABLE_API_KEY` | Free tier (no login); key raises rate limits |
| `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) | | `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) |
| `duckduckgo` (default) | — | — | Yes | | `duckduckgo` (default) | — | — | Yes |
@@ -1565,6 +1566,22 @@ You can set `BOCHA_API_KEY` in the environment instead of storing it in config.
You can also set `WEB_SEARCH_API_KEY` for compatibility with the Volcengine web-search skill. Create the key in the [Volcengine web search console](https://console.volcengine.com/search-infinity/web-search), then copy it from [API keys](https://console.volcengine.com/search-infinity/api-key). Volcengine Ark keys are separate and do not work for this search provider. You can also set `WEB_SEARCH_API_KEY` for compatibility with the Volcengine web-search skill. Create the key in the [Volcengine web search console](https://console.volcengine.com/search-infinity/web-search), then copy it from [API keys](https://console.volcengine.com/search-infinity/api-key). Volcengine Ark keys are separate and do not work for this search provider.
**Keenable** (free tier, no login required):
```json
{
"tools": {
"web": {
"search": {
"provider": "keenable",
"apiKey": "${KEENABLE_API_KEY}"
}
}
}
}
```
Search works without a key on the free tier; `apiKey` is optional and raises rate limits. Create a key at [keenable.ai](https://keenable.ai). You can also set `KEENABLE_API_KEY` in the environment instead of storing it in config.
**SearXNG** (self-hosted, no API key needed): **SearXNG** (self-hosted, no API key needed):
```json ```json
{ {
+6 -2
View File
@@ -501,7 +501,7 @@ class WebSearchTool(Tool):
"https://api.keenable.ai/v1/search", "https://api.keenable.ai/v1/search",
headers=headers, headers=headers,
json={"query": query}, json={"query": query},
timeout=15.0, timeout=float(self.config.timeout),
) )
r.raise_for_status() r.raise_for_status()
items = [ items = [
@@ -513,8 +513,12 @@ class WebSearchTool(Tool):
for x in r.json().get("results", []) for x in r.json().get("results", [])
] ]
return _format_results(query, items, n) return _format_results(query, items, n)
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
return "Error: Keenable search rate limited. Try again later or reduce search frequency."
return f"Error: Keenable search failed ({e.response.status_code}): {e}"
except Exception as e: except Exception as e:
return f"Error: {e}" return f"Error: Keenable search failed: {e}"
async def _search_searxng(self, query: str, n: int) -> str: async def _search_searxng(self, query: str, n: int) -> str:
base_url = (self.config.base_url or os.environ.get("SEARXNG_BASE_URL", "")).strip() base_url = (self.config.base_url or os.environ.get("SEARXNG_BASE_URL", "")).strip()
+1
View File
@@ -90,6 +90,7 @@ _WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
{"name": "olostep", "label": "Olostep", "credential": "api_key"}, {"name": "olostep", "label": "Olostep", "credential": "api_key"},
{"name": "bocha", "label": "Bocha", "credential": "api_key"}, {"name": "bocha", "label": "Bocha", "credential": "api_key"},
{"name": "volcengine", "label": "Volcengine Search", "credential": "api_key"}, {"name": "volcengine", "label": "Volcengine Search", "credential": "api_key"},
{"name": "keenable", "label": "Keenable", "credential": "api_key"},
) )
_WEB_SEARCH_PROVIDER_BY_NAME = { _WEB_SEARCH_PROVIDER_BY_NAME = {
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
+1
View File
@@ -1767,6 +1767,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert search_providers["exa"]["credential"] == "api_key" assert search_providers["exa"]["credential"] == "api_key"
assert search_providers["bocha"]["credential"] == "api_key" assert search_providers["bocha"]["credential"] == "api_key"
assert search_providers["volcengine"]["credential"] == "api_key" assert search_providers["volcengine"]["credential"] == "api_key"
assert search_providers["keenable"]["credential"] == "api_key"
assert search_providers["searxng"]["credential"] == "base_url" assert search_providers["searxng"]["credential"] == "base_url"
assert body["image_generation"]["enabled"] is False assert body["image_generation"]["enabled"] is False
assert body["image_generation"]["provider"] == "openrouter" assert body["image_generation"]["provider"] == "openrouter"
+26
View File
@@ -178,6 +178,32 @@ async def test_keenable_search_without_key_omits_header(monkeypatch):
assert "Anon" in result assert "Anon" in result
@pytest.mark.asyncio
async def test_keenable_search_uses_env_api_key(monkeypatch):
async def mock_post(self, url, **kw):
assert kw["headers"]["X-API-Key"] == "env-keen-key"
return _response(json={
"results": [{"title": "Env", "url": "https://keenable.ai/env", "description": "ok"}]
})
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
monkeypatch.setenv("KEENABLE_API_KEY", "env-keen-key")
tool = _tool(provider="keenable", api_key="")
result = await tool.execute(query="keenable", count=1)
assert "Env" in result
@pytest.mark.asyncio
async def test_keenable_search_http_error(monkeypatch):
async def mock_post(self, url, **kw):
return _response(status=401, json={"error": "invalid key"})
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
tool = _tool(provider="keenable", api_key="bad-keen-key")
result = await tool.execute(query="keenable")
assert "Error: Keenable search failed (401)" in result
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bocha_search(monkeypatch): async def test_bocha_search(monkeypatch):
async def mock_post(self, url, **kw): async def mock_post(self, url, **kw):
+1
View File
@@ -130,6 +130,7 @@ const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
huggingface: brand("huggingface.co", "#FF9D00", "HF"), huggingface: brand("huggingface.co", "#FF9D00", "HF"),
jina: brand("jina.ai", "#7C3AED", "J"), jina: brand("jina.ai", "#7C3AED", "J"),
kagi: brand("kagi.com", "#FFB319", "K"), kagi: brand("kagi.com", "#FFB319", "K"),
keenable: brand("keenable.ai", "#0EA5E9", "K"),
lm_studio: brand("lmstudio.ai", "#111827", "LM"), lm_studio: brand("lmstudio.ai", "#111827", "LM"),
longcat: brand("longcatai.org", "#4F8CFF", "LC", [ longcat: brand("longcatai.org", "#4F8CFF", "LC", [
"https://www.longcatai.org/favicon.svg", "https://www.longcatai.org/favicon.svg",
+5
View File
@@ -57,4 +57,9 @@ describe("provider brand logos", () => {
expect(providerBrand("bocha")?.logoUrls).toContain("https://bochaai.com/favicon.ico"); expect(providerBrand("bocha")?.logoUrls).toContain("https://bochaai.com/favicon.ico");
expect(providerBrand("bocha")?.initials).toBe("B"); expect(providerBrand("bocha")?.initials).toBe("B");
}); });
it("keeps Keenable web search settings on the first-party brand domain", () => {
expect(providerBrand("keenable")?.logoUrls).toContain("https://keenable.ai/favicon.ico");
expect(providerBrand("keenable")?.initials).toBe("K");
});
}); });