diff --git a/coworker/config.py b/coworker/config.py index 336d4af8..6e10d63f 100644 --- a/coworker/config.py +++ b/coworker/config.py @@ -37,7 +37,8 @@ class Config: auto_allow: list[str] = field(default_factory=list) host: str = "127.0.0.1" port: int = 8765 - # Web search provider: "duckduckgo" (keyless default) | "tavily" | "brave" (need a key). + # Web search provider: "duckduckgo" (keyless default) | "tavily" | "brave" | "exa" + # (the last three need a key). web_search_provider: str = "duckduckgo" # OpenWorker Cloud (sign-in + managed connectors). Config, never constants: # dev/staging/BYO-VPC deployments point these at their own instances. diff --git a/coworker/web/__init__.py b/coworker/web/__init__.py index e397eab8..23d21494 100644 --- a/coworker/web/__init__.py +++ b/coworker/web/__init__.py @@ -5,6 +5,7 @@ from .providers import ( BraveProvider, DuckDuckGoProvider, + ExaProvider, SearchResult, TavilyProvider, WebSearchProvider, @@ -20,6 +21,7 @@ "DuckDuckGoProvider", "TavilyProvider", "BraveProvider", + "ExaProvider", "build_provider", "provider_names", "make_web_search_tool", diff --git a/coworker/web/providers.py b/coworker/web/providers.py index 23cad857..a227789e 100644 --- a/coworker/web/providers.py +++ b/coworker/web/providers.py @@ -1,7 +1,7 @@ """Web search providers — a keyless default + pluggable third-party services. -`duckduckgo` works with no API key (our "starting version of our own"). `tavily` and `brave` -give better results but need a key (configured via the SecretStore / env). All providers +`duckduckgo` works with no API key (our "starting version of our own"). `tavily`, `brave` and +`exa` give better results but need a key (configured via the SecretStore / env). All providers return a uniform `list[SearchResult]`; the heavy client libs are lazy-imported. """ @@ -108,10 +108,47 @@ def search(self, query: str, max_results: int = 5) -> list[SearchResult]: ] +class ExaProvider(WebSearchProvider): + name = "exa" + requires_key = True + + def __init__(self, api_key: str) -> None: + self.api_key = api_key + + def search(self, query: str, max_results: int = 5) -> list[SearchResult]: + import httpx + + resp = httpx.post( + "https://api.exa.ai/search", + headers={ + "x-api-key": self.api_key, + "Content-Type": "application/json", + "x-exa-integration": "andrewyng/openworker-integration", + }, + json={ + "query": query, + "type": "auto", + "numResults": max_results, + "contents": {"highlights": True}, + }, + timeout=_TIMEOUT, + ) + data = resp.json() + return [ + SearchResult( + title=r.get("title") or "", + url=r.get("url") or "", + snippet=" ".join(r.get("highlights") or []), + ) + for r in data.get("results", []) + ] + + _PROVIDERS = { "duckduckgo": DuckDuckGoProvider, "tavily": TavilyProvider, "brave": BraveProvider, + "exa": ExaProvider, } diff --git a/tests/test_web_search.py b/tests/test_web_search.py index d1c2d0fd..16d2e45f 100644 --- a/tests/test_web_search.py +++ b/tests/test_web_search.py @@ -1,7 +1,7 @@ """Tests for web search — provider abstraction, the tool, and config resolution. No network: a FakeProvider is injected; third-party key handling and the REST config path -are exercised without hitting DuckDuckGo/Tavily/Brave. +are exercised without hitting DuckDuckGo/Tavily/Brave/Exa. """ from __future__ import annotations @@ -18,6 +18,7 @@ from coworker.web.providers import ( BraveProvider, DuckDuckGoProvider, + ExaProvider, TavilyProvider, WebSearchProvider, ) @@ -79,6 +80,48 @@ def test_build_provider_third_party_requires_key(): build_provider("tavily") # no key assert isinstance(build_provider("tavily", "tvly-x"), TavilyProvider) assert isinstance(build_provider("brave", "brv-x"), BraveProvider) + with pytest.raises(ValueError): + build_provider("exa") # no key + assert isinstance(build_provider("exa", "exa-x"), ExaProvider) + + +def test_exa_provider_parses_highlights(monkeypatch): + import httpx + + captured = {} + + def fake_post(url, headers=None, json=None, timeout=None): + captured.update(url=url, headers=headers, json=json) + return httpx.Response( + 200, + json={ + "results": [ + { + "title": "Exa", + "url": "https://exa.ai", + "highlights": ["neural search", "for AI"], + }, + {"title": None, "url": "https://exa.ai/docs"}, + ] + }, + ) + + monkeypatch.setattr(httpx, "post", fake_post) + results = ExaProvider("exa-x").search("what is exa", max_results=2) + + assert captured["url"] == "https://api.exa.ai/search" + assert captured["headers"]["x-api-key"] == "exa-x" + assert captured["headers"]["x-exa-integration"] == "andrewyng/openworker-integration" + assert captured["json"]["numResults"] == 2 + assert captured["json"]["contents"] == {"highlights": True} + assert [r.to_dict() for r in results] == [ + { + "title": "Exa", + "url": "https://exa.ai", + "snippet": "neural search for AI", + }, + {"title": "", "url": "https://exa.ai/docs", "snippet": ""}, + ] def test_tool_surfaces_missing_key_error(tmp_path):