diff --git a/docs/configuration.md b/docs/configuration.md index 2a95bb1b..1e16b356 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` (optional) | `KEENABLE_API_KEY` | Free tier (no login); key raises rate limits | +| `keenable` | `apiKey` | `KEENABLE_API_KEY` | No | | `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) | | `duckduckgo` (default) | — | — | Yes | @@ -1566,7 +1566,7 @@ 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** (free tier, no login required): +**Keenable:** ```json { "tools": { @@ -1580,7 +1580,7 @@ You can also set `WEB_SEARCH_API_KEY` for compatibility with the Volcengine web- } ``` -Search works without a key on the free tier; `apiKey` is optional and raises rate limits. 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. +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. **SearXNG** (self-hosted, no API key needed): ```json diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 78ec89f4..e97f4a65 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -318,7 +318,8 @@ class WebSearchTool(Tool): ) return "volcengine" if api_key else "duckduckgo" if provider == "keenable": - return "keenable" # free tier works without a key; never fall back + api_key = self.config.api_key or os.environ.get("KEENABLE_API_KEY", "") + return "keenable" if api_key else "duckduckgo" return provider @property @@ -490,13 +491,15 @@ 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, } - if api_key: - headers["X-API-Key"] = api_key try: async with httpx.AsyncClient(proxy=self.proxy) as client: r = await client.post( diff --git a/tests/tools/test_web_search_tool.py b/tests/tools/test_web_search_tool.py index 4e46f90f..3b380a10 100644 --- a/tests/tools/test_web_search_tool.py +++ b/tests/tools/test_web_search_tool.py @@ -131,11 +131,12 @@ async def test_tavily_search(monkeypatch): assert "https://openclaw.io" in result -def test_keenable_without_api_key_stays_on_provider(monkeypatch): +def test_keenable_without_api_key_is_treated_as_duckduckgo(monkeypatch): + # The REST API requires a key; without one we 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 + assert tool.exclusive is True + assert tool.concurrency_safe is False @pytest.mark.asyncio @@ -158,20 +159,20 @@ async def test_keenable_search(monkeypatch): @pytest.mark.asyncio -async def test_keenable_search_without_key_omits_header(monkeypatch): +async def test_keenable_fallback_to_duckduckgo_when_no_key(monkeypatch): + class MockDDGS: + def __init__(self, **kw): + pass + + def text(self, query, max_results=5): + return [{"title": "Fallback", "href": "https://ddg.example", "body": "DuckDuckGo fallback"}] + + monkeypatch.setattr("ddgs.DDGS", MockDDGS) 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 + assert "DuckDuckGo fallback" in result @pytest.mark.asyncio