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) <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 d5f5eb43e5
commit a630a78941
2 changed files with 79 additions and 0 deletions
+48
View File
@@ -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):