feat(web-tools): no-key web_search via Copilot /responses, DuckDuckGo fallback#205
Merged
Conversation
InProcessFetchExecutor.search() previously always returned "unavailable" unless OLLAMA_API_KEY was configured, even though web_fetch already works key-free via plain HTTPS. Extend the same no-key philosophy to search by scraping html.duckduckgo.com's server-rendered results page (no JS, no API key) and decoding its uddg-wrapped redirect links back to real URLs. OllamaWebExecutor remains the higher-quality option when a key is set.
Copilot's native web search is a server-side capability exposed only on
the /responses endpoint, and only GPT models advertise /responses — no
Claude model does. So a Claude client's web_search hits maximal
unresolved. Add CopilotResponsesExecutor: it fires an internal
/responses side-call with a GPT model (gpt-5-mini / configured
smallModel) + the native {type:"web_search"} tool, then harvests the
cited url_citation annotations (title+url) and raw
web_search_call.action.sources[] (url) into SearchHit[].
fetch() needs no backend (plain HTTPS + Turndown already works), so it
delegates to InProcessFetchExecutor.
Selection precedence (chooseExecutor): explicit OLLAMA_API_KEY wins,
else Copilot /responses when a capable GPT model + token exist, else
DuckDuckGo HTML scrape. No extra key for the Copilot path — reuses the
entitlement already present. Verified live: gpt-5-mini returns real
titled URL hits for news/fact queries; describeExecutor reflects the
choice in maximal debug.
Refs #204
CodeQL flagged two high-severity findings in the DuckDuckGo scrape helpers: - js/incomplete-multi-character-sanitization: single-pass tag strip could leave tag-like residue on crafted input (e.g. `<scr<script>ipt>`). Loop stripTags to a fixpoint so no `<` survives. - js/double-escaping: sequential entity decoding turned `&lt;` into `<`. Replace with a single-pass regex + lookup so decoding never re-examines its own output. Also harden model selection against gpt-5-mini deprecation: extract the pure pickResponsesModel() — it reads the LIVE catalog, so a retired model drops out and selection falls through (configured small → mini-class by pattern → any /responses model). Never trusts a frozen id. Refs #204
The CopilotResponsesExecutor side-call spends the account's Copilot quota (GPT-model tokens + a web_search request), but previously discarded the result's usage — so a Claude client's web search burned quota invisibly, absent from `maximal debug` and the token-usage view. Wire it through createCopilotTokenUsageRecorder(endpoint: responses) + normalizeResponsesUsage, mirroring the normal /responses handler. Recorder is injectable for tests. Refs #204
…fy web search Billing capture (all Copilot completion endpoints return copilot_usage, previously parsed nowhere): - Add a versioned SQLite migration framework (runMigrations + PRAGMA user_version) to src/lib/sqlite.ts: transactional per-step, rolls back on failure, supports DDL + data backfill so existing rows migrate. - Migrate token_usage_events with total_nano_aiu + is_premium columns (existing rows backfill to 0 / NULL). Thread copilot_usage.total_nano_aiu out of createResponses / chat-completions / messages via extractCopilotCost; resolve is_premium from the model catalog's billing.is_premium at record time. Summaries SUM cost and expose per-model premium status. - Addresses Copilot's 'model multipliers no longer apply' warning by recording the authoritative usage-based cost (nano_aiu) instead. Web-search prompt (reconciled with what Claude actually sends — the shim exposes only , so search() gets the model's authoritative phrase): - Pass the query through as-is instead of wrapping it in steering prose. - Set tool_choice: 'required' so the search always runs (was 'auto', which let the model answer from memory -> 0 sources). - Append today's date to undated queries (withDateHint) so results skew current; queries with a year/month/recency cue pass through unchanged. Verified live: gpt-5-mini forced search returns real titled URLs and the per-request cost (411100000 nano_aiu) flows into the usage recorder. Refs #204
Adding the typed billing field to Model made the 'as unknown as Model' cast in richModel() redundant, which lint:all (full-tree, uncached in CI) flagged as no-unnecessary-type-assertion. The fixture already set billing, so the object is now structurally a valid Model. Refs #204
/simplify follow-up: the { ...normalizeXUsage(usage), total_nano_aiu:
extractCopilotCost(copilot_usage) } shape was copy-pasted at 6 record
sites (all four review agents flagged it). Replace with one exported
withCopilotCost(base, copilot_usage) helper in token-usage; every site
now calls it. Drops the file-local responsesUsageWithCost and the
per-site spreads, so a copilot_usage field rename is a one-line change.
Extracted finishNonStreamingResponses to keep handleWithResponsesApi
under the per-function line cap.
Refs #204
Ran Stryker against src/routes/messages/web-tools/executor.ts. Added cases to kill real (non-equivalent) survivors in the PR's new logic: - DuckDuckGo parse: maxResults cap boundary, URL dedup, empty-title skip, class=result__a filter, missing-href guard, http:// (not just https://) result links, non-URL href rejection. - harvestResponsesHits: maxResults cap, title→url fallback when a citation has no title, wrong-type output items ignored, non-object content/source entries skipped. Remaining survivors in this module are equivalent mutants (defensive guards made redundant by the type checks that immediately follow) and untargeted code covered by other test files. Refs #204
The Settings > Diagnostics page surfaced version, launch, tokens, and
rate limit but NOT which executor resolves web_search/web_fetch — the
one place a user would look to answer 'where is my web search going?'.
(The CLI 'maximal debug' / /_debug/state already emit it; the UI
contract didn't.)
Add web_search { kind, detail } to the DiagnosticsResponse schema,
populate it in buildDiagnostics() via describeExecutor(), and render a
'Web search' row in the existing key-value list (no new card/token).
The UI maps the executor class to plain language, e.g.
'Copilot (no extra key) — gpt-5-mini', 'Ollama hosted', 'Built-in (no key)'.
Updated the diagnostics tests + the UI-harness fixture (validated against
the real Zod schema at harness startup). Verified via headless-Chrome
screenshot of the harness Diagnostics view.
Refs #204
web_fetch enforced the client's declared domain policy but web_search
silently ignored it — a search restricted to docs.python.org would
return results from anywhere. Close that gap, two layers:
- Push the constraint to each backend: Copilot /responses gets an
OpenAI-style { filters: { allowed_domains, blocked_domains } } on the
web_search tool (shape per OpenAI docs + openai-python + Vercel AI SDK;
verified live that Copilot honors it); DuckDuckGo gets site:/-site:
operators.
- Post-filter returned hits by host in exec.ts as the correctness floor,
so the constraint holds even if a backend ignores it.
Domain matching (new isHostAllowed) follows Anthropic's server-tools
spec: subdomains of a listed domain are auto-included (example.com covers
docs.example.com), a listed specific subdomain restricts to it, path
entries match on host, and allowed/blocked are mutually exclusive
(blocked evaluated first defensively).
NOTE (follow-up, intentionally not changed here): web_fetch's existing
hostMatches uses a -glob + exact match, which predates and diverges
from the spec's subdomain-inclusive rule. Left as-is to avoid an
unreviewed behavior change; worth reconciling onto isHostAllowed
separately.
Refs #204
This was referenced Jul 3, 2026
stuffbucket
added a commit
that referenced
this pull request
Jul 3, 2026
…_search matcher (#209) Route checkDomainPolicy (web_fetch) through the shared isHostAllowed matcher instead of the spec-divergent hostMatches (exact-only + `*.` glob). Subdomains of a listed domain are now auto-included, suffix lookalikes rejected, and path/wildcard entries matched on their host portion — matching web_search post-filtering. Delete the now-unused hostMatches. Add checkFetchPolicy coverage. Behavior change: web_fetch clients relying on exact-only or `*.`-glob semantics are affected; this aligns both tools with the published spec. Refs #208, #205.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes
web_search/web_fetchwork for Claude clients (Claude Desktop, Claude Code) through maximal without any extra API key, and captures Copilot's real per-request cost.The core finding
Copilot's native web search is a server-side capability exposed only on the
/responsesendpoint, and only GPT models advertise/responses— no Claude model does. So a Claude client's Anthropicweb_search_20250305sent to/v1/messagesis rejected outright (400 unsupported_value). Undocumented by GitHub but empirically verified and third-party-corroborated (ericc-ch/copilot-api#169, openclaw/openclaw#78573).Web tools
CopilotResponsesExecutor— resolvessearchvia an internal/responsesside-call with a GPT model (gpt-5-mini/ configuredsmallModel) + native{type:"web_search"}, harvesting citedurl_citationannotations (title+url) and rawweb_search_call.action.sources[].fetchstays key-free via plain HTTPS + Turndown.chooseExecutor):OLLAMA_API_KEY→ Copilot/responses→ DuckDuckGo scrape.pickResponsesModelreads the live catalog, so a deprecated model (e.g.gpt-5-mini) drops out and selection falls through (configured → mini-class-by-pattern → any).query, so that string is authoritative — don't wrap it),tool_choice: "required"so search always runs, and append today's date to undated queries (withDateHint) so results skew current.Billing capture
runMigrations+PRAGMA user_version): transactional per-step, rolls back on failure, supports DDL + data backfill so existing data migrates forward.token_usage_eventswithtotal_nano_aiu(authoritative usage-based cost, returned on all Copilot completion endpoints, previously parsed nowhere) +is_premium(from the model catalog'sbilling.is_premium). Existing rows backfill to0/NULL. Summaries SUM cost and expose per-model premium status. This also addresses Copilot's "model multipliers no longer apply" warning by recording the real cost instead of the deprecated multiplier.Closes #204
Test plan
bun test— full suite, 1171 pass / 0 failtotal_nano_aiucaptured + summed in summaries;withDateHintdate-cue detection;tool_choice: required+ query-as-is asserted via payload captureselectExecutor→CopilotResponsesExecutoron a real account; forced search returned real titled URLs; per-request cost (411100000nano_aiu) flowed into the usage recorderbun run typecheck+bun run lint— cleanFollow-up (not in this PR)
Client-declared
allowed_domains/blocked_domainsare enforced onweb_fetchbut don't yet scope the search query — a reconciliation gap to consider separately.Diagnostics page — web-search executor row
The Settings → Diagnostics page now shows which executor resolves web tools (it already surfaced in
maximal debug//_debug/state, but not the UI). Rendered via the UI harness: