Merge eab9ab47d49fb83e1a6b29276ef9fe132934bdcb into 9e145d9ceb6268b6957fef6aa1889cb99e9f35be

This commit is contained in:
Kes 2026-08-29 17:04:26 -07:00 committed by GitHub
commit fca62643c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 87 additions and 4 deletions

View File

@ -54,7 +54,8 @@ class Config:
auto_approve_shadow: bool = False
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.

View File

@ -5,6 +5,7 @@ from __future__ import annotations
from .providers import (
BraveProvider,
DuckDuckGoProvider,
ExaProvider,
SearchResult,
TavilyProvider,
WebSearchProvider,
@ -20,6 +21,7 @@ __all__ = [
"DuckDuckGoProvider",
"TavilyProvider",
"BraveProvider",
"ExaProvider",
"build_provider",
"provider_names",
"make_web_search_tool",

View File

@ -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 @@ class BraveProvider(WebSearchProvider):
]
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,
}

View File

@ -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 import (
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):