From a630a78941de7ad0b918ac22ede6aec9575336a0 Mon Sep 17 00:00:00 2001 From: Ilya Gusev Date: Mon, 15 Jun 2026 11:36:10 +0000 Subject: [PATCH] feat(web): add Keenable search provider Add Keenable (https://keenable.ai) as a web_search backend, modeled on the existing httpx-based providers. Unlike key-gated providers, Keenable has a no-login free tier, so it resolves to itself even without an API key instead of falling back to DuckDuckGo; the X-API-Key header is only sent when a key is configured (config api_key or KEENABLE_API_KEY env). Maps result snippet (falling back to description) into the shared content field. Covered by tests for keyed/anonymous search and the no-fallback concurrency behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- nanobot/agent/tools/web.py | 31 +++++++++++++++++++ tests/tools/test_web_search_tool.py | 48 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 0d2302b5..d51febe9 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -317,6 +317,8 @@ class WebSearchTool(Tool): or os.environ.get("WEB_SEARCH_API_KEY", "") ) return "volcengine" if api_key else "duckduckgo" + if provider == "keenable": + return "keenable" # free tier works without a key; never fall back return provider @property @@ -371,6 +373,8 @@ class WebSearchTool(Tool): n, freshness=kwargs.get("freshness", "noLimit"), ) + elif provider == "keenable": + return await self._search_keenable(query, n) else: return f"Error: unknown search provider '{provider}'" @@ -484,6 +488,33 @@ class WebSearchTool(Tool): except Exception as e: return f"Error: {e}" + async def _search_keenable(self, query: str, n: int) -> str: + # Keenable has a no-login free tier, so an API key is optional. + api_key = self.config.api_key or os.environ.get("KEENABLE_API_KEY", "") + headers = {"Content-Type": "application/json", "User-Agent": self.user_agent} + if api_key: + headers["X-API-Key"] = api_key + try: + async with httpx.AsyncClient(proxy=self.proxy) as client: + r = await client.post( + "https://api.keenable.ai/v1/search", + headers=headers, + json={"query": query}, + timeout=15.0, + ) + r.raise_for_status() + items = [ + { + "title": x.get("title", ""), + "url": x.get("url", ""), + "content": x.get("snippet") or x.get("description", ""), + } + for x in r.json().get("results", []) + ] + return _format_results(query, items, n) + except Exception as e: + return f"Error: {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() if not base_url: diff --git a/tests/tools/test_web_search_tool.py b/tests/tools/test_web_search_tool.py index 1fd81f0c..4f5804cd 100644 --- a/tests/tools/test_web_search_tool.py +++ b/tests/tools/test_web_search_tool.py @@ -131,6 +131,54 @@ async def test_tavily_search(monkeypatch): assert "https://openclaw.io" in result +def test_keenable_without_api_key_stays_on_provider(monkeypatch): + # Free tier needs no key, so Keenable must not fall back to DuckDuckGo. + monkeypatch.delenv("KEENABLE_API_KEY", raising=False) + tool = _tool(provider="keenable", api_key="") + assert tool.exclusive is False + assert tool.concurrency_safe is True + + +@pytest.mark.asyncio +async def test_keenable_search(monkeypatch): + async def mock_post(self, url, **kw): + assert "keenable" in url + assert kw["headers"]["X-API-Key"] == "keen-key" + assert kw["headers"]["User-Agent"] == "nanobot-search-test" + return _response(json={ + "results": [{ + "title": "Keen", + "url": "https://keenable.ai", + "description": "short", + "snippet": "longer excerpt", + }] + }) + + monkeypatch.setattr(httpx.AsyncClient, "post", mock_post) + tool = _tool(provider="keenable", api_key="keen-key", user_agent="nanobot-search-test") + result = await tool.execute(query="keenable", count=1) + assert "Keen" in result + assert "https://keenable.ai" in result + assert "longer excerpt" in result # snippet preferred over description + + +@pytest.mark.asyncio +async def test_keenable_search_without_key_omits_header(monkeypatch): + monkeypatch.delenv("KEENABLE_API_KEY", raising=False) + + async def mock_post(self, url, **kw): + assert "keenable" in url + assert "X-API-Key" not in kw["headers"] + return _response(json={ + "results": [{"title": "Anon", "url": "https://keenable.ai", "description": "ok"}] + }) + + monkeypatch.setattr(httpx.AsyncClient, "post", mock_post) + tool = _tool(provider="keenable", api_key="") + result = await tool.execute(query="keenable", count=1) + assert "Anon" in result # description used when snippet absent + + @pytest.mark.asyncio async def test_bocha_search(monkeypatch): async def mock_post(self, url, **kw):