diff --git a/app/controllers/api/levelcode/v1/ai_controller.rb b/app/controllers/api/levelcode/v1/ai_controller.rb index cc273a97..78b83f63 100644 --- a/app/controllers/api/levelcode/v1/ai_controller.rb +++ b/app/controllers/api/levelcode/v1/ai_controller.rb @@ -534,9 +534,15 @@ def current_request_id # payload because this is a transparent proxy — we must not drop fields # the editor/upstream rely on. def chat_params - params.permit!.to_h.except("controller", "action", "ai", "format") + params.permit!.to_h.except("controller", "action", "ai", "format", *UNBILLED_FIELDS) end + # Fields that would make OpenRouter do paid work the gateway never bills: its web-search plugin and the + # search options of a hosted search model. Metering prices tokens at catalog rates (Levelcode.cost_micros) and + # never reads OpenRouter's own cost, so these would run on LevelCode's account for free. The editor's web + # search goes through AiWebController, which bills it. + UNBILLED_FIELDS = %w[plugins web_search_options].freeze + # Per-request output ceiling (M14). Enforcement is per-request-start, so without a per-request # bound a burst of concurrent streams could overshoot the dollar budget before their spend # lands. Clamps an over-large client value AND sets a default when omitted. The FREE ceiling diff --git a/app/controllers/api/levelcode/v1/ai_web_controller.rb b/app/controllers/api/levelcode/v1/ai_web_controller.rb new file mode 100644 index 00000000..ade10d01 --- /dev/null +++ b/app/controllers/api/levelcode/v1/ai_web_controller.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module Api + module Levelcode + module V1 + # Web access for the editor's agent (levelcode docs/WEB-SEARCH.md): + # + # POST /api/levelcode/v1/ai/web/search { queries: [..1-4], count?, recency? } → results per query + credits + # POST /api/levelcode/v1/ai/web/fetch { url } → the page's text + # + # Search is billed like a model turn: the wallet is checked, the worst case (every query) reserved, the queries + # that answered settled and written to the ledger as one row (model "web-search"), and the editor gets the same + # `levelcode` credits object the chat stream ends with. Fetch is free, behind the same sign-in, agent scope and + # per-minute cap as every gateway call; it exists because a browser tab cannot read another site itself. + class AiWebController < BaseController + before_action :require_agent_scope! + + def search + queries = ::Levelcode::WebSearch.clean_queries(params[:queries]) + if queries.empty? + return render_levelcode_error("invalid_request", "queries must hold 1-#{::Levelcode::WebSearch::MAX_QUERIES} search queries", :unprocessable_entity) + end + + provider = ::Levelcode::WebSearch.provider + return render_levelcode_error("web_search_unavailable", "Web search is not set up on LevelCode Cloud yet.", :service_unavailable) unless provider + + wallet = ::Levelcode::FreeTier.wallet_for(current_levelcode_user) + decision = ::Levelcode::Metering.check!(wallet) + # Over budget on a throttle plan keeps the cheap model running; a search costs real money per query, so it stops. + return render_no_credits if decision.blocked? || decision.throttle + + per_query = ::Levelcode::WebSearch.cost_micros_per_query + res = ::Levelcode::Metering.reserve!(wallet, queries.size * per_query) + return render_no_credits unless res.ok + + reservation = res.amount + settled = false + begin + count = params[:count].present? ? params[:count].to_i.clamp(1, ::Levelcode::WebSearch::MAX_COUNT) : ::Levelcode::WebSearch::DEFAULT_COUNT + recency = ::Levelcode::WebSearch::RECENCY.include?(params[:recency].to_s) ? params[:recency].to_s : nil + results = ::Levelcode::WebSearch.run(queries, provider: provider, count: count, recency: recency) + cost = results.count { |r| r[:error].nil? } * per_query + + ::Levelcode::Metering.settle!(wallet, reservation, 0, 0, cost) + settled = true + if cost.positive? + RecordUsageJob.perform_async(current_levelcode_user.id, SecureRandom.uuid, "web-search", provider.name, + 0, 0, 0, cost, wallet.period_end.to_i, Time.current.to_i) + end + render json: { results: results, levelcode: credits(wallet, cost) } + ensure + ::Levelcode::Metering.settle!(wallet, reservation, 0, 0, 0) unless settled # an exception releases the hold + end + end + + def fetch + url = params[:url].to_s + unless url.length <= 2048 && url.match?(%r{\Ahttps?://}i) + return render_levelcode_error("invalid_request", "url must be an http or https address", :unprocessable_entity) + end + + page = ::Levelcode::WebFetch.get(url) + render json: page + rescue ::Levelcode::WebFetch::Blocked => e + render_levelcode_error("blocked", e.message, :unprocessable_entity) + rescue ::Levelcode::WebFetch::NotText => e + render_levelcode_error("not_text", e.message, :unsupported_media_type) + rescue Net::OpenTimeout, Net::ReadTimeout + render_levelcode_error("timeout", "The page did not answer in time.", :gateway_timeout) + rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, EOFError, IOError, URI::InvalidURIError => e + render_levelcode_error("unreachable", "The page could not be reached (#{e.class.name.demodulize}).", :bad_gateway) + end + + private + + # Web access is an agent capability: the same scope the gateway asks of a tool-calling turn. + def require_agent_scope! + scopes = (levelcode_token_claims && levelcode_token_claims["scope"].to_s.split(/\s+/)) || [] + return if scopes.include?("ai:agent") + + render_levelcode_error("insufficient_scope", "This token cannot use web access (missing ai:agent).", :forbidden) + end + + def render_no_credits + render_levelcode_error("cap_reached", "No credits left for web search in this period.", :payment_required) + end + + # The same retail basis as the chat stream's credits frame (AiController#credits_frame). + def credits(wallet, cost_micros) + state = ::Levelcode::Metering.tranche_state(wallet) + { + cost_micros: ::Levelcode.retail_micros(cost_micros.to_i, wallet.plan_key), + credits_remaining_micros: ::Levelcode.retail_micros(state[:remaining_micros].to_i, wallet.plan_key) + } + rescue => e + Rails.logger.warn("[Api::Levelcode::V1::AiWebController] credits skipped: #{e.class}") + nil + end + end + end + end +end diff --git a/app/services/levelcode/web_fetch.rb b/app/services/levelcode/web_fetch.rb new file mode 100644 index 00000000..1ef2a099 --- /dev/null +++ b/app/services/levelcode/web_fetch.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "net/http" +require "ipaddr" +require "resolv" + +module Levelcode + # Reads a web page for the editor's agent (its `web_fetch` tool, from LevelCode in the browser, where a tab cannot + # read another site itself; levelcode docs/WEB-SEARCH.md). + # + # This server must never become a way into its own network: every address a name resolves to is checked, a name + # with any private, loopback, link-local or otherwise local address is refused, and the connection goes to the + # address that was checked (Net::HTTP#ipaddr=), so a second DNS answer cannot swap in another. Every redirect is + # checked the same way. Only text comes back, at most 2 MB of it. + module WebFetch + module_function + + class Blocked < StandardError; end + class NotText < StandardError; end + + MAX_BYTES = 2 * 1024 * 1024 + MAX_REDIRECTS = 4 + OPEN_TIMEOUT = 4 + READ_TIMEOUT = 8 + USER_AGENT = "Mozilla/5.0 (compatible; LevelCode/1.4; +https://levelcode.ai)" + TEXTUAL = %r{\A(text/|application/(xhtml\+xml|xml|json|ld\+json|rss\+xml|atom\+xml|javascript))}i + + BLOCKED = %w[ + 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 + 192.88.99.0/24 192.168.0.0/16 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4 + ::/128 ::1/128 fc00::/7 fe80::/10 ff00::/8 64:ff9b::/96 2001:db8::/32 + ].map { |c| IPAddr.new(c) }.freeze + + # True for anything that is not the public internet (and for anything that does not parse as an address). + def private_address?(ip) + addr = IPAddr.new(ip.to_s.delete_prefix("[").delete_suffix("]").sub(/%.*\z/, "")) + addr = addr.native if addr.ipv6? && addr.ipv4_mapped? + BLOCKED.any? { |r| r.family == addr.family && r.include?(addr) } + rescue IPAddr::Error, ArgumentError + true + end + + def resolve(host) + return [ host ] if host.match?(Resolv::IPv4::Regex) || host.match?(Resolv::IPv6::Regex) + + Resolv.getaddresses(host).uniq + end + + # { final_url:, status:, content_type:, body:, truncated: }. Raises Blocked, NotText, or a network error. + # `resolver` and `connect` are the seams the specs replace. + def get(url, resolver: method(:resolve), connect: method(:request)) + current = url.to_s + (MAX_REDIRECTS + 1).times do + uri = URI.parse(current) + raise Blocked, "only http and https pages can be opened" unless %w[http https].include?(uri.scheme) && uri.host.present? + raise Blocked, "addresses with a user name or password are not opened" if uri.userinfo.present? + + host = uri.host.delete_prefix("[").delete_suffix("]") + addresses = resolver.call(host) + raise Blocked, "#{host} does not resolve" if addresses.empty? + raise Blocked, "#{host} is on a private or local network; only public pages are opened" if addresses.any? { |a| private_address?(a) } + + status, headers, body, truncated = connect.call(uri, addresses.first) + if (300..399).cover?(status) && headers["location"].present? + current = URI.join(current, headers["location"]).to_s + next + end + type = headers["content-type"].to_s + raise NotText, "#{type.split(';').first.presence || 'unknown type'} is not a text page" if type.present? && !type.match?(TEXTUAL) + + return { final_url: current, status: status, content_type: type, body: utf8(body, type), truncated: truncated } + end + raise Blocked, "too many redirects" + end + + class TooLarge < StandardError; end + + # One GET to `ip`, with the name kept for the Host header and TLS. Answers [status, headers, body, truncated]. + def request(uri, ip) + http = Net::HTTP.new(uri.host, uri.port) + http.ipaddr = ip + http.use_ssl = uri.scheme == "https" + http.open_timeout = OPEN_TIMEOUT + http.read_timeout = READ_TIMEOUT + req = Net::HTTP::Get.new(uri.request_uri) + req["User-Agent"] = USER_AGENT + req["Accept"] = "text/html,application/xhtml+xml,text/plain;q=0.9,application/json;q=0.8,*/*;q=0.5" + status = 0 + headers = {} + body = +"" + truncated = false + begin + http.start do + http.request(req) do |res| + status = res.code.to_i + headers = res.to_hash.transform_values { |v| Array(v).join(", ") } + next if (300..399).cover?(status) + + res.read_body do |chunk| + body << chunk + raise TooLarge if body.bytesize > MAX_BYTES + end + end + end + rescue TooLarge + body = body.byteslice(0, MAX_BYTES) + truncated = true + end + [ status, headers, body, truncated ] + end + + def utf8(body, content_type) + charset = content_type[/charset\s*=\s*"?([\w.:-]+)/i, 1] || "UTF-8" + enc = Encoding.find(charset) rescue Encoding::UTF_8 + body.to_s.dup.force_encoding(enc).encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�") + end + end +end diff --git a/app/services/levelcode/web_search.rb b/app/services/levelcode/web_search.rb new file mode 100644 index 00000000..940c4bd2 --- /dev/null +++ b/app/services/levelcode/web_search.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "net/http" + +module Levelcode + # Web search for the editor's agent (its `web_search` tool; levelcode docs/WEB-SEARCH.md). One request carries + # 1-4 queries; they run at the same time against the configured search API and are billed per query that + # answered, like a model turn: reserved up front, settled after, one ledger row (Api::Levelcode::V1::AiWebController). + # + # The provider is configured, not hard-wired: LEVELCODE_WEB_SEARCH_PROVIDER (default "brave") and its key in + # BRAVE_SEARCH_API_KEY or credentials[:brave_search_api_key]. No key → `provider` is nil → the endpoint answers 503 + # and the editor tells the model web search is not available, so nothing breaks before the owner sets one. + module WebSearch + module_function + + MAX_QUERIES = 4 + MAX_QUERY_CHARS = 400 + DEFAULT_COUNT = 6 + MAX_COUNT = 10 + RECENCY = %w[day week month year].freeze + QUERY_TIMEOUT = 6 # seconds; a query that has not answered by then is reported failed, not waited on + + # What one query costs US, in internal COST micro-$ (the wallet's basis). Brave's Data for AI plan lists $5 per + # thousand queries; set LEVELCODE_WEB_SEARCH_COST_MICROS to the provider's real price. Retail is derived from it + # by the same margin as tokens (Levelcode.retail_micros). + def cost_micros_per_query + Integer(ENV.fetch("LEVELCODE_WEB_SEARCH_COST_MICROS", "5000")) + end + + def provider + name = ENV.fetch("LEVELCODE_WEB_SEARCH_PROVIDER", "brave").to_s + case name + when "brave" + key = ENV["BRAVE_SEARCH_API_KEY"].presence || Rails.application.credentials.dig(:brave_search_api_key).presence + key && BraveProvider.new(key) + end + end + + # The editor's queries, cleaned: trimmed, de-duplicated (any case), at most four of at most 400 characters. + def clean_queries(raw) + Array(raw).map { |q| q.to_s.squish.first(MAX_QUERY_CHARS) }.reject(&:blank?).uniq(&:downcase).first(MAX_QUERIES) + end + + # Every query at once. Answers one entry per query, in order: { query:, results: [...] } or { query:, results: [], error: }. + def run(queries, provider:, count:, recency:) + threads = queries.map do |q| + Thread.new do + { query: q, results: provider.search(q, count: count, recency: recency) } + rescue => e + { query: q, results: [], error: e.message.to_s.first(200) } + end + end + threads.each_with_index.map do |t, i| + next t.value if t.join(QUERY_TIMEOUT + 1) + + t.kill + { query: queries[i], results: [], error: "timed out after #{QUERY_TIMEOUT} s" } + end + end + + # Brave Search's web endpoint. `http` is the seam the specs replace: (uri, headers) -> [status, body]. + class BraveProvider + ENDPOINT = "https://api.search.brave.com/res/v1/web/search" + FRESHNESS = { "day" => "pd", "week" => "pw", "month" => "pm", "year" => "py" }.freeze + + attr_reader :name + + def initialize(key, http: nil) + @key = key + @http = http || method(:get) + @name = "brave" + end + + def search(query, count:, recency:) + params = { q: query, count: count, extra_snippets: true, text_decorations: false } + params[:freshness] = FRESHNESS[recency] if FRESHNESS[recency] + uri = URI(ENDPOINT) + uri.query = URI.encode_www_form(params) + status, body = @http.call(uri, { "Accept" => "application/json", "X-Subscription-Token" => @key }) + raise "Brave Search answered HTTP #{status}" unless status == 200 + + Array(JSON.parse(body).dig("web", "results")).map do |r| + { + title: r["title"].to_s, + url: r["url"].to_s, + snippet: [ r["description"], *Array(r["extra_snippets"]).first(2) ].compact.join(" … "), + date: r["page_age"].to_s + } + end + end + + private + + def get(uri, headers) + Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 3, read_timeout: QUERY_TIMEOUT) do |h| + req = Net::HTTP::Get.new(uri) + headers.each { |k, v| req[k] = v } + res = h.request(req) + [ res.code.to_i, res.body.to_s ] + end + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 36ef052e..aa5829b9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -174,6 +174,9 @@ # HTML catch-all. `ai/chat` kept as an alias for the SPEC/tests. post "ai/chat/completions", to: "ai#chat" post "ai/chat", to: "ai#chat" + # Web access for the agent's web_search / web_fetch tools (levelcode docs/WEB-SEARCH.md). + post "ai/web/search", to: "ai_web#search" + post "ai/web/fetch", to: "ai_web#fetch" end end end diff --git a/spec/requests/api/levelcode/v1/ai_spec.rb b/spec/requests/api/levelcode/v1/ai_spec.rb index 99969d42..85b4d50d 100644 --- a/spec/requests/api/levelcode/v1/ai_spec.rb +++ b/spec/requests/api/levelcode/v1/ai_spec.rb @@ -612,6 +612,25 @@ def with_scope(scope) expect(captured["max_tokens"]).to eq(Levelcode::FREE_MAX_TOKENS) end + it "drops OpenRouter's web-search plugin and search options — paid work metering would never bill" do + allow(Levelcode::Metering).to receive(:check!) + .and_return(Levelcode::Metering::Decision.new(allowed: true, throttle: false, policy: "stop")) + captured = nil + allow_any_instance_of(Levelcode::OpenRouterAdapter).to receive(:stream) do |_a, routed, on_chunk:| + captured = routed + canned_chunks.each { |c| on_chunk.call(c) } + Levelcode::OpenRouterAdapter::Result.new(usage: final_usage, model: "moonshotai/kimi-k2.6") + end + + post "/api/levelcode/v1/ai/chat", + params: { model: "x", stream: true, messages: [ { role: "user", content: "who is it?" } ], + plugins: [ { id: "web", max_results: 10 } ], web_search_options: { search_context_size: "high" } }, as: :json + + expect(response).to have_http_status(:ok) + expect(captured.keys).not_to include("plugins", "web_search_options") + expect(captured["messages"]).to be_present + end + it "402s cap_reached with an upgrade_url when the free monthly cap is hit" do allow(Levelcode::Metering).to receive(:check!) .and_return(Levelcode::Metering::Decision.new(allowed: false, throttle: false, policy: "stop")) diff --git a/spec/requests/api/levelcode/v1/ai_web_spec.rb b/spec/requests/api/levelcode/v1/ai_web_spec.rb new file mode 100644 index 00000000..15b44d54 --- /dev/null +++ b/spec/requests/api/levelcode/v1/ai_web_spec.rb @@ -0,0 +1,180 @@ +require "rails_helper" + +# Web access for the editor's agent (levelcode docs/WEB-SEARCH.md): POST /ai/web/search and /ai/web/fetch. +# The search provider and the page reader are replaced at their seams (WebSearch.provider, WebFetch.get), and +# metering is stubbed at its interface as in ai_spec.rb, so these run without Redis or the network. +RSpec.describe "Api::Levelcode::V1::AiWeb", type: :request do + let(:user) { create(:user) } + let(:wallet) do + instance_double("CreditWallet", user_id: user.id, plan_key: "orbits_pro", period_end: Time.current.end_of_month, overage_policy: "throttle") + end + let(:scope) { "ai:chat ai:agent account:read" } + + # A provider that answers from a table: a String result is an error for that query. + let(:table) { {} } + let(:provider) do + t = table + Class.new do + define_method(:name) { "brave" } + define_method(:search) do |q, count:, recency:| + r = t.fetch(q, []) + raise r if r.is_a?(String) + + r + end + end.new + end + + before do + allow_any_instance_of(Api::Levelcode::V1::AiWebController).to receive(:authenticate_levelcode!).and_return(true) + allow_any_instance_of(Api::Levelcode::V1::AiWebController).to receive(:current_levelcode_user).and_return(user) + allow_any_instance_of(Api::Levelcode::V1::AiWebController).to receive(:levelcode_token_claims) { { "scope" => scope } } + allow(Levelcode::FreeTier).to receive(:wallet_for).with(user).and_return(wallet) + allow(Levelcode::Metering).to receive(:check!).and_return(Levelcode::Metering::Decision.new(allowed: true, throttle: false, policy: "stop")) + allow(Levelcode::Metering).to receive(:reserve!) { |_w, amount| Levelcode::Metering::Reservation.new(ok: true, amount: amount) } + allow(Levelcode::Metering).to receive(:settle!).and_return(true) + allow(Levelcode::Metering).to receive(:tranche_state).and_return({ remaining_micros: 7_000_000 }) + allow(Levelcode::WebSearch).to receive(:provider).and_return(provider) + allow(Levelcode::WebSearch).to receive(:cost_micros_per_query).and_return(5_000) + allow(RecordUsageJob).to receive(:perform_async) + end + + def search(body) + post "/api/levelcode/v1/ai/web/search", params: body, as: :json + end + + describe "POST /ai/web/search" do + let(:table) do + { "csaf" => [ { title: "CSAF 2.1", url: "https://docs.oasis-open.org/csaf/", snippet: "spec", date: "" } ], + "openeox" => [ { title: "OpenEoX", url: "https://openeox.org/", snippet: "eol", date: "2026-03-01" } ], + "broken" => "Brave Search answered HTTP 429" } + end + + it "answers every query, in order, with the results and the credits object the chat stream ends with" do + search(queries: [ "csaf", "openeox" ], count: 3) + + expect(response).to have_http_status(:ok) + json = response.parsed_body + expect(json["results"].map { |r| r["query"] }).to eq(%w[csaf openeox]) + expect(json["results"][0]["results"][0]).to include("title" => "CSAF 2.1", "url" => "https://docs.oasis-open.org/csaf/") + expect(json["levelcode"]).to eq( + "cost_micros" => Levelcode.retail_micros(10_000, "orbits_pro"), + "credits_remaining_micros" => Levelcode.retail_micros(7_000_000, "orbits_pro") + ) + end + + it "reserves the worst case, then settles and records only the queries that answered — one ledger row" do + search(queries: [ "csaf", "broken" ]) + + expect(Levelcode::Metering).to have_received(:reserve!).with(wallet, 10_000) + expect(Levelcode::Metering).to have_received(:settle!).with(wallet, 10_000, 0, 0, 5_000).once + expect(RecordUsageJob).to have_received(:perform_async) + .with(user.id, kind_of(String), "web-search", "brave", 0, 0, 0, 5_000, wallet.period_end.to_i, kind_of(Integer)).once + expect(response.parsed_body["results"][1]).to eq("query" => "broken", "results" => [], "error" => "Brave Search answered HTTP 429") + end + + it "bills nothing and writes no ledger row when no query answered" do + search(queries: [ "broken" ]) + + expect(Levelcode::Metering).to have_received(:settle!).with(wallet, 5_000, 0, 0, 0).once + expect(RecordUsageJob).not_to have_received(:perform_async) + end + + it "cleans the queries: trimmed, duplicates dropped, at most four" do + seen = [] + allow(provider).to receive(:search) { |q, **| seen << q; [] } + search(queries: [ " a b ", "A B", "c", "", "d", "e", "f" ]) + + expect(seen).to match_array([ "a b", "c", "d", "e" ]) + expect(Levelcode::Metering).to have_received(:reserve!).with(wallet, 20_000) + end + + it "passes count (clamped) and a known recency to the provider, and ignores an unknown one" do + calls = [] + allow(provider).to receive(:search) { |q, count:, recency:| calls << [ q, count, recency ]; [] } + search(queries: [ "x" ], count: 99, recency: "week") + search(queries: [ "y" ], recency: "decade") + + expect(calls).to eq([ [ "x", 10, "week" ], [ "y", Levelcode::WebSearch::DEFAULT_COUNT, nil ] ]) + end + + it "422s without a query, and 503s — billing nothing — when no search key is set" do + search(queries: [ "", " " ]) + expect(response).to have_http_status(:unprocessable_entity) + + allow(Levelcode::WebSearch).to receive(:provider).and_return(nil) + search(queries: [ "csaf" ]) + expect(response).to have_http_status(:service_unavailable) + expect(response.parsed_body.dig("error", "code")).to eq("web_search_unavailable") + expect(Levelcode::Metering).not_to have_received(:reserve!) + end + + it "402s when the wallet is over its cap, when a throttle plan is over budget, or when the reservation does not fit" do + allow(Levelcode::Metering).to receive(:check!).and_return(Levelcode::Metering::Decision.new(allowed: false, throttle: false, policy: "stop")) + search(queries: [ "csaf" ]) + expect(response).to have_http_status(:payment_required) + + allow(Levelcode::Metering).to receive(:check!).and_return(Levelcode::Metering::Decision.new(allowed: true, throttle: true, policy: "throttle")) + search(queries: [ "csaf" ]) + expect(response).to have_http_status(:payment_required) + + allow(Levelcode::Metering).to receive(:check!).and_return(Levelcode::Metering::Decision.new(allowed: true, throttle: false, policy: "stop")) + allow(Levelcode::Metering).to receive(:reserve!).and_return(Levelcode::Metering::Reservation.new(ok: false, amount: 0)) + search(queries: [ "csaf" ]) + expect(response).to have_http_status(:payment_required) + expect(response.parsed_body.dig("error", "code")).to eq("cap_reached") + end + + it "releases the reservation when the search itself blows up" do + allow(Levelcode::WebSearch).to receive(:run).and_raise(RuntimeError, "boom") + expect { search(queries: [ "csaf" ]) }.to raise_error(RuntimeError, "boom") + expect(Levelcode::Metering).to have_received(:settle!).with(wallet, 5_000, 0, 0, 0).once + end + + context "with a token that is not an agent token" do + let(:scope) { "ai:chat account:read" } + + it "403s before anything is reserved" do + search(queries: [ "csaf" ]) + expect(response).to have_http_status(:forbidden) + expect(Levelcode::Metering).not_to have_received(:reserve!) + end + end + end + + describe "POST /ai/web/fetch" do + def fetch(url) + post "/api/levelcode/v1/ai/web/fetch", params: { url: url }, as: :json + end + + it "answers the page's text, final address and type" do + allow(Levelcode::WebFetch).to receive(:get).with("https://openeox.org/a") + .and_return(final_url: "https://openeox.org/b", status: 200, content_type: "text/html", body: "

hi

", truncated: false) + fetch("https://openeox.org/a") + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to eq("final_url" => "https://openeox.org/b", "status" => 200, "content_type" => "text/html", "body" => "

hi

", "truncated" => false) + end + + it "refuses what is not an http(s) address before reading anything" do + allow(Levelcode::WebFetch).to receive(:get) + [ "file:///etc/passwd", "gopher://x", "https://#{'a' * 2100}.org" ].each do |u| + fetch(u) + expect(response).to have_http_status(:unprocessable_entity) + end + expect(Levelcode::WebFetch).not_to have_received(:get) + end + + it "says why a page was not read: blocked, not text, too slow, unreachable" do + { Levelcode::WebFetch::Blocked.new("x is on a private or local network") => [ :unprocessable_entity, "blocked" ], + Levelcode::WebFetch::NotText.new("application/pdf is not a text page") => [ :unsupported_media_type, "not_text" ], + Net::ReadTimeout.new => [ :gateway_timeout, "timeout" ], + SocketError.new("getaddrinfo") => [ :bad_gateway, "unreachable" ] }.each do |error, (status, code)| + allow(Levelcode::WebFetch).to receive(:get).and_raise(error) + fetch("https://example.org/") + expect(response).to have_http_status(status) + expect(response.parsed_body.dig("error", "code")).to eq(code) + end + end + end +end diff --git a/spec/services/levelcode/web_fetch_spec.rb b/spec/services/levelcode/web_fetch_spec.rb new file mode 100644 index 00000000..31bb97c0 --- /dev/null +++ b/spec/services/levelcode/web_fetch_spec.rb @@ -0,0 +1,98 @@ +require "rails_helper" + +# Levelcode::WebFetch must never read a page on this server's own network. The address checks run on addresses +# alone; the reading runs against a real HTTP server on 127.0.0.1, reached through the `connect` seam (the guard +# itself would refuse 127.0.0.1, which is the point of the first examples). +RSpec.describe Levelcode::WebFetch do + describe ".private_address?" do + it "is true for loopback, private, link-local, carrier-grade, multicast, reserved and mapped addresses" do + %w[127.0.0.1 10.1.2.3 172.16.0.1 172.31.255.255 192.168.1.1 169.254.169.254 100.64.0.1 0.0.0.0 224.0.0.1 240.0.0.1 + 198.18.0.1 ::1 :: fe80::1 fd00::1 ff02::1 ::ffff:127.0.0.1 ::ffff:10.0.0.1 64:ff9b::a00:1 2001:db8::1 [::1] nonsense].each do |a| + expect(described_class.private_address?(a)).to be(true), a + end + end + + it "is false for public addresses" do + %w[8.8.8.8 1.1.1.1 172.32.0.1 172.15.255.255 100.128.0.1 2606:4700::6810:84e5 ::ffff:8.8.8.8].each do |a| + expect(described_class.private_address?(a)).to be(false), a + end + end + end + + describe ".get" do + let(:never) { ->(*) { raise "must not connect" } } + + it "refuses a name with any private address, before connecting — so a second DNS answer cannot sneak one in" do + expect { described_class.get("http://internal.example/", resolver: ->(_) { %w[93.184.216.34 10.0.0.5] }, connect: never) } + .to raise_error(Levelcode::WebFetch::Blocked, /private or local network/) + %w[http://127.0.0.1/ http://[::1]/ http://169.254.169.254/latest/meta-data].each do |u| + expect { described_class.get(u, connect: never) }.to raise_error(Levelcode::WebFetch::Blocked), u + end + expect { described_class.get("http://localhost:3000/", connect: never) }.to raise_error(Levelcode::WebFetch::Blocked) + end + + it "refuses other schemes, credentials in the address, and names that do not resolve" do + expect { described_class.get("ftp://example.org/x", connect: never) }.to raise_error(Levelcode::WebFetch::Blocked, /only http and https/) + expect { described_class.get("https://u:p@example.org/", resolver: ->(_) { %w[93.184.216.34] }, connect: never) }.to raise_error(Levelcode::WebFetch::Blocked, /user name or password/) + expect { described_class.get("https://nothing.invalid/", resolver: ->(_) { [] }, connect: never) }.to raise_error(Levelcode::WebFetch::Blocked, /does not resolve/) + end + + it "connects to the address that was checked, follows a redirect after checking it again, and refuses a hop to a private one" do + seen = [] + connect = lambda do |uri, ip| + seen << [ uri.to_s, ip ] + case uri.path + when "/a" then [ 302, { "location" => "/b" }, "", false ] + when "/b" then [ 200, { "content-type" => "text/html; charset=utf-8" }, "

Grüße

", false ] + when "/evil" then [ 301, { "location" => "http://10.0.0.1/admin" }, "", false ] + end + end + page = described_class.get("https://public.example/a", resolver: ->(_) { %w[93.184.216.34] }, connect: connect) + expect(page).to eq(final_url: "https://public.example/b", status: 200, content_type: "text/html; charset=utf-8", body: "

Grüße

", truncated: false) + expect(seen).to eq([ [ "https://public.example/a", "93.184.216.34" ], [ "https://public.example/b", "93.184.216.34" ] ]) + + expect { described_class.get("https://public.example/evil", resolver: ->(h) { h == "10.0.0.1" ? %w[10.0.0.1] : %w[93.184.216.34] }, connect: connect) } + .to raise_error(Levelcode::WebFetch::Blocked, /private or local/) + end + + it "refuses a page that is not text, stops after four redirects, and turns another charset into UTF-8" do + pdf = ->(*) { [ 200, { "content-type" => "application/pdf" }, "%PDF", false ] } + expect { described_class.get("https://p.example/f", resolver: ->(_) { %w[93.184.216.34] }, connect: pdf) }.to raise_error(Levelcode::WebFetch::NotText, /application\/pdf/) + loop_ = ->(*) { [ 302, { "location" => "/again" }, "", false ] } + expect { described_class.get("https://p.example/", resolver: ->(_) { %w[93.184.216.34] }, connect: loop_) }.to raise_error(Levelcode::WebFetch::Blocked, /too many redirects/) + latin = ->(*) { [ 200, { "content-type" => "text/plain; charset=ISO-8859-1" }, "caf\xE9".b, false ] } + expect(described_class.get("https://p.example/", resolver: ->(_) { %w[93.184.216.34] }, connect: latin)[:body]).to eq("café") + end + end + + describe ".request (a real HTTP server on 127.0.0.1)" do + around do |ex| + @server = TCPServer.new("127.0.0.1", 0) + @port = @server.addr[1] + @thread = Thread.new do + loop do + s = @server.accept + line = s.gets.to_s + nil while (h = s.gets) && h != "\r\n" + path = line.split[1] + body = path == "/big" ? ("x" * (Levelcode::WebFetch::MAX_BYTES + 5000)) : "hello #{path}" + s.write("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}") + s.close + rescue IOError, Errno::EPIPE, Errno::ECONNRESET + nil + end + end + ex.run + ensure + @thread&.kill + @server&.close + end + + it "reads the body, sends a LevelCode user agent, and cuts a page larger than 2 MB" do + status, headers, body, truncated = described_class.request(URI("http://127.0.0.1:#{@port}/page"), "127.0.0.1") + expect([ status, headers["content-type"], body, truncated ]).to eq([ 200, "text/plain", "hello /page", false ]) + _, _, big, cut = described_class.request(URI("http://127.0.0.1:#{@port}/big"), "127.0.0.1") + expect([ big.bytesize, cut ]).to eq([ Levelcode::WebFetch::MAX_BYTES, true ]) + end + end +end diff --git a/spec/services/levelcode/web_search_spec.rb b/spec/services/levelcode/web_search_spec.rb new file mode 100644 index 00000000..2b8747a5 --- /dev/null +++ b/spec/services/levelcode/web_search_spec.rb @@ -0,0 +1,64 @@ +require "rails_helper" + +RSpec.describe Levelcode::WebSearch do + describe ".provider" do + around do |ex| + saved = ENV.to_h.slice("LEVELCODE_WEB_SEARCH_PROVIDER", "BRAVE_SEARCH_API_KEY") + ex.run + ensure + %w[LEVELCODE_WEB_SEARCH_PROVIDER BRAVE_SEARCH_API_KEY].each { |k| saved.key?(k) ? ENV[k] = saved[k] : ENV.delete(k) } + end + + it "is Brave when its key is set, and nil (search unavailable) without one or for an unknown provider" do + allow(Rails.application.credentials).to receive(:dig).and_call_original + allow(Rails.application.credentials).to receive(:dig).with(:brave_search_api_key).and_return(nil) + ENV.delete("BRAVE_SEARCH_API_KEY") + ENV.delete("LEVELCODE_WEB_SEARCH_PROVIDER") + expect(described_class.provider).to be_nil + ENV["BRAVE_SEARCH_API_KEY"] = "k" + expect(described_class.provider).to be_a(Levelcode::WebSearch::BraveProvider) + ENV["LEVELCODE_WEB_SEARCH_PROVIDER"] = "bing" + expect(described_class.provider).to be_nil + end + end + + describe ".run" do + it "runs the queries at the same time and reports a failing or slow one without losing the others" do + stub_const("Levelcode::WebSearch::QUERY_TIMEOUT", 0.2) + provider = Object.new + def provider.search(q, count:, recency:) + sleep 0.15 if q == "a" || q == "b" + raise "Brave Search answered HTTP 429" if q == "bad" + sleep 5 if q == "slow" + [ { title: q, url: "https://#{q}.org/", snippet: "", date: "" } ] + end + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + out = described_class.run(%w[a b bad slow], provider: provider, count: 3, recency: nil) + took = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started + + expect(out.map { |r| [ r[:query], r[:error] ] }).to eq([ [ "a", nil ], [ "b", nil ], [ "bad", "Brave Search answered HTTP 429" ], [ "slow", "timed out after 0.2 s" ] ]) + expect(took).to be < 2.0 # a and b in parallel, slow not waited on + end + end + + describe Levelcode::WebSearch::BraveProvider do + it "asks with the key, extra snippets and freshness, and maps results to title, url, snippet and date" do + seen = nil + http = lambda do |uri, headers| + seen = [ uri, headers ] + [ 200, { web: { results: [ { title: "T", url: "https://t.org/", description: "Main", extra_snippets: %w[More Again Unused], page_age: "2026-03-01T00:00:00" } ] } }.to_json ] + end + results = described_class.new("KEY", http: http).search("csaf 2.1", count: 6, recency: "month") + + params = Rack::Utils.parse_query(seen[0].query) + expect(seen[0].host).to eq("api.search.brave.com") + expect(params).to include("q" => "csaf 2.1", "count" => "6", "extra_snippets" => "true", "freshness" => "pm") + expect(seen[1]).to include("X-Subscription-Token" => "KEY") + expect(results).to eq([ { title: "T", url: "https://t.org/", snippet: "Main … More … Again", date: "2026-03-01T00:00:00" } ]) + end + + it "raises on a non-200 answer, so that query is reported failed" do + expect { described_class.new("K", http: ->(*) { [ 429, "{}" ] }).search("q", count: 1, recency: nil) }.to raise_error(/answered HTTP 429/) + end + end +end