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:
committed by
Xubin Ren
co-authored by
Claude Opus 4.8
parent
4f6e5e9cb8
commit
fa3e902ee8
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user