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) <noreply@anthropic.com>
This commit is contained in:
committed by
Xubin Ren
co-authored by
Claude Opus 4.8
parent
a5768a4ebe
commit
74daa81a1b
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user