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:
committed by
Xubin Ren
co-authored by
Claude Opus 4.8
parent
d5f5eb43e5
commit
a630a78941
@@ -317,6 +317,8 @@ class WebSearchTool(Tool):
|
|||||||
or os.environ.get("WEB_SEARCH_API_KEY", "")
|
or os.environ.get("WEB_SEARCH_API_KEY", "")
|
||||||
)
|
)
|
||||||
return "volcengine" if api_key else "duckduckgo"
|
return "volcengine" if api_key else "duckduckgo"
|
||||||
|
if provider == "keenable":
|
||||||
|
return "keenable" # free tier works without a key; never fall back
|
||||||
return provider
|
return provider
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -371,6 +373,8 @@ class WebSearchTool(Tool):
|
|||||||
n,
|
n,
|
||||||
freshness=kwargs.get("freshness", "noLimit"),
|
freshness=kwargs.get("freshness", "noLimit"),
|
||||||
)
|
)
|
||||||
|
elif provider == "keenable":
|
||||||
|
return await self._search_keenable(query, n)
|
||||||
else:
|
else:
|
||||||
return f"Error: unknown search provider '{provider}'"
|
return f"Error: unknown search provider '{provider}'"
|
||||||
|
|
||||||
@@ -484,6 +488,33 @@ class WebSearchTool(Tool):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error: {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:
|
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()
|
||||||
if not base_url:
|
if not base_url:
|
||||||
|
|||||||
@@ -131,6 +131,54 @@ async def test_tavily_search(monkeypatch):
|
|||||||
assert "https://openclaw.io" in result
|
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
|
@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):
|
||||||
|
|||||||
Reference in New Issue
Block a user