Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions openhack/tools/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down
38 changes: 37 additions & 1 deletion tests/test_web_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 = (
Expand Down