diff --git a/docs/configuration.md b/docs/configuration.md index cb8f85d6..2a95bb1b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 | | `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 | +| `keenable` | `apiKey` (optional) | `KEENABLE_API_KEY` | Free tier (no login); key raises rate limits | | `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) | | `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. +**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): ```json { diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index e1e1976b..6d4de138 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -501,7 +501,7 @@ class WebSearchTool(Tool): "https://api.keenable.ai/v1/search", headers=headers, json={"query": query}, - timeout=15.0, + timeout=float(self.config.timeout), ) r.raise_for_status() items = [ @@ -513,8 +513,12 @@ class WebSearchTool(Tool): for x in r.json().get("results", []) ] 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: - return f"Error: {e}" + return f"Error: Keenable search failed: {e}" async def _search_searxng(self, query: str, n: int) -> str: base_url = (self.config.base_url or os.environ.get("SEARXNG_BASE_URL", "")).strip() diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 6cc795c1..5da3a86e 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -90,6 +90,7 @@ _WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = ( {"name": "olostep", "label": "Olostep", "credential": "api_key"}, {"name": "bocha", "label": "Bocha", "credential": "api_key"}, {"name": "volcengine", "label": "Volcengine Search", "credential": "api_key"}, + {"name": "keenable", "label": "Keenable", "credential": "api_key"}, ) _WEB_SEARCH_PROVIDER_BY_NAME = { provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 8fb80fcd..a18ec150 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -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["bocha"]["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 body["image_generation"]["enabled"] is False assert body["image_generation"]["provider"] == "openrouter" diff --git a/tests/tools/test_web_search_tool.py b/tests/tools/test_web_search_tool.py index 9cda6758..6423f077 100644 --- a/tests/tools/test_web_search_tool.py +++ b/tests/tools/test_web_search_tool.py @@ -178,6 +178,32 @@ async def test_keenable_search_without_key_omits_header(monkeypatch): 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 async def test_bocha_search(monkeypatch): async def mock_post(self, url, **kw): diff --git a/webui/src/lib/provider-brand.ts b/webui/src/lib/provider-brand.ts index ebeea08b..aba77842 100644 --- a/webui/src/lib/provider-brand.ts +++ b/webui/src/lib/provider-brand.ts @@ -130,6 +130,7 @@ const PROVIDER_BRANDS: Record = { huggingface: brand("huggingface.co", "#FF9D00", "HF"), jina: brand("jina.ai", "#7C3AED", "J"), kagi: brand("kagi.com", "#FFB319", "K"), + keenable: brand("keenable.ai", "#0EA5E9", "K"), lm_studio: brand("lmstudio.ai", "#111827", "LM"), longcat: brand("longcatai.org", "#4F8CFF", "LC", [ "https://www.longcatai.org/favicon.svg", diff --git a/webui/src/tests/provider-brand.test.ts b/webui/src/tests/provider-brand.test.ts index bbbffa35..2b06cf80 100644 --- a/webui/src/tests/provider-brand.test.ts +++ b/webui/src/tests/provider-brand.test.ts @@ -57,4 +57,9 @@ describe("provider brand logos", () => { expect(providerBrand("bocha")?.logoUrls).toContain("https://bochaai.com/favicon.ico"); 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"); + }); });