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
2 changes: 2 additions & 0 deletions coworker/web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
DuckDuckGoProvider,
SearchResult,
TavilyProvider,
SerperProvider,
WebSearchProvider,
build_provider,
provider_names,
Expand All @@ -20,6 +21,7 @@
"DuckDuckGoProvider",
"TavilyProvider",
"BraveProvider",
"SerperProvider",
"build_provider",
"provider_names",
"make_web_search_tool",
Expand Down
31 changes: 31 additions & 0 deletions coworker/web/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,41 @@ def search(self, query: str, max_results: int = 5) -> list[SearchResult]:
]


class SerperProvider(WebSearchProvider):
name = "serper"
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://google.serper.dev/search",
headers={
"X-API-KEY": self.api_key,
"Content-Type": "application/json",
},
json={"q": query, "num": max_results},
timeout=_TIMEOUT,
)
data = resp.json()
return [
SearchResult(
title=r.get("title", ""),
url=r.get("link", ""),
snippet=r.get("snippet", ""),
)
for r in data.get("organic", [])
]


_PROVIDERS = {
"duckduckgo": DuckDuckGoProvider,
"tavily": TavilyProvider,
"brave": BraveProvider,
"serper": SerperProvider,
}


Expand Down
18 changes: 18 additions & 0 deletions surfaces/gui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions surfaces/gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@tauri-apps/cli": "^2.11.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^26.1.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
Expand Down
23 changes: 16 additions & 7 deletions surfaces/gui/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const PERMISSION_OPTIONS: Option[] = [
// goes stale and silently offers ids the backend never confirmed (caught 2026-07-21).

// Drop the provider prefix for display (anthropic:claude-opus-4-8 → claude-opus-4-8); full id on hover.
const shortModel = (m: string) => (m.includes(":") ? m.split(":").slice(1).join(":") : m);
const shortModel = (m?: string) => (m && m.includes(":") ? m.split(":").slice(1).join(":") : m || "");

// Identify an attachment by name + payload size so duplicates (e.g. the same file picked twice,
// or a prefill applied twice) collapse to one chip.
Expand Down Expand Up @@ -323,10 +323,12 @@ export function Composer(props: Props) {
const modelsLoaded = !!(props.models && props.models.length);
const modelOptions: Option[] = Array.from(
new Set([props.model, ...(props.models || [])]),
).map((m) => ({
value: m,
label: props.modelLabels?.[m] || shortModel(m),
}));
)
.filter(Boolean)
.map((m) => ({
value: m,
label: props.modelLabels?.[m] || shortModel(m),
}));

const iconBtn =
"w-7 h-7 grid place-items-center rounded-md text-muted hover:text-ink hover:bg-paper shrink-0";
Expand Down Expand Up @@ -516,8 +518,15 @@ export function Composer(props: Props) {

{/* send / stop */}
{props.running ? (
<button className="btn danger" onClick={props.onInterrupt}>
⏹ Stop
<button
className="w-7 h-7 rounded-full grid place-items-center shrink-0 bg-red-50 text-red-600 hover:bg-red-100 border border-red-200 transition-colors"
onClick={props.onInterrupt}
title="Stop running"
aria-label="Stop"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="4" y="4" width="16" height="16" rx="2" />
</svg>
</button>
) : (
<button
Expand Down
45 changes: 45 additions & 0 deletions tests/test_web_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
BraveProvider,
DuckDuckGoProvider,
TavilyProvider,
SerperProvider,
WebSearchProvider,
)

Expand Down Expand Up @@ -79,6 +80,9 @@ 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("serper") # no key
assert isinstance(build_provider("serper", "serp-x"), SerperProvider)


def test_tool_surfaces_missing_key_error(tmp_path):
Expand Down Expand Up @@ -152,3 +156,44 @@ def stream(self, **_kw):
from coworker.providers.base import StreamChunk

yield StreamChunk(turn=self.complete())


def test_serper_search_calls_api(monkeypatch):
from unittest.mock import MagicMock
import httpx
from coworker.web.providers import SerperProvider

mock_resp = MagicMock()
mock_resp.json.return_value = {
"organic": [
{"title": "Result 1", "link": "https://r1", "snippet": "Snippet 1"},
{"title": "Result 2", "link": "https://r2", "snippet": "Snippet 2"},
]
}

post_calls = []

def mock_post(url, headers, json, timeout):
post_calls.append((url, headers, json, timeout))
return mock_resp

monkeypatch.setattr(httpx, "post", mock_post)

provider = SerperProvider("test-key")
results = provider.search("python news", max_results=2)

assert len(results) == 2
assert results[0].title == "Result 1"
assert results[0].url == "https://r1"
assert results[0].snippet == "Snippet 1"
assert results[1].title == "Result 2"
assert results[1].url == "https://r2"
assert results[1].snippet == "Snippet 2"

assert len(post_calls) == 1
url, headers, json_body, timeout = post_calls[0]
assert url == "https://google.serper.dev/search"
assert headers["X-API-KEY"] == "test-key"
assert headers["Content-Type"] == "application/json"
assert json_body == {"q": "python news", "num": 2}