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
3 changes: 2 additions & 1 deletion coworker/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions coworker/web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .providers import (
BraveProvider,
DuckDuckGoProvider,
ExaProvider,
SearchResult,
TavilyProvider,
WebSearchProvider,
Expand All @@ -20,6 +21,7 @@
"DuckDuckGoProvider",
"TavilyProvider",
"BraveProvider",
"ExaProvider",
"build_provider",
"provider_names",
"make_web_search_tool",
Expand Down
41 changes: 39 additions & 2 deletions coworker/web/providers.py
Original file line number Diff line number Diff line change
@@ -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.
"""

Expand Down Expand Up @@ -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,
}


Expand Down
45 changes: 44 additions & 1 deletion tests/test_web_search.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,6 +18,7 @@
from coworker.web.providers import (
BraveProvider,
DuckDuckGoProvider,
ExaProvider,
TavilyProvider,
WebSearchProvider,
)
Expand Down Expand Up @@ -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):
Expand Down