diff --git a/openhack/tools/web.py b/openhack/tools/web.py index e8a5577..d37552d 100644 --- a/openhack/tools/web.py +++ b/openhack/tools/web.py @@ -191,6 +191,8 @@ def _provider() -> str: return "exa" if os.environ.get("BRAVE_API_KEY"): return "brave" + if os.environ.get("KEENABLE_API_KEY"): + return "keenable" # Otherwise OpenHack's own gateway, which holds a provider key # server-side — so search works with zero setup, exactly like inference. # (Login is mandatory, so this is the normal path for everyone.) @@ -218,6 +220,8 @@ def web_search(self, query: str, max_results: int = 8) -> dict: results = self._search_exa(query, n) elif provider == "brave": results = self._search_brave(query, n) + elif provider == "keenable": + results = self._search_keenable(query, n) else: provider = "duckduckgo" results = self._search_duckduckgo(query, n) @@ -367,6 +371,29 @@ def _search_brave(self, query: str, n: int) -> list[dict]: for it in web[:n] ] + def _search_keenable(self, query: str, n: int) -> list[dict]: + """Keenable: keyless via the public endpoint, or keyed with KEENABLE_API_KEY. + Select it without a key via OPENHACK_SEARCH_PROVIDER=keenable.""" + key = (os.environ.get("KEENABLE_API_KEY") or "").strip() + headers = {"X-Keenable-Title": "OpenHack"} + if key: + headers["X-API-Key"] = key + r = self._post( + "https://api.keenable.ai/v1/search" if key + else "https://api.keenable.ai/v1/search/public", + {"query": query, "max_results": n}, + headers=headers, + ) + r.raise_for_status() + out = [] + for it in (r.json().get("results") or [])[:n]: + if not it.get("url"): + continue + # Prefer the short description; the snippet can be a long page excerpt. + snippet = it.get("description") or (it.get("snippet") or "")[:1000] + out.append(_result(title=it.get("title"), url=it.get("url"), snippet=snippet)) + return out + def _search_duckduckgo(self, query: str, n: int) -> list[dict]: """Keyless fallback — scrape DuckDuckGo's no-JS endpoints. diff --git a/tests/test_web_tools.py b/tests/test_web_tools.py index bc627b9..21c3ee2 100644 --- a/tests/test_web_tools.py +++ b/tests/test_web_tools.py @@ -45,7 +45,7 @@ def test_extract_text_passes_through_non_html(): # --------------------------------------------------------------- provider pick _KEYS = ("OPENHACK_SEARCH_PROVIDER", "TAVILY_API_KEY", "PERPLEXITY_API_KEY", - "EXA_API_KEY", "BRAVE_API_KEY") + "EXA_API_KEY", "BRAVE_API_KEY", "KEENABLE_API_KEY") def _clear_keys(monkeypatch, openhack=True): @@ -168,6 +168,42 @@ def test_web_search_tavily(monkeypatch): assert "pre-auth RCE" in out["results"][0]["snippet"] +def test_web_search_keenable_keyless_via_override(monkeypatch): + _clear_keys(monkeypatch) + monkeypatch.setenv("OPENHACK_SEARCH_PROVIDER", "keenable") + w = WebTools() + seen = {} + + def fake_post(url, payload, headers=None, **k): + seen.update(url=url, payload=payload, headers=headers) + return _Resp(payload={"results": [ + {"title": "A", "url": "https://a.example", "description": "short", "snippet": "long body"}, + {"title": "no url"}, + ]}) + + monkeypatch.setattr(w, "_post", fake_post) + out = w.web_search("q", max_results=3) + assert out["engine"] == "keenable" and out["count"] == 1 + assert out["results"][0]["snippet"] == "short" + assert seen["url"] == "https://api.keenable.ai/v1/search/public" + assert seen["payload"] == {"query": "q", "max_results": 3} + assert seen["headers"]["X-Keenable-Title"] == "OpenHack" + assert "X-API-Key" not in seen["headers"] + + +def test_keenable_key_selects_keyed_endpoint(monkeypatch): + _clear_keys(monkeypatch, openhack=False) + monkeypatch.setenv("KEENABLE_API_KEY", "kk") + assert WebTools._provider() == "keenable" + w = WebTools() + seen = {} + monkeypatch.setattr(w, "_post", lambda url, payload, headers=None, **k: ( + seen.update(url=url, headers=headers) or _Resp(payload={"results": []}))) + w.web_search("q") + assert seen["url"] == "https://api.keenable.ai/v1/search" + assert seen["headers"]["X-API-Key"] == "kk" + + def test_web_search_duckduckgo_parses_and_unwraps_redirect(monkeypatch): _clear_keys(monkeypatch, openhack=False) markup = (