From 74daa81a1ba39170fe0ab78f496c3d55a3378903 Mon Sep 17 00:00:00 2001 From: Ilya Gusev Date: Thu, 18 Jun 2026 16:35:02 +0000 Subject: [PATCH] feat(web): allow Keenable search without an API key Keenable's public endpoint serves the free tier (1000 req/hour) without auth. Route to /v1/search/public with the X-Keenable-Title header when no key is configured, instead of falling back to DuckDuckGo; keep the authenticated /v1/search path when an apiKey or KEENABLE_API_KEY is set. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/configuration.md | 9 ++++----- nanobot/agent/tools/web.py | 15 ++++++++------- tests/tools/test_web_search_tool.py | 28 ++++++++++++++-------------- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index aa43084b..997e6926 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1456,7 +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` | `KEENABLE_API_KEY` | No | +| `keenable` | `apiKey` (optional) | `KEENABLE_API_KEY` | Yes (no key needed; key raises limits) | | `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) | | `duckduckgo` (default) | — | — | Yes | @@ -1566,21 +1566,20 @@ 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:** +**Keenable** (works without an API key on the free tier): ```json { "tools": { "web": { "search": { - "provider": "keenable", - "apiKey": "${KEENABLE_API_KEY}" + "provider": "keenable" } } } } ``` -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. +Keenable search works out of the box with no account, via its token-less public endpoint (free tier, limited to 1,000 requests/hour). Set `apiKey` (or `KEENABLE_API_KEY`) from [keenable.ai](https://keenable.ai) to remove the hourly limit. **SearXNG** (self-hosted, no API key needed): ```json diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index e97f4a65..b0df65b4 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -318,8 +318,7 @@ class WebSearchTool(Tool): ) return "volcengine" if api_key else "duckduckgo" if provider == "keenable": - api_key = self.config.api_key or os.environ.get("KEENABLE_API_KEY", "") - return "keenable" if api_key else "duckduckgo" + return "keenable" return provider @property @@ -491,19 +490,21 @@ class WebSearchTool(Tool): async def _search_keenable(self, query: str, n: int) -> str: api_key = self.config.api_key or os.environ.get("KEENABLE_API_KEY", "") - if not api_key: - logger.warning("KEENABLE_API_KEY not set, falling back to DuckDuckGo") - return await self._search_duckduckgo(query, n) headers = { "Content-Type": "application/json", "User-Agent": self.user_agent, "X-Keenable-Title": "nanobot", - "X-API-Key": api_key, } + # Without a key, the token-less /public endpoint serves the free tier. + url = "https://api.keenable.ai/v1/search" + if api_key: + headers["X-API-Key"] = api_key + else: + url += "/public" try: async with httpx.AsyncClient(proxy=self.proxy) as client: r = await client.post( - "https://api.keenable.ai/v1/search", + url, headers=headers, json={"query": query}, timeout=float(self.config.timeout), diff --git a/tests/tools/test_web_search_tool.py b/tests/tools/test_web_search_tool.py index 3b380a10..95e873d8 100644 --- a/tests/tools/test_web_search_tool.py +++ b/tests/tools/test_web_search_tool.py @@ -131,12 +131,11 @@ async def test_tavily_search(monkeypatch): assert "https://openclaw.io" in result -def test_keenable_without_api_key_is_treated_as_duckduckgo(monkeypatch): - # The REST API requires a key; without one we fall back to DuckDuckGo. +def test_keenable_without_api_key_is_concurrency_safe(monkeypatch): monkeypatch.delenv("KEENABLE_API_KEY", raising=False) tool = _tool(provider="keenable", api_key="") - assert tool.exclusive is True - assert tool.concurrency_safe is False + assert tool.exclusive is False + assert tool.concurrency_safe is True @pytest.mark.asyncio @@ -159,20 +158,21 @@ async def test_keenable_search(monkeypatch): @pytest.mark.asyncio -async def test_keenable_fallback_to_duckduckgo_when_no_key(monkeypatch): - class MockDDGS: - def __init__(self, **kw): - pass +async def test_keenable_without_api_key_uses_public_endpoint(monkeypatch): + async def mock_post(self, url, **kw): + assert url == "https://api.keenable.ai/v1/search/public" + assert "X-API-Key" not in kw["headers"] + assert kw["headers"]["X-Keenable-Title"] == "nanobot" + return _response(json={ + "results": [{"title": "Public", "url": "https://keenable.ai/pub", "description": "ok"}] + }) - def text(self, query, max_results=5): - return [{"title": "Fallback", "href": "https://ddg.example", "body": "DuckDuckGo fallback"}] - - monkeypatch.setattr("ddgs.DDGS", MockDDGS) + monkeypatch.setattr(httpx.AsyncClient, "post", mock_post) monkeypatch.delenv("KEENABLE_API_KEY", raising=False) - tool = _tool(provider="keenable", api_key="") result = await tool.execute(query="keenable", count=1) - assert "DuckDuckGo fallback" in result + assert "Public" in result + assert "https://keenable.ai/pub" in result @pytest.mark.asyncio