Skip to content

Web search and page fetch for the LevelCode agent; drop OpenRouter's unbilled web-search fields - #462

Open
ndemianc wants to merge 2 commits into
developfrom
feat/levelcode-web-search
Open

ndemianc wants to merge 2 commits into
developfrom
feat/levelcode-web-search

Conversation

@ndemianc

@ndemianc ndemianc commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

The gateway side of web search for the editor's agent (levelcode PR: levelcodeai/levelcode#119, design note docs/WEB-SEARCH.md there), and a billing fix found while building it.

1. Billing fix: OpenRouter's web-search fields are dropped (own commit)

chat_params is a transparent proxy (permit!), so a chat request could carry OpenRouter's plugins: [{id: "web"}] or web_search_options and get web search run on LevelCode's OpenRouter account, unbilled: metering prices tokens at catalog rates and never reads OpenRouter's cost. Both fields are now dropped (AiController::UNBILLED_FIELDS). This hole is open in production until this ships.

2. Web search and page fetch

Endpoint What it does
POST /api/levelcode/v1/ai/web/search {queries: 1-4, count?, recency?} Runs the queries at the same time against the configured provider. Billed like a model turn: wallet check (an over-budget throttle plan stops too), reserve every query, settle only the queries that answered, one ledger row (model: "web-search"), and the chat stream's levelcode credits object in the answer. An exception releases the hold.
POST /api/levelcode/v1/ai/web/fetch {url} The browser edition's page reader. Refuses a name if any of its addresses is private, loopback, link-local or otherwise local, connects to the address it checked (Net::HTTP#ipaddr=, so DNS rebinding cannot swap one in), re-checks every redirect, text only, 2 MB cap. Free, behind sign-in, ai:agent and the per-minute cap.

Configuration (needed before search works in production):

  • LEVELCODE_WEB_SEARCH_PROVIDER: default brave.
  • BRAVE_SEARCH_API_KEY or credentials[:brave_search_api_key].
  • LEVELCODE_WEB_SEARCH_COST_MICROS: the price per query in COST micro-$, default 5,000 ($0.005); retail follows the token margin.

With no key, search answers 503 and the editor tells the model search is not available, so this is safe to merge before a key exists.

Tested

  • New specs:
    • spec/requests/api/levelcode/v1/ai_web_spec.rb: billing, partial failures, no key, scope, caps, and every fetch error.
    • spec/services/levelcode/web_search_spec.rb: the parallel run, timeouts, and the Brave request and mapping.
    • spec/services/levelcode/web_fetch_spec.rb: the address checks, redirects, charsets, and a real TCP server for reading and the 2 MB cap.
  • The billing fix has its own example in ai_spec.rb.
  • Targeted run: 81 examples, 0 failures.
  • Mutations: each of 9 one-edit mutations fails a spec.
  • Full suite: 1,652 examples, 4 failures. The same 4 fail on untouched develop in this environment (static_spec ×2, levelcode/web_spec, admin_spec).
  • Lint and security: RuboCop clean on the changed files; Brakeman 0 warnings.

Not run

A real Brave key against the live API; the endpoints from a real browser tab.

…ch it would never bill

chat_params is a transparent proxy (permit!), so a request could carry OpenRouter's `plugins: [{id: "web"}]` or
`web_search_options` and get web search run on LevelCode's OpenRouter account. Metering prices tokens at catalog
rates (Levelcode.cost_micros) and never reads OpenRouter's own cost, so that search was never billed. Both
fields are now dropped (AiController::UNBILLED_FIELDS); the editor's web search goes through AiWebController,
which bills it.

Spec: a chat request with both fields reaches the upstream without them.
The editor's agent gets web_search and web_fetch (levelcode docs/WEB-SEARCH.md). Two endpoints:

- POST /api/levelcode/v1/ai/web/search {queries: 1-4, count?, recency?}. Billed like a model turn: the wallet is
  checked (a throttle plan over budget stops too, since a search costs real money per query), every query's cost
  is reserved, the queries run at the same time (Levelcode::WebSearch.run, 6 s each), only those that answered
  are settled, one ledger row (model "web-search"), and the answer carries the chat stream's `levelcode` credits
  object. An exception releases the hold.
- POST /api/levelcode/v1/ai/web/fetch {url}: the browser edition's page reader (a tab cannot read another site).
  Levelcode::WebFetch refuses a name if ANY of its addresses is private, loopback, link-local or otherwise
  local, connects to the address it checked (Net::HTTP#ipaddr=) so a second DNS answer cannot swap one in,
  re-checks every redirect, reads text only, at most 2 MB. Free, behind sign-in, the ai:agent scope and the
  per-minute cap.

The search provider is configured: LEVELCODE_WEB_SEARCH_PROVIDER (default "brave") with BRAVE_SEARCH_API_KEY or
credentials[:brave_search_api_key]; LEVELCODE_WEB_SEARCH_COST_MICROS is the price per query in COST micro-$
(default 5,000). With no key the search endpoint answers 503 and the editor tells the model search is not
available, so this ships safely before a key is set.

Specs: ai_web_spec.rb (billing, partial failures, no key, scope, caps, the fetch errors), web_search_spec.rb
(parallel run and timeouts, the Brave request and mapping), web_fetch_spec.rb (the address checks, redirects,
charsets, and a real TCP server for reading and the 2 MB cap). 9 one-edit mutations each fail a spec.
Copilot AI balanced review requested due to automatic review settings October 21, 2026 05:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

2 participants