Skip to content

feat(web-tools): no-key web_search via Copilot /responses, DuckDuckGo fallback#205

Merged
stuffbucket merged 11 commits into
mainfrom
feat/web-search-html-fallback
Jul 3, 2026
Merged

feat(web-tools): no-key web_search via Copilot /responses, DuckDuckGo fallback#205
stuffbucket merged 11 commits into
mainfrom
feat/web-search-html-fallback

Conversation

@stuffbucket

@stuffbucket stuffbucket commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

Makes web_search / web_fetch work 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 /responses endpoint, and only GPT models advertise /responsesno Claude model does. So a Claude client's Anthropic web_search_20250305 sent to /v1/messages is 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 — resolves search via an internal /responses side-call with a GPT model (gpt-5-mini / configured smallModel) + native {type:"web_search"}, harvesting cited url_citation annotations (title+url) and raw web_search_call.action.sources[]. fetch stays key-free via plain HTTPS + Turndown.
  • Selection ladder (chooseExecutor): OLLAMA_API_KEY → Copilot /responses → DuckDuckGo scrape. pickResponsesModel reads the live catalog, so a deprecated model (e.g. gpt-5-mini) drops out and selection falls through (configured → mini-class-by-pattern → any).
  • Prompt robustness: pass the model's query through as-is (the shim exposes only 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.
  • Sanitization hardening (CodeQL): fixpoint tag-strip + single-pass entity decode in the DuckDuckGo helpers.

Billing capture

  • Versioned SQLite migration framework (runMigrations + PRAGMA user_version): transactional per-step, rolls back on failure, supports DDL + data backfill so existing data migrates forward.
  • Migrate token_usage_events with total_nano_aiu (authoritative usage-based cost, returned on all Copilot completion endpoints, previously parsed nowhere) + is_premium (from the model catalog's billing.is_premium). Existing rows backfill to 0 / 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.
  • The brokered web-search side-call records its own usage+cost so it isn't billed invisibly.

Closes #204

Test plan

  • bun test — full suite, 1171 pass / 0 fail
  • Migration framework unit tests (fresh, idempotent re-run w/ data backfill, rollback-on-failure preserves version)
  • Billing: total_nano_aiu captured + summed in summaries; withDateHint date-cue detection; tool_choice: required + query-as-is asserted via payload capture
  • Live end-to-end: selectExecutorCopilotResponsesExecutor on a real account; forced search returned real titled URLs; per-request cost (411100000 nano_aiu) flowed into the usage recorder
  • bun run typecheck + bun run lint — clean

Follow-up (not in this PR)

Client-declared allowed_domains/blocked_domains are enforced on web_fetch but 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:

Diagnostics web search row

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.
Comment thread src/routes/messages/web-tools/executor.ts Fixed
Comment thread src/routes/messages/web-tools/executor.ts Fixed
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
@stuffbucket stuffbucket changed the title feat(web-tools): no-key web_search fallback via DuckDuckGo HTML scrape feat(web-tools): no-key web_search via Copilot /responses, DuckDuckGo fallback Jul 3, 2026
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 `&amp;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
Browser smoke artifact for the Diagnostics web-search row (PR #205).

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
@stuffbucket
stuffbucket merged commit 6cbdb36 into main Jul 3, 2026
4 checks passed
@stuffbucket
stuffbucket deleted the feat/web-search-html-fallback branch July 3, 2026 17:09
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

web_search returns unavailable with no OLLAMA_API_KEY configured

2 participants