diff --git a/.rust-file-sizes.json b/.rust-file-sizes.json index 31cf9a29..addd59ac 100644 --- a/.rust-file-sizes.json +++ b/.rust-file-sizes.json @@ -10,7 +10,6 @@ "crates/agentic-server-core/src/storage/schema.rs": 552, "crates/agentic-server-core/src/tool/registry.rs": 515, "crates/agentic-server-core/src/tool/tool_search.rs": 1764, - "crates/agentic-server-core/src/tool/web_search/mod.rs": 535, "crates/agentic-server-core/src/types/io/input.rs": 629, "crates/agentic-server-core/src/types/io/output.rs": 1110, "crates/agentic-server-core/src/types/request_response.rs": 582, diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e1f2a657..ea3bb07b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -945,7 +945,8 @@ declaration until they have a complete handler and execution path. (`CodexNamespaceHandler`), and `tool_search.rs` (`ToolSearchHandler`). Their calls are returned for the client to resolve; the gateway does not execute them. - **Gateway-owned / built-in** tools implement both traits: see `web_search/mod.rs` - (`WebSearchHandler`, backed by You.com) and `mcp/handler.rs` (`McpHandler`, backed + (`WebSearchHandler`, backed by the configured `WebSearchProvider` in `web_search/you.rs` + or `web_search/brave.rs`) and `mcp/handler.rs` (`McpHandler`, backed by `mcp/client.rs`'s MCP protocol client and `mcp/pool.rs`'s connection pool). They have no client translator association because the gateway owns their execution and public lifecycle. diff --git a/CHANGELOG.md b/CHANGELOG.md index a564f0a9..afd92930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to Agentic API are documented here. +## [Unreleased] + +### Added + +- Added Brave Search as a selectable backend for the gateway-owned `web_search` tool (#294, Phase 2 of #291). + Select it with `AGENTIC_WEB_SEARCH_PROVIDER=brave` or `[web_search] provider = "brave"` and supply `BRAVE_API_KEY`; + the endpoint defaults to `https://api.search.brave.com` and can be overridden with `AGENTIC_WEB_SEARCH_BASE_URL` + or `[web_search] base_url`. Web and news results come from one request per query. The gateway adapts the shared + tool contract: `allowed_domains` / `blocked_domains` and the model's `include_domains` / `exclude_domains` are + enforced client-side on a label boundary, `count` is clamped to Brave's maximum of 20, `freshness` is rendered in + Brave syntax, `language` maps to `search_lang`, and the You.com-specific `livecrawl`, `livecrawl_formats`, + `crawl_timeout`, and `boost_domains` arguments are ignored. Rejected credentials and HTTP 429 responses fail the + `web_search_call` without an automatic retry, naming the key variable or the upstream `Retry-After` value and never + echoing the secret. Each Brave `metadata[]` entry carries `"provider": "brave"`. +- Added `[web_search] max_concurrent_queries` and `AGENTIC_WEB_SEARCH_MAX_CONCURRENT_QUERIES` to cap concurrent + provider requests inside one batched search. Brave defaults to `1` for its free-plan rate limit; You.com keeps + inheriting `max_concurrent_gateway_calls`. The effective ceiling is the smallest of the gateway limit, this + override, and the provider's own ceiling. + +### Changed + +- `WebSearchProviderConfig` is now `#[non_exhaustive]` and gains `provider` and `max_concurrent_queries` fields; + construct it with `WebSearchProviderConfig::new(api_key, base_url)` plus the `with_provider` and + `with_max_concurrent_queries` builders. Downstream crates that built it with a struct literal must switch to the + constructor; field reads and `Default` are unchanged. `WebSearchProviderKind` gains a `Brave` variant, `FromStr` + (case-insensitive), `default_base_url`, `default_max_concurrent_queries`, and `config_name`; + `WebSearchHandler::from_config` builds the handler for the selected provider and `GatewayExecutors::from_config` + uses it. With `provider` unset, You.com behavior, configuration, and model-facing output are unchanged; a generated + `config.toml` now records `provider = "you"` and leaves `api_key_env` unset so provider switches select the matching + default credential variable. + ## [0.7.0] - 2026-09-14 ### Added diff --git a/README.md b/README.md index f47fa300..eaa626d1 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ flowchart LR ## ✨ Key Features - 🔄 **Stateful conversations**: the server manages history via `previous_response_id`. No client-side message tracking, no replaying full transcripts. -- 🛠️ **Server-side tool execution**: an explicit tool-ownership model (gateway / client / provider) decides exactly what runs where. Web search ships today via [You.com](https://you.com), and the model executes multi-step tool chains automatically. +- 🛠️ **Server-side tool execution**: an explicit tool-ownership model (gateway / client / provider) decides exactly what runs where. Web search ships today via [You.com](https://you.com) or [Brave Search](https://brave.com/search/api/), and the model executes multi-step tool chains automatically. - 📡 **Every transport**: non-streaming HTTP, server-sent events for token streaming, and full **WebSocket** support for interactive clients. - 🧰 **Codex-ready**: accepts Codex-shaped Responses traffic out of the box, preserving the tool declarations and response item shapes Codex depends on. - 🏃 **Background execution**: fire-and-forget requests that keep processing server-side. @@ -186,6 +186,13 @@ YOU_API_KEY= YOU_API_BASE_URL= \ cargo run -p agentic-server -- --llm-api-base http://0.0.0.0:5050 ``` +Prefer [Brave Search](https://brave.com/search/api/) (it has a free developer plan)? Select it instead: + +```bash +AGENTIC_WEB_SEARCH_PROVIDER=brave BRAVE_API_KEY= \ + cargo run -p agentic-server -- --llm-api-base http://0.0.0.0:5050 +``` + The default database is `~/.agentic-api/agentic_api.db`, so running an installed binary does not create state in the current directory. Set `AGENTIC_API_HOME` to an absolute directory to move both the default database and user configuration, or set `DATABASE_URL`/`--db-url` to select a different database. @@ -219,8 +226,13 @@ llm_api_base = "http://127.0.0.1:5050" # database_url = "postgresql://agentic-api@localhost/agentic_api" [web_search] +# Search backend for the gateway-owned web_search tool: "you" (default) or "brave". +provider = "you" base_url = "https://api.ydc-index.io" api_key_env = "YOU_API_KEY" +# Concurrent provider requests inside one batched web-search call; unset uses +# the provider default (Brave: 1, You.com: max_concurrent_gateway_calls). +# max_concurrent_queries = 1 [mcp] allowed_hosts = ["mcp.example.com"] @@ -253,10 +265,14 @@ parsed. Order of precedence is `--max-request-body-size-bytes`, then `AGENTIC_MA file setting. `api_key_env` names the process environment variable containing the web-search credential; it does not contain the -credential itself. `YOU_API_BASE_URL`, `AGENTIC_MCP_ALLOWED_HOSTS`, `AGENTIC_MAX_REQUEST_BODY_SIZE_BYTES`, and -`AGENTIC_MAX_CONCURRENT_GATEWAY_CALLS` can override their typed file settings. The concurrency value is a sliding-window -upper bound; handlers may further serialize calls to the same tool name. The MCP allowlist is used only for -request-declared remote MCP URLs; configured `[mcp_servers]` entries are trusted operator configuration. +credential itself. When it is unset, the selected provider's conventional variable is read (`YOU_API_KEY` or +`BRAVE_API_KEY`). Newly generated files leave `api_key_env` unset so changing providers also changes the default +credential variable. `AGENTIC_WEB_SEARCH_PROVIDER`, `AGENTIC_WEB_SEARCH_BASE_URL`, `AGENTIC_WEB_SEARCH_MAX_CONCURRENT_QUERIES`, +`AGENTIC_MCP_ALLOWED_HOSTS`, `AGENTIC_MAX_REQUEST_BODY_SIZE_BYTES`, and `AGENTIC_MAX_CONCURRENT_GATEWAY_CALLS` can +override their typed file settings; `YOU_API_BASE_URL` is still honored as the endpoint override when the provider is +`you`. The concurrency values are sliding-window upper bounds; handlers may further serialize calls to the same tool +name. The MCP allowlist is used only for request-declared remote MCP URLs; configured `[mcp_servers]` entries are trusted +operator configuration. With that file in place, inject only the secret when starting the server: @@ -264,6 +280,48 @@ With that file in place, inject only the secret when starting the server: YOU_API_KEY="" agentic-server ``` +#### Web search providers + +The gateway-owned `web_search` tool runs against one configured backend; the model-facing tool schema is the same for +every provider. + +| Setting | Environment variable | `config.toml` key | Default | +| :--- | :--- | :--- | :--- | +| Provider | `AGENTIC_WEB_SEARCH_PROVIDER` | `[web_search] provider` | `you` | +| API key | variable named by `api_key_env` | `[web_search] api_key_env` | `YOU_API_KEY` / `BRAVE_API_KEY` | +| Endpoint | `AGENTIC_WEB_SEARCH_BASE_URL` (or `YOU_API_BASE_URL` for You.com) | `[web_search] base_url` | none for You.com; `https://api.search.brave.com` for Brave | +| Concurrent queries | `AGENTIC_WEB_SEARCH_MAX_CONCURRENT_QUERIES` | `[web_search] max_concurrent_queries` | You.com inherits `max_concurrent_gateway_calls`; Brave `1` | + +**You.com** (`provider = "you"`) is the default and behaves exactly as before: domain filters are applied by the +provider, `count` accepts 1–100, and the You.com-specific `livecrawl`, `livecrawl_formats`, `crawl_timeout`, and +`boost_domains` arguments are forwarded. + +**Brave Search** (`provider = "brave"`) needs only `BRAVE_API_KEY`; its [free plan](https://brave.com/search/api/) +covers local and evaluation deployments. Web and news results come from one request per query. The gateway adapts +the shared tool contract to Brave: + +- `allowed_domains` / `blocked_domains` (and the model's `include_domains` / `exclude_domains`) are enforced by the + gateway after the response arrives, since Brave has no server-side domain filter. An allowlist is a hard contract, + so a filtered search can return fewer than `count` results. +- `count` is clamped to Brave's maximum of 20; `freshness` is translated to Brave's `pd`/`pw`/`pm`/`py` codes or + passed through as a date range; `language` maps to `search_lang`. +- The You.com-specific arguments above are ignored (logged at debug level). +- Each per-query `metadata[]` entry carries `"provider": "brave"` so the model can see which backend answered. +- Brave's free plan allows roughly one request per second, so batched queries run one at a time by default. Raise + `max_concurrent_queries` on a paid plan. A rate-limited request (HTTP 429) fails that `web_search_call` without an + automatic retry and reports the upstream `Retry-After` value; a cap lowers, but cannot eliminate, 429s on a + per-second quota. + +If you switch an existing deployment to Brave, update `api_key_env` if an older `config.toml` pins it (or +remove it) and drop a You.com `base_url`; a mismatched key variable is reported in the failed `web_search_call` +message. Example: + +```toml +[web_search] +provider = "brave" +api_key_env = "BRAVE_API_KEY" +``` + Restrict the file to the service account (for example, `chmod 600 ~/.agentic-api/config.toml`), especially if you add credentialed `database_url`, MCP headers, or stdio MCP environment values. Prefer `DATABASE_URL`, referenced API-key environment variables, and a deployment secret manager for secrets. @@ -362,8 +420,8 @@ Claude Code's own tools (Bash, Edit, Read, …) stay **client-owned** — Claude ### Running Claude Code's web search on the gateway Current Claude Code versions declare Anthropic's native `web_search_20250305` server tool. Agentic API translates that -declaration for the upstream model and executes the resulting search server-side against the configured search backend; -no MCP server or tool alias is required: +declaration for the upstream model and executes the resulting search server-side against the configured search backend +(You.com or Brave Search, see [Web search providers](#web-search-providers)); no MCP server or tool alias is required: ```bash YOU_API_KEY= YOU_API_BASE_URL= \ diff --git a/crates/agentic-server-core/src/config.rs b/crates/agentic-server-core/src/config.rs index 10ffd711..fbfa1b87 100644 --- a/crates/agentic-server-core/src/config.rs +++ b/crates/agentic-server-core/src/config.rs @@ -23,6 +23,8 @@ pub const DEFAULT_SQLITE_MAX_CONNECTIONS: u32 = 4; pub const DEFAULT_SQLITE_JOURNAL_SIZE_LIMIT_BYTES: u64 = 6_144_000; pub const DEFAULT_SQLITE_MMAP_SIZE_BYTES: u64 = 268_435_456; pub const DEFAULT_MAX_CONCURRENT_GATEWAY_CALLS: NonZeroUsize = NonZeroUsize::new(5).expect("default is nonzero"); +/// Brave Search's free plan allows roughly one request per second. +pub const DEFAULT_BRAVE_MAX_CONCURRENT_QUERIES: NonZeroUsize = NonZeroUsize::new(1).expect("default is nonzero"); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PostgresConfig { @@ -89,24 +91,50 @@ impl Default for SqliteConfig { /// Backend that serves the gateway-owned `web_search` tool. /// -/// Additional providers are added here (#291). The enum is non-exhaustive so -/// downstream crates keep a fallback arm when a new variant lands. Selecting a -/// provider through [`WebSearchProviderConfig`] is deferred until a second -/// provider exists. +/// Selected through [`WebSearchProviderConfig::provider`]; `you` is the +/// default so existing deployments are unchanged. The enum is non-exhaustive +/// so downstream crates keep a fallback arm when a new variant lands (#291). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum WebSearchProviderKind { #[default] You, + Brave, } impl WebSearchProviderKind { + /// Every selectable provider, in the order operator-facing messages list them. + pub const ALL: [Self; 2] = [Self::You, Self::Brave]; + /// Environment variable that conventionally carries this provider's API key. #[must_use] pub const fn default_api_key_env(self) -> &'static str { match self { Self::You => "YOU_API_KEY", + Self::Brave => "BRAVE_API_KEY", + } + } + + /// Endpoint used when neither the environment nor the configuration file + /// sets one. You.com has no default so a deployment that fails today keeps + /// failing the same way (#291 Q2). + #[must_use] + pub const fn default_base_url(self) -> Option<&'static str> { + match self { + Self::You => None, + Self::Brave => Some("https://api.search.brave.com"), + } + } + + /// Provider-imposed default ceiling on concurrent search requests. `None` + /// inherits the gateway-wide limit. Brave's free plan allows roughly one + /// request per second, so it defaults to serial queries. + #[must_use] + pub const fn default_max_concurrent_queries(self) -> Option { + match self { + Self::You => None, + Self::Brave => Some(DEFAULT_BRAVE_MAX_CONCURRENT_QUERIES), } } @@ -115,8 +143,47 @@ impl WebSearchProviderKind { pub const fn display_name(self) -> &'static str { match self { Self::You => "You.com", + Self::Brave => "Brave Search", + } + } + + /// Configuration label (`you`, `brave`) matching the serialized form. + #[must_use] + pub const fn config_name(self) -> &'static str { + match self { + Self::You => "you", + Self::Brave => "brave", } } + + /// Whether this is the default provider whose model-facing output must stay + /// byte-identical to earlier releases. + #[must_use] + pub const fn is_you(&self) -> bool { + matches!(self, Self::You) + } +} + +impl std::str::FromStr for WebSearchProviderKind { + type Err = Error; + + /// Parses a configuration or environment value case-insensitively. + fn from_str(value: &str) -> Result { + let trimmed = value.trim(); + Self::ALL + .into_iter() + .find(|kind| kind.config_name().eq_ignore_ascii_case(trimmed)) + .ok_or_else(|| { + let expected = Self::ALL + .iter() + .map(|kind| kind.config_name()) + .collect::>() + .join(", "); + Error::Config(format!( + "unknown web_search provider {trimmed:?}; expected one of: {expected}" + )) + }) + } } impl std::fmt::Display for WebSearchProviderKind { @@ -125,18 +192,46 @@ impl std::fmt::Display for WebSearchProviderKind { } } -/// Credentials for the gateway-owned `web_search` provider (You.com). +/// Selection and credentials for the gateway-owned `web_search` provider. +/// +/// Construct with [`WebSearchProviderConfig::new`] and the `with_*` builders; +/// the struct is non-exhaustive so adding a provider setting is not a +/// breaking change for downstream crates. #[derive(Clone, Default)] +#[non_exhaustive] pub struct WebSearchProviderConfig { + pub provider: WebSearchProviderKind, pub api_key: Option, pub base_url: Option, + /// Operator override for the provider's concurrent-query ceiling. `None` + /// uses [`WebSearchProviderKind::default_max_concurrent_queries`]. + pub max_concurrent_queries: Option, } impl WebSearchProviderConfig { - /// Builds the config from the credential and endpoint the deployment resolved. + /// Builds a You.com config from the credential and endpoint the deployment resolved. #[must_use] pub const fn new(api_key: Option, base_url: Option) -> Self { - Self { api_key, base_url } + Self { + provider: WebSearchProviderKind::You, + api_key, + base_url, + max_concurrent_queries: None, + } + } + + /// Selects the provider the credential and endpoint belong to. + #[must_use] + pub const fn with_provider(mut self, provider: WebSearchProviderKind) -> Self { + self.provider = provider; + self + } + + /// Overrides the provider's default concurrent-query ceiling. + #[must_use] + pub const fn with_max_concurrent_queries(mut self, max_concurrent_queries: Option) -> Self { + self.max_concurrent_queries = max_concurrent_queries; + self } } @@ -144,8 +239,10 @@ impl std::fmt::Debug for WebSearchProviderConfig { /// Redacts `api_key` so debug-printing any enclosing config never logs the secret. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("WebSearchProviderConfig") + .field("provider", &self.provider) .field("api_key", &self.api_key.as_ref().map(|_| "")) .field("base_url", &self.base_url) + .field("max_concurrent_queries", &self.max_concurrent_queries) .finish() } } @@ -315,7 +412,21 @@ mod tests { assert!(!format!("{tools:?}").contains("super-secret-key")); assert_eq!( format!("{:?}", WebSearchProviderConfig::default()), - "WebSearchProviderConfig { api_key: None, base_url: None }" + "WebSearchProviderConfig { provider: You, api_key: None, base_url: None, max_concurrent_queries: None }" + ); + } + + #[test] + fn web_search_provider_config_builders_select_provider_and_ceiling() { + let config = WebSearchProviderConfig::new(Some("k".to_owned()), None) + .with_provider(WebSearchProviderKind::Brave) + .with_max_concurrent_queries(NonZeroUsize::new(3)); + assert_eq!(config.provider, WebSearchProviderKind::Brave); + assert_eq!(config.api_key.as_deref(), Some("k")); + assert_eq!(config.max_concurrent_queries, NonZeroUsize::new(3)); + assert_eq!( + WebSearchProviderConfig::new(None, None).provider, + WebSearchProviderKind::You ); } @@ -323,7 +434,53 @@ mod tests { fn web_search_provider_kind_labels() { assert_eq!(WebSearchProviderKind::You.to_string(), "You.com"); assert_eq!(WebSearchProviderKind::You.default_api_key_env(), "YOU_API_KEY"); + assert_eq!(WebSearchProviderKind::You.default_base_url(), None); + assert_eq!(WebSearchProviderKind::You.default_max_concurrent_queries(), None); + assert!(WebSearchProviderKind::You.is_you()); assert_eq!(WebSearchProviderKind::default(), WebSearchProviderKind::You); + + assert_eq!(WebSearchProviderKind::Brave.to_string(), "Brave Search"); + assert_eq!(WebSearchProviderKind::Brave.default_api_key_env(), "BRAVE_API_KEY"); + assert_eq!( + WebSearchProviderKind::Brave.default_base_url(), + Some("https://api.search.brave.com") + ); + assert_eq!( + WebSearchProviderKind::Brave.default_max_concurrent_queries(), + NonZeroUsize::new(1) + ); + assert!(!WebSearchProviderKind::Brave.is_you()); + } + + #[test] + fn web_search_provider_kind_parses_case_insensitively_and_serializes_snake_case() { + for value in ["brave", "Brave", " BRAVE "] { + assert_eq!( + value.parse::().unwrap(), + WebSearchProviderKind::Brave + ); + } + assert_eq!( + "you".parse::().unwrap(), + WebSearchProviderKind::You + ); + let error = "bing".parse::().unwrap_err(); + assert_eq!( + error.to_string(), + "unknown web_search provider \"bing\"; expected one of: you, brave" + ); + + assert_eq!( + serde_json::to_string(&WebSearchProviderKind::Brave).unwrap(), + "\"brave\"" + ); + assert_eq!( + serde_json::from_str::("\"you\"").unwrap(), + WebSearchProviderKind::You + ); + for kind in WebSearchProviderKind::ALL { + assert_eq!(kind.config_name().parse::().unwrap(), kind); + } } #[test] diff --git a/crates/agentic-server-core/src/tool/executors.rs b/crates/agentic-server-core/src/tool/executors.rs index 02e63352..9bf3cafb 100644 --- a/crates/agentic-server-core/src/tool/executors.rs +++ b/crates/agentic-server-core/src/tool/executors.rs @@ -84,10 +84,9 @@ impl GatewayExecutors { } else { config.mcp_allowed_hosts.clone() }, - web_search: Some(Arc::new(WebSearchHandler::from_values( + web_search: Some(Arc::new(WebSearchHandler::from_config( client, - config.web_search.api_key.clone(), - config.web_search.base_url.clone(), + &config.web_search, config.max_concurrent_gateway_calls, ))), }; diff --git a/crates/agentic-server-core/src/tool/web_search/args.rs b/crates/agentic-server-core/src/tool/web_search/args.rs index e1f3214f..08278732 100644 --- a/crates/agentic-server-core/src/tool/web_search/args.rs +++ b/crates/agentic-server-core/src/tool/web_search/args.rs @@ -211,16 +211,14 @@ pub(crate) fn clean_vec(values: Option<&[String]>) -> Option> { /// (label boundary), compared case-insensitively after IDNA normalization. A /// URL without a parseable host cannot be checked, so it is rejected whenever /// any allowlist or blocklist is active (fail closed). You.com filters -/// server-side, so this is not applied on that path; the first provider that -/// needs it wires it in (#291 Phase 2). +/// server-side, so this is not applied on that path; Brave Search applies it +/// to every result section. #[derive(Debug, Clone, Default, PartialEq, Eq)] -#[allow(dead_code)] // wired by the first provider without server-side filtering (#291 Phase 2) pub(crate) struct DomainFilter { include: Vec, exclude: Vec, } -#[allow(dead_code)] // wired by the first provider without server-side filtering (#291 Phase 2) impl DomainFilter { pub(crate) fn new(include: Option<&[String]>, exclude: Option<&[String]>) -> Self { Self { diff --git a/crates/agentic-server-core/src/tool/web_search/brave.rs b/crates/agentic-server-core/src/tool/web_search/brave.rs new file mode 100644 index 00000000..153d50c6 --- /dev/null +++ b/crates/agentic-server-core/src/tool/web_search/brave.rs @@ -0,0 +1,664 @@ +//! Brave Search API provider for `web_search`. +//! +//! Owns request shaping against Brave's `GET /res/v1/web/search` and the +//! mapping of its JSON envelope onto the provider-neutral +//! [`WebSearchProviderResponse`]. Brave differs from You.com in ways the +//! gateway adapts here rather than surfacing to the model: +//! +//! - no server-side domain filtering, so `include_domains` / `exclude_domains` +//! are applied client-side through [`DomainFilter`]; +//! - `count` is capped at [`BRAVE_MAX_COUNT`] and clamped instead of rejected; +//! - `freshness` uses `pd` / `pw` / `pm` / `py` short codes; +//! - the free plan allows roughly one request per second, so the provider +//! defaults to serial queries through [`WebSearchProvider::max_concurrent_requests`]. +//! +//! `Accept-Encoding` is deliberately never sent: the core `reqwest` build has no +//! `gzip` feature, so a compressed body could not be decoded. + +use std::future::Future; +use std::num::NonZeroUsize; +use std::pin::Pin; +use std::sync::Arc; + +use reqwest::StatusCode; +use serde::Deserialize; + +use super::args::{DomainFilter, Freshness, WebSearchArguments, clean_string, clean_vec, validate_count}; +use super::{ + ApiKey, WebSearchProvider, WebSearchProviderMetadata, WebSearchProviderResponse, WebSearchResult, clean_base_url, + null_as_default, read_response_limited, +}; +use crate::config::WebSearchProviderKind; +use crate::tool::handler::ToolError; +use crate::types::tools::{WebSearchContextSize, WebSearchToolParam}; + +pub(crate) const BRAVE_API_KEY: &str = WebSearchProviderKind::Brave.default_api_key_env(); + +/// Largest `count` Brave accepts per request. +pub(crate) const BRAVE_MAX_COUNT: u8 = 20; + +const SEARCH_PATH: &str = "/res/v1/web/search"; +const RESULT_FILTER: &str = "web,news"; + +#[derive(Debug, Clone)] +pub(crate) struct BraveSearchProvider { + client: Arc, + api_key: Option, + base_url: String, + max_concurrent_requests: NonZeroUsize, +} + +impl BraveSearchProvider { + /// Builds a provider from optional environment-style values: a blank key + /// counts as unset and fails at execution time; a blank base URL falls back + /// to Brave's public endpoint. + pub(crate) fn from_values( + client: Arc, + api_key: Option, + base_url: Option, + max_concurrent_requests: NonZeroUsize, + ) -> Self { + let api_key = api_key + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .map(ApiKey); + let base_url = base_url + .and_then(|value| clean_base_url(&value)) + .or_else(|| WebSearchProviderKind::Brave.default_base_url().map(str::to_owned)) + .unwrap_or_default(); + Self { + client, + api_key, + base_url, + max_concurrent_requests, + } + } +} + +impl WebSearchProvider for BraveSearchProvider { + fn search<'a>( + &'a self, + query: &'a str, + args: &'a WebSearchArguments, + config: &'a WebSearchToolParam, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let api_key = self + .api_key + .as_ref() + .ok_or_else(|| ToolError::Config(format!("{BRAVE_API_KEY} must be set to use the web_search tool")))?; + let request = BraveSearchRequest::from_args_and_config(query, args, config)?; + let resp = self + .client + .get(format!("{}{SEARCH_PATH}", self.base_url)) + .query(&request.query_params()) + .header("Accept", "application/json") + .header("X-Subscription-Token", &api_key.0) + .send() + .await + .map_err(|e| ToolError::Execution(format!("Brave Search request failed: {e}")))?; + + let status = resp.status(); + if !status.is_success() { + return Err(failure_from_status(resp).await); + } + + let response_text = read_response_limited(resp, WebSearchProviderKind::Brave).await?; + let response: BraveSearchResponse = serde_json::from_str(&response_text) + .map_err(|e| ToolError::Execution(format!("Brave Search returned invalid JSON: {e}")))?; + Ok(response.into_provider_response(&request.query, &request.domain_filter)) + }) + } + + fn max_concurrent_requests(&self) -> Option { + Some(self.max_concurrent_requests) + } +} + +/// Maps a non-2xx Brave response to an actionable, credential-free error. +/// +/// `401`/`403` name the key variable without echoing the upstream body; `429` +/// is reported without retrying (a retry loop would blur the gateway tool +/// timeout) and carries the upstream `Retry-After` — or Brave's +/// `X-RateLimit-Reset` — so the caller can back off. +async fn failure_from_status(resp: reqwest::Response) -> ToolError { + let status = resp.status(); + match status { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => ToolError::Execution(format!( + "Brave Search rejected the API key ({status}); check {BRAVE_API_KEY}" + )), + StatusCode::TOO_MANY_REQUESTS => { + let retry_after = ["retry-after", "x-ratelimit-reset"] + .into_iter() + .find_map(|name| resp.headers().get(name).and_then(|value| value.to_str().ok())) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + let hint = retry_after.map_or_else( + || "; no Retry-After header was provided".to_owned(), + |value| format!("; retry after {value}"), + ); + ToolError::Execution(format!( + "Brave Search rate limited the request ({status}); the gateway does not retry{hint}" + )) + } + _ => { + let body = read_response_limited(resp, WebSearchProviderKind::Brave) + .await + .unwrap_or_default(); + ToolError::Execution(format!("Brave Search returned {status}: {body}")) + } + } +} + +/// Query parameters for Brave's `GET /res/v1/web/search`, derived from the +/// model's arguments and the request-level tool configuration, plus the +/// client-side [`DomainFilter`] Brave cannot apply itself. +#[derive(Debug, PartialEq, Eq)] +struct BraveSearchRequest { + query: String, + count: Option, + freshness: Option, + country: Option, + search_lang: Option, + safesearch: Option, + domain_filter: DomainFilter, +} + +impl BraveSearchRequest { + fn query_params(&self) -> Vec<(String, String)> { + let mut params = vec![ + ("q".to_owned(), self.query.clone()), + ("result_filter".to_owned(), RESULT_FILTER.to_owned()), + // Brave wraps matched terms in `` unless asked not to; the + // model should see plain text. + ("text_decorations".to_owned(), "false".to_owned()), + ]; + if let Some(count) = self.count { + params.push(("count".to_owned(), count.to_string())); + } + if let Some(freshness) = &self.freshness { + params.push(("freshness".to_owned(), brave_freshness(*freshness))); + } + if let Some(country) = &self.country { + params.push(("country".to_owned(), country.clone())); + } + if let Some(search_lang) = &self.search_lang { + params.push(("search_lang".to_owned(), search_lang.clone())); + } + if let Some(safesearch) = &self.safesearch { + params.push(("safesearch".to_owned(), safesearch.clone())); + } + params + } + + fn from_args_and_config( + query: &str, + args: &WebSearchArguments, + config: &WebSearchToolParam, + ) -> Result { + let count = args + .count + .or_else(|| { + config + .search_context_size + .map(WebSearchContextSize::default_count) + .map(u16::from) + }) + .map(validate_count) + .transpose()? + .map(clamp_count); + let config_domains = config + .filters + .as_ref() + .and_then(|filters| clean_vec(filters.allowed_domains.as_deref())); + let config_blocked_domains = config + .filters + .as_ref() + .and_then(|filters| clean_vec(filters.blocked_domains.as_deref())); + let include_domains = config_domains.or_else(|| args.include_domains.clone()); + let exclude_domains = config_blocked_domains.or_else(|| args.exclude_domains.clone()); + if include_domains.is_some() && (exclude_domains.is_some() || args.boost_domains.is_some()) { + return Err(ToolError::Config( + "include_domains cannot be combined with exclude_domains or boost_domains".to_owned(), + )); + } + log_ignored_arguments(args); + let country = config + .user_location + .as_ref() + .and_then(|location| clean_string(location.country.as_deref())) + .or_else(|| args.country.clone()) + .map(|value| value.to_ascii_uppercase()); + + Ok(Self { + query: query.trim().to_owned(), + count, + freshness: args.freshness, + country, + // Brave expects lowercase codes such as `en`, `pt-br`, `zh-hans`. + search_lang: args.language.as_deref().map(str::to_ascii_lowercase), + safesearch: args.safesearch.clone(), + domain_filter: DomainFilter::new(include_domains.as_deref(), exclude_domains.as_deref()), + }) + } +} + +/// Brave accepts at most [`BRAVE_MAX_COUNT`] results; the model cannot know +/// provider limits, so a larger request is clamped rather than rejected. +fn clamp_count(count: u8) -> u8 { + if count > BRAVE_MAX_COUNT { + tracing::debug!( + requested = count, + max = BRAVE_MAX_COUNT, + "clamped web_search count to Brave maximum" + ); + BRAVE_MAX_COUNT + } else { + count + } +} + +/// You.com-specific arguments have no Brave equivalent and are dropped; +/// `boost_domains` has no filtering semantics, so it is dropped too. +fn log_ignored_arguments(args: &WebSearchArguments) { + let ignored: Vec<&str> = [ + ("livecrawl", args.livecrawl.is_some()), + ("livecrawl_formats", args.livecrawl_formats.is_some()), + ("crawl_timeout", args.crawl_timeout.is_some()), + ("boost_domains", args.boost_domains.is_some()), + ] + .into_iter() + .filter_map(|(name, present)| present.then_some(name)) + .collect(); + if !ignored.is_empty() { + tracing::debug!(arguments = ?ignored, "ignored You.com-specific web_search arguments for Brave Search"); + } +} + +/// Renders the typed freshness filter in Brave's syntax. +fn brave_freshness(freshness: Freshness) -> String { + match freshness { + Freshness::Day => "pd".to_owned(), + Freshness::Week => "pw".to_owned(), + Freshness::Month => "pm".to_owned(), + Freshness::Year => "py".to_owned(), + range @ Freshness::Range { .. } => range.to_string(), + } +} + +/// Brave's `GET /res/v1/web/search` envelope. Only the `web` and `news` +/// sections are modeled; other sections (`mixed`, `query`, `videos`, …) and +/// unknown keys are ignored so upstream additions never break the provider. +#[derive(Debug, Default, Deserialize)] +struct BraveSearchResponse { + #[serde(default, deserialize_with = "null_as_default")] + web: BraveResultSection, + #[serde(default, deserialize_with = "null_as_default")] + news: BraveResultSection, +} + +#[derive(Debug, Default, Deserialize)] +struct BraveResultSection { + #[serde(default, deserialize_with = "null_as_default")] + results: Vec, +} + +/// One Brave web or news hit. `page_age` is Brave's ISO timestamp and `age` its +/// human-readable form; cosmetic fields (`thumbnail`, `meta_url`, `profile`) +/// are not modeled. +#[derive(Debug, Default, Deserialize)] +struct BraveResult { + #[serde(default)] + url: String, + #[serde(default)] + title: Option, + #[serde(default)] + description: Option, + #[serde(default)] + page_age: Option, + #[serde(default)] + age: Option, + /// Additional excerpts, returned only on plans that enable them. + #[serde(default, deserialize_with = "null_as_default")] + extra_snippets: Vec, +} + +impl From for WebSearchResult { + fn from(result: BraveResult) -> Self { + Self { + url: result.url.trim().to_owned(), + title: clean_string(result.title.as_deref()), + description: clean_string(result.description.as_deref()), + snippets: clean_vec(Some(&result.extra_snippets)).unwrap_or_default(), + page_age: clean_string(result.page_age.as_deref()).or_else(|| clean_string(result.age.as_deref())), + contents: None, + } + } +} + +impl BraveSearchResponse { + fn into_provider_response(self, query: &str, domain_filter: &DomainFilter) -> WebSearchProviderResponse { + let mut web: Vec = self.web.results.into_iter().map(Into::into).collect(); + let mut news: Vec = self.news.results.into_iter().map(Into::into).collect(); + domain_filter.retain(&mut web); + domain_filter.retain(&mut news); + WebSearchProviderResponse { + web, + news, + metadata: WebSearchProviderMetadata { + provider: WebSearchProviderKind::Brave, + query: query.to_owned(), + search_uuid: None, + latency: None, + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::tools::{WebSearchFilters, WebSearchUserLocation}; + + fn build_provider(api_key: Option<&str>, base_url: Option<&str>) -> BraveSearchProvider { + BraveSearchProvider::from_values( + Arc::new(reqwest::Client::new()), + api_key.map(str::to_owned), + base_url.map(str::to_owned), + NonZeroUsize::new(1).unwrap(), + ) + } + + fn args(json: &str) -> WebSearchArguments { + WebSearchArguments::from_json(json).unwrap() + } + + fn params(request: &BraveSearchRequest) -> Vec<(String, String)> { + request.query_params() + } + + #[test] + fn provider_debug_is_redacted_and_defaults_base_url() { + let provider = build_provider(Some("super-secret-key"), None); + let rendered = format!("{provider:?}"); + assert!(!rendered.contains("super-secret-key")); + assert!(rendered.contains("ApiKey()")); + assert_eq!(provider.base_url, "https://api.search.brave.com"); + assert_eq!(provider.max_concurrent_requests(), NonZeroUsize::new(1)); + + let provider = build_provider(Some(" "), Some(" https://brave.example/// ")); + assert!(provider.api_key.is_none()); + assert_eq!(provider.base_url, "https://brave.example"); + } + + #[tokio::test] + async fn search_without_api_key_names_the_env_var() { + let provider = build_provider(None, None); + let error = provider + .search("q", &args(r#"{"query":"q"}"#), &WebSearchToolParam::default()) + .await + .unwrap_err(); + assert_eq!( + error.to_string(), + "invalid tool config: BRAVE_API_KEY must be set to use the web_search tool" + ); + } + + #[test] + fn request_renders_every_argument_in_brave_syntax() { + let request = BraveSearchRequest::from_args_and_config( + " rust async ", + &args( + r#"{"query":"rust async","count":7,"freshness":"week","country":"gb","language":"en-GB","safesearch":"strict"}"#, + ), + &WebSearchToolParam::default(), + ) + .unwrap(); + assert_eq!( + params(&request), + [ + ("q", "rust async"), + ("result_filter", "web,news"), + ("text_decorations", "false"), + ("count", "7"), + ("freshness", "pw"), + ("country", "GB"), + ("search_lang", "en-gb"), + ("safesearch", "strict"), + ] + .map(|(key, value)| (key.to_owned(), value.to_owned())) + ); + assert!(request.domain_filter.is_empty()); + } + + #[test] + fn request_clamps_count_and_applies_context_size_default() { + let request = BraveSearchRequest::from_args_and_config( + "q", + &args(r#"{"query":"q","count":50}"#), + &WebSearchToolParam::default(), + ) + .unwrap(); + assert_eq!(request.count, Some(BRAVE_MAX_COUNT)); + + let request = BraveSearchRequest::from_args_and_config( + "q", + &args(r#"{"query":"q","count":20}"#), + &WebSearchToolParam::default(), + ) + .unwrap(); + assert_eq!(request.count, Some(20)); + + let config = WebSearchToolParam { + search_context_size: Some(WebSearchContextSize::High), + ..WebSearchToolParam::default() + }; + let request = BraveSearchRequest::from_args_and_config("q", &args(r#"{"query":"q"}"#), &config).unwrap(); + assert_eq!( + request.count.map(u16::from), + Some(u16::from(WebSearchContextSize::High.default_count())) + ); + + let error = BraveSearchRequest::from_args_and_config( + "q", + &args(r#"{"query":"q","count":0}"#), + &WebSearchToolParam::default(), + ) + .unwrap_err(); + assert_eq!( + error.to_string(), + "invalid tool config: web_search count must be between 1 and 100" + ); + } + + #[test] + fn freshness_renders_short_codes_and_ranges() { + assert_eq!(brave_freshness(Freshness::Day), "pd"); + assert_eq!(brave_freshness(Freshness::Week), "pw"); + assert_eq!(brave_freshness(Freshness::Month), "pm"); + assert_eq!(brave_freshness(Freshness::Year), "py"); + let range: Freshness = "2026-01-02to2026-02-03".parse().unwrap(); + assert_eq!(brave_freshness(range), "2026-01-02to2026-02-03"); + } + + #[test] + fn request_ignores_you_specific_arguments_and_builds_domain_filter() { + let request = BraveSearchRequest::from_args_and_config( + "q", + &args( + r#"{"query":"q","livecrawl":"web","livecrawl_formats":["markdown"],"crawl_timeout":5,"exclude_domains":["Example.com"]}"#, + ), + &WebSearchToolParam::default(), + ) + .unwrap(); + let rendered = params(&request); + assert!( + rendered + .iter() + .all(|(key, _)| !key.starts_with("livecrawl") && key != "crawl_timeout") + ); + assert!(rendered.iter().all(|(key, _)| !key.contains("domains"))); + assert!(!request.domain_filter.allows("https://docs.example.com/x")); + assert!(request.domain_filter.allows("https://other.org/x")); + } + + #[test] + fn request_prefers_tool_config_filters_and_location_over_arguments() { + let config = WebSearchToolParam { + filters: Some(WebSearchFilters { + allowed_domains: Some(vec!["rust-lang.org".to_owned()]), + blocked_domains: None, + }), + user_location: Some(WebSearchUserLocation { + country: Some("us".to_owned()), + ..WebSearchUserLocation::default() + }), + ..WebSearchToolParam::default() + }; + let request = BraveSearchRequest::from_args_and_config( + "q", + &args(r#"{"query":"q","country":"de","include_domains":["example.com"]}"#), + &config, + ) + .unwrap(); + assert_eq!(request.country.as_deref(), Some("US")); + assert!(request.domain_filter.allows("https://doc.rust-lang.org/book")); + assert!(!request.domain_filter.allows("https://example.com/")); + } + + #[test] + fn request_rejects_conflicting_domain_lists() { + let error = BraveSearchRequest::from_args_and_config( + "q", + &args(r#"{"query":"q","include_domains":["a.com"],"exclude_domains":["b.com"]}"#), + &WebSearchToolParam::default(), + ) + .unwrap_err(); + assert_eq!( + error.to_string(), + "invalid tool config: include_domains cannot be combined with exclude_domains or boost_domains" + ); + let error = BraveSearchRequest::from_args_and_config( + "q", + &args(r#"{"query":"q","include_domains":["a.com"],"boost_domains":["b.com"]}"#), + &WebSearchToolParam::default(), + ) + .unwrap_err(); + assert!(error.to_string().contains("cannot be combined")); + } + + #[test] + fn response_maps_web_and_news_and_tolerates_unknown_fields() { + let response: BraveSearchResponse = serde_json::from_str( + r#"{ + "type": "search", + "query": {"original": "rust"}, + "mixed": {"type": "mixed", "main": []}, + "web": { + "type": "search", + "results": [ + { + "title": " Rust ", + "url": " https://www.rust-lang.org/ ", + "description": "A language", + "page_age": "2026-01-02T03:04:05", + "age": "2 days ago", + "language": "en", + "family_friendly": true, + "extra_snippets": ["one", " ", "two"], + "thumbnail": {"src": "x"}, + "meta_url": {"hostname": "rust-lang.org"} + }, + {"url": "https://example.com/no-title", "title": "", "description": null} + ] + }, + "news": { + "type": "news", + "results": [ + {"title": "Release", "url": "https://blog.rust-lang.org/1", "age": "1 hour ago", + "source": "Rust Blog", "breaking": false} + ] + } + }"#, + ) + .unwrap(); + let response = response.into_provider_response("rust", &DomainFilter::default()); + assert_eq!( + response.web, + vec![ + WebSearchResult { + url: "https://www.rust-lang.org/".to_owned(), + title: Some("Rust".to_owned()), + description: Some("A language".to_owned()), + snippets: vec!["one".to_owned(), "two".to_owned()], + page_age: Some("2026-01-02T03:04:05".to_owned()), + contents: None, + }, + WebSearchResult { + url: "https://example.com/no-title".to_owned(), + ..WebSearchResult::default() + }, + ] + ); + assert_eq!( + response.news, + vec![WebSearchResult { + url: "https://blog.rust-lang.org/1".to_owned(), + title: Some("Release".to_owned()), + page_age: Some("1 hour ago".to_owned()), + ..WebSearchResult::default() + }] + ); + assert_eq!( + response.metadata, + WebSearchProviderMetadata { + provider: WebSearchProviderKind::Brave, + query: "rust".to_owned(), + search_uuid: None, + latency: None, + } + ); + assert_eq!( + serde_json::to_string(&response.metadata).unwrap(), + r#"{"provider":"brave","query":"rust"}"# + ); + } + + #[test] + fn response_tolerates_missing_and_null_sections() { + for body in ["{}", r#"{"web":null,"news":null}"#, r#"{"web":{"results":null}}"#] { + let response: BraveSearchResponse = serde_json::from_str(body).unwrap(); + let response = response.into_provider_response("q", &DomainFilter::default()); + assert!(response.web.is_empty()); + assert!(response.news.is_empty()); + } + } + + #[test] + fn response_applies_domain_filter_to_both_sections() { + let response: BraveSearchResponse = serde_json::from_str( + r#"{ + "web": {"results": [ + {"url": "https://docs.example.com/a"}, + {"url": "https://notexample.com/b"}, + {"url": "https://EXAMPLE.COM./c"}, + {"url": "not a url"} + ]}, + "news": {"results": [ + {"url": "https://news.example.com/d"}, + {"url": "https://other.org/e"} + ]} + }"#, + ) + .unwrap(); + let filter = DomainFilter::new(Some(&["example.com".to_owned()]), None); + let response = response.into_provider_response("q", &filter); + let urls = |results: &[WebSearchResult]| results.iter().map(|r| r.url.clone()).collect::>(); + assert_eq!( + urls(&response.web), + ["https://docs.example.com/a", "https://EXAMPLE.COM./c"] + ); + assert_eq!(urls(&response.news), ["https://news.example.com/d"]); + } +} diff --git a/crates/agentic-server-core/src/tool/web_search/mod.rs b/crates/agentic-server-core/src/tool/web_search/mod.rs index 3f530d60..94ce8d28 100644 --- a/crates/agentic-server-core/src/tool/web_search/mod.rs +++ b/crates/agentic-server-core/src/tool/web_search/mod.rs @@ -1,12 +1,13 @@ //! Gateway-owned `web_search` tool. //! //! `mod.rs` owns the OpenAI-facing adapter: the [`WebSearchHandler`], the -//! private [`WebSearchProvider`] contract, the typed result shape every -//! provider normalizes into, and the mapping to public `web_search_call` -//! output items. [`args`] parses the model's arguments; provider modules such -//! as [`you`] shape requests and map responses. +//! mapping to public `web_search_call` output items. [`provider`] defines the +//! private provider contract and normalized result types. [`args`] parses the model's arguments; provider modules +//! ([`you`], [`brave`]) shape requests and map responses. pub(crate) mod args; +pub(crate) mod brave; +mod provider; pub(crate) mod you; use std::collections::HashMap; @@ -22,12 +23,16 @@ use serde_json::Value; use tokio::sync::Semaphore; use self::args::{MAX_WEB_SEARCH_QUERIES, WebSearchArguments}; +use self::brave::BraveSearchProvider; +use self::provider::{ + ApiKey, WebSearchProvider, WebSearchProviderMetadata, WebSearchProviderResponse, WebSearchResult, clean_base_url, +}; use self::you::{YOU_API_BASE_URL, YOU_API_KEY, YouSearchProvider}; use super::handler::MAX_GATEWAY_TOOL_OUTPUT_BYTES; use super::handler::{GatewayExecutor, GatewayToolEventPlan, ToolError, ToolHandler, ToolOutput}; use super::ownership::GatewayBinding; use super::registry::{ToolEntry, ToolType}; -use crate::config::{DEFAULT_MAX_CONCURRENT_GATEWAY_CALLS, WebSearchProviderKind}; +use crate::config::{DEFAULT_MAX_CONCURRENT_GATEWAY_CALLS, WebSearchProviderConfig, WebSearchProviderKind}; use crate::types::io::output::{FunctionToolCall, WebSearchCall, WebSearchCallStatus, WebSearchSource}; use crate::types::io::{FunctionTool, OutputItem}; use crate::types::tools::WebSearchToolParam; @@ -191,6 +196,42 @@ impl WebSearchHandler { Self::with_provider_and_query_concurrency(provider, effective) } + /// Builds the handler for the provider selected in `config`. + /// + /// The query ceiling is the smallest of the gateway-wide limit, the + /// operator's `max_concurrent_queries` override, and the provider's own + /// [`WebSearchProvider::max_concurrent_requests`] ceiling. + #[must_use] + pub fn from_config( + client: Arc, + config: &WebSearchProviderConfig, + max_concurrent_gateway_calls: NonZeroUsize, + ) -> Self { + let requested = config + .max_concurrent_queries + .map_or(max_concurrent_gateway_calls, |ceiling| { + ceiling.min(max_concurrent_gateway_calls) + }); + let provider: Arc = match config.provider { + WebSearchProviderKind::You => Arc::new(YouSearchProvider::from_values( + client, + config.api_key.clone(), + config.base_url.clone(), + )), + WebSearchProviderKind::Brave => Arc::new(BraveSearchProvider::from_values( + client, + config.api_key.clone(), + config.base_url.clone(), + config + .max_concurrent_queries + .or(WebSearchProviderKind::Brave.default_max_concurrent_queries()) + .unwrap_or(max_concurrent_gateway_calls), + )), + }; + let effective = effective_query_concurrency(provider.as_ref(), requested); + Self::with_provider_and_query_concurrency(provider, effective) + } + #[must_use] pub fn with_api_key(client: Arc, api_key: String, base_url: &str) -> Self { Self::with_provider_and_query_concurrency( @@ -301,86 +342,6 @@ fn effective_query_concurrency(provider: &dyn WebSearchProvider, requested: NonZ .map_or(requested, |ceiling| requested.min(ceiling)) } -/// A search backend behind `web_search`. -/// -/// Implementations shape one provider request per query and normalize the -/// response into [`WebSearchProviderResponse`]; the handler owns fan-out, -/// concurrency, and the model-facing output shape. -pub(crate) trait WebSearchProvider: std::fmt::Debug + Send + Sync { - fn search<'a>( - &'a self, - query: &'a str, - args: &'a WebSearchArguments, - config: &'a WebSearchToolParam, - ) -> Pin> + Send + 'a>>; - - /// Provider-imposed ceiling on concurrent requests, if any. The handler - /// never schedules more queries at once than this allows. - fn max_concurrent_requests(&self) -> Option { - None - } -} - -/// One normalized search hit. Serialized field names are the model-facing -/// contract and reuse You.com's wire names; `url` is empty when the provider -/// omitted it, and empty/`None` fields are not serialized. Fields outside this -/// struct (cosmetic `thumbnail_url` / `favicon_url`, unknown keys) are dropped. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct WebSearchResult { - #[serde(default, skip_serializing_if = "String::is_empty")] - pub url: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default, deserialize_with = "null_as_default", skip_serializing_if = "Vec::is_empty")] - pub snippets: Vec, - /// Kept as `page_age` (You.com's wire name) so existing model-facing output - /// is unchanged; a provider-neutral name is deferred to the first provider - /// that needs it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub page_age: Option, - /// Live-crawled page body, present when the provider fetched the page. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub contents: Option, -} - -/// Live-crawled page body in the formats the provider returned. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct WebSearchPageContents { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub html: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub markdown: Option, - #[serde(default, deserialize_with = "null_as_default", skip_serializing_if = "Vec::is_empty")] - pub highlights: Vec, -} - -/// Per-query provider metadata echoed to the model as `metadata[]`. -/// -/// `provider` is available to the gateway but not serialized, so `metadata[]` -/// keeps the You.com shape (`query`, `search_uuid`, `latency`) that existing -/// consumers see; exposing the provider name is #291 open question Q5. -#[derive(Debug, Clone, PartialEq, Serialize)] -pub(crate) struct WebSearchProviderMetadata { - #[serde(skip)] - pub provider: WebSearchProviderKind, - pub query: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub search_uuid: Option, - /// Provider-reported latency in seconds. - #[serde(skip_serializing_if = "Option::is_none")] - pub latency: Option, -} - -/// Normalized response for a single query. -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct WebSearchProviderResponse { - pub web: Vec, - pub news: Vec, - pub metadata: WebSearchProviderMetadata, -} - #[derive(Debug, Default, Serialize)] struct WebSearchResultSections { web: Vec, @@ -813,6 +774,40 @@ mod tests { assert!(format!("{handler:?}").contains("YouSearchProvider")); } + #[test] + fn from_config_selects_provider_and_resolves_query_ceiling() { + let gateway_limit = NonZeroUsize::new(5).expect("nonzero test limit"); + let client = Arc::new(reqwest::Client::new()); + + // You.com inherits the gateway limit unless the operator lowers it. + let you = WebSearchProviderConfig::new(Some("k".to_owned()), Some("https://you.example".to_owned())); + let handler = WebSearchHandler::from_config(Arc::clone(&client), &you, gateway_limit); + assert!(format!("{handler:?}").contains("YouSearchProvider")); + assert_eq!(handler.max_concurrent_queries.get(), 5); + let lowered = you.clone().with_max_concurrent_queries(NonZeroUsize::new(2)); + let handler = WebSearchHandler::from_config(Arc::clone(&client), &lowered, gateway_limit); + assert_eq!(handler.max_concurrent_queries.get(), 2); + let raised = you.with_max_concurrent_queries(NonZeroUsize::new(9)); + let handler = WebSearchHandler::from_config(Arc::clone(&client), &raised, gateway_limit); + assert_eq!( + handler.max_concurrent_queries.get(), + 5, + "gateway limit still caps the override" + ); + + // Brave defaults to serial queries; the override can raise the ceiling. + let brave = + WebSearchProviderConfig::new(Some("k".to_owned()), None).with_provider(WebSearchProviderKind::Brave); + let handler = WebSearchHandler::from_config(Arc::clone(&client), &brave, gateway_limit); + assert!(format!("{handler:?}").contains("BraveSearchProvider")); + assert!(!format!("{handler:?}").contains("k\"")); + assert_eq!(handler.max_concurrent_queries.get(), 1); + assert_eq!(handler.query_permits.available_permits(), 1); + let raised = brave.with_max_concurrent_queries(NonZeroUsize::new(3)); + let handler = WebSearchHandler::from_config(client, &raised, gateway_limit); + assert_eq!(handler.max_concurrent_queries.get(), 3); + } + #[test] fn from_values_does_not_leak_api_key_in_debug_output() { let handler = WebSearchHandler::from_values( diff --git a/crates/agentic-server-core/src/tool/web_search/provider.rs b/crates/agentic-server-core/src/tool/web_search/provider.rs new file mode 100644 index 00000000..fc307dc0 --- /dev/null +++ b/crates/agentic-server-core/src/tool/web_search/provider.rs @@ -0,0 +1,111 @@ +//! Shared search-provider contract and normalized result types. + +use std::fmt; +use std::future::Future; +use std::num::NonZeroUsize; +use std::pin::Pin; + +use serde::{Deserialize, Serialize}; + +use super::args::WebSearchArguments; +use super::null_as_default; +use crate::config::WebSearchProviderKind; +use crate::tool::handler::ToolError; +use crate::types::tools::WebSearchToolParam; + +/// Provider credential whose `Debug` output never contains the secret. +#[derive(Clone)] +pub(crate) struct ApiKey(pub String); + +impl fmt::Debug for ApiKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ApiKey()") + } +} + +/// Trims a configured base URL and its trailing slashes; blank counts as unset. +pub(crate) fn clean_base_url(value: &str) -> Option { + let trimmed = value.trim().trim_end_matches('/'); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + +/// A search backend behind `web_search`. +/// +/// Implementations shape one provider request per query and normalize the +/// response into [`WebSearchProviderResponse`]; the handler owns fan-out, +/// concurrency, and the model-facing output shape. +pub(crate) trait WebSearchProvider: std::fmt::Debug + Send + Sync { + fn search<'a>( + &'a self, + query: &'a str, + args: &'a WebSearchArguments, + config: &'a WebSearchToolParam, + ) -> Pin> + Send + 'a>>; + + /// Provider-imposed ceiling on concurrent requests, if any. The handler + /// never schedules more queries at once than this allows. + fn max_concurrent_requests(&self) -> Option { + None + } +} + +/// One normalized search hit. Serialized field names are the model-facing +/// contract and reuse You.com's wire names; `url` is empty when the provider +/// omitted it, and empty/`None` fields are not serialized. Fields outside this +/// struct (cosmetic `thumbnail_url` / `favicon_url`, unknown keys) are dropped. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct WebSearchResult { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, deserialize_with = "null_as_default", skip_serializing_if = "Vec::is_empty")] + pub snippets: Vec, + /// Kept as `page_age` (You.com's wire name) so existing model-facing output + /// is unchanged; a provider-neutral name is deferred to the first provider + /// that needs it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page_age: Option, + /// Live-crawled page body, present when the provider fetched the page. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub contents: Option, +} + +/// Live-crawled page body in the formats the provider returned. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct WebSearchPageContents { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub html: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub markdown: Option, + #[serde(default, deserialize_with = "null_as_default", skip_serializing_if = "Vec::is_empty")] + pub highlights: Vec, +} + +/// Per-query provider metadata echoed to the model as `metadata[]`. +/// +/// `provider` is serialized for every backend except the default You.com, so +/// existing consumers and recordings keep the exact You.com shape (`query`, +/// `search_uuid`, `latency`) while alternative providers are visible to the +/// model (#291 Q5). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct WebSearchProviderMetadata { + #[serde(skip_serializing_if = "WebSearchProviderKind::is_you")] + pub provider: WebSearchProviderKind, + pub query: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub search_uuid: Option, + /// Provider-reported latency in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub latency: Option, +} + +/// Normalized response for a single query. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct WebSearchProviderResponse { + pub web: Vec, + pub news: Vec, + pub metadata: WebSearchProviderMetadata, +} diff --git a/crates/agentic-server-core/src/tool/web_search/you.rs b/crates/agentic-server-core/src/tool/web_search/you.rs index 969a9625..86a026f4 100644 --- a/crates/agentic-server-core/src/tool/web_search/you.rs +++ b/crates/agentic-server-core/src/tool/web_search/you.rs @@ -3,7 +3,6 @@ //! Owns request shaping against You.com's `GET /v1/search` and the mapping of //! its JSON envelope onto the provider-neutral [`WebSearchProviderResponse`]. -use std::fmt; use std::future::Future; use std::pin::Pin; use std::sync::Arc; @@ -12,8 +11,8 @@ use serde::Deserialize; use super::args::{Freshness, WebSearchArguments, clean_string, clean_vec, validate_count}; use super::{ - WebSearchProvider, WebSearchProviderMetadata, WebSearchProviderResponse, WebSearchResult, null_as_default, - read_response_limited, + ApiKey, WebSearchProvider, WebSearchProviderMetadata, WebSearchProviderResponse, WebSearchResult, clean_base_url, + null_as_default, read_response_limited, }; use crate::config::WebSearchProviderKind; use crate::tool::handler::ToolError; @@ -22,16 +21,6 @@ use crate::types::tools::{WebSearchContextSize, WebSearchToolParam}; pub(crate) const YOU_API_KEY: &str = WebSearchProviderKind::You.default_api_key_env(); pub(crate) const YOU_API_BASE_URL: &str = "YOU_API_BASE_URL"; -/// Provider credential whose `Debug` output never contains the secret. -#[derive(Clone)] -pub(crate) struct ApiKey(pub String); - -impl fmt::Debug for ApiKey { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("ApiKey()") - } -} - #[derive(Debug, Clone)] pub(crate) struct YouSearchProvider { client: Arc, @@ -230,11 +219,6 @@ fn validate_crawl_timeout(timeout: u16) -> Result { } } -fn clean_base_url(value: &str) -> Option { - let trimmed = value.trim().trim_end_matches('/'); - (!trimmed.is_empty()).then(|| trimmed.to_owned()) -} - /// You.com's `GET /v1/search` response envelope. Result items deserialize /// straight into [`WebSearchResult`]; cosmetic fields (`thumbnail_url`, /// `original_thumbnail_url`, `favicon_url`) are not modeled and therefore diff --git a/crates/agentic-server-core/tests/support/mod.rs b/crates/agentic-server-core/tests/support/mod.rs index 75b04a01..e5d74865 100644 --- a/crates/agentic-server-core/tests/support/mod.rs +++ b/crates/agentic-server-core/tests/support/mod.rs @@ -567,3 +567,30 @@ pub fn output_text(payload: &ResponsePayload) -> String { }) .collect::() } + +/// Decodes a request's query string into a JSON object; numeric values become +/// numbers and repeated keys become arrays, so tests can assert on it directly. +pub fn query_params_as_json(uri: &axum::http::Uri) -> serde_json::Value { + let mut params = serde_json::Map::new(); + for (key, value) in url::form_urlencoded::parse(uri.query().unwrap_or_default().as_bytes()) { + let value = if let Ok(number) = value.parse::() { + serde_json::Value::from(number) + } else { + serde_json::Value::String(value.into_owned()) + }; + let key = key.into_owned(); + match params.remove(&key) { + None => { + params.insert(key, value); + } + Some(serde_json::Value::Array(mut values)) => { + values.push(value); + params.insert(key, serde_json::Value::Array(values)); + } + Some(previous) => { + params.insert(key, serde_json::Value::Array(vec![previous, value])); + } + } + } + serde_json::Value::Object(params) +} diff --git a/crates/agentic-server-core/tests/web_search_brave_test.rs b/crates/agentic-server-core/tests/web_search_brave_test.rs new file mode 100644 index 00000000..43a9f0b2 --- /dev/null +++ b/crates/agentic-server-core/tests/web_search_brave_test.rs @@ -0,0 +1,554 @@ +//! Brave Search provider behavior against a local Axum mock (#294). +//! +//! Every test binds its own `127.0.0.1:0` listener; nothing here reaches the +//! network. Mock handlers use `try_send` so a slow test never blocks inside +//! the server task. + +use std::num::NonZeroUsize; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use agentic_core::config::{WebSearchProviderConfig, WebSearchProviderKind}; +use agentic_core::tool::{GatewayExecutor, WebSearchHandler}; +use agentic_core::types::event::MessageStatus; +use agentic_core::types::io::OutputItem; +use agentic_core::types::io::output::{FunctionToolCall, WebSearchCallStatus}; +use agentic_core::types::tools::{WebSearchFilters, WebSearchToolParam}; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode, Uri}; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::{Json, Router}; +use tokio::net::TcpListener; +use tokio::sync::mpsc; + +mod support; + +const BRAVE_SEARCH_PATH: &str = "/res/v1/web/search"; +const GATEWAY_LIMIT: NonZeroUsize = NonZeroUsize::new(5).expect("nonzero gateway limit"); + +#[derive(Debug)] +struct CapturedBraveRequest { + subscription_token: String, + accept: Option, + accept_encoding: Option, + params: serde_json::Value, +} + +#[derive(Clone)] +struct MockBrave { + tx: mpsc::Sender, + status: StatusCode, + headers: Vec<(&'static str, &'static str)>, + body: serde_json::Value, +} + +fn capture(headers: &HeaderMap, uri: &Uri) -> CapturedBraveRequest { + let header = |name: &str| { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }; + CapturedBraveRequest { + subscription_token: header("x-subscription-token").unwrap_or_default(), + accept: header("accept"), + accept_encoding: header("accept-encoding"), + params: support::query_params_as_json(uri), + } +} + +async fn spawn_mock_brave( + status: StatusCode, + headers: Vec<(&'static str, &'static str)>, + body: serde_json::Value, +) -> ( + String, + mpsc::Receiver, + tokio::task::JoinHandle<()>, +) { + let (tx, rx) = mpsc::channel(16); + let app = Router::new() + .route( + BRAVE_SEARCH_PATH, + get( + |State(mock): State, headers: HeaderMap, uri: Uri| async move { + mock.tx + .try_send(capture(&headers, &uri)) + .expect("test channel has capacity"); + let mut response = (mock.status, Json(mock.body.clone())).into_response(); + for (name, value) in mock.headers { + response.headers_mut().insert(name, value.parse().unwrap()); + } + response + }, + ), + ) + .with_state(MockBrave { + tx, + status, + headers, + body, + }); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), rx, handle) +} + +fn brave_handler(base_url: &str, max_concurrent_queries: Option) -> WebSearchHandler { + let config = WebSearchProviderConfig::new(Some("secret-brave-key".to_owned()), Some(base_url.to_owned())) + .with_provider(WebSearchProviderKind::Brave) + .with_max_concurrent_queries(max_concurrent_queries); + WebSearchHandler::from_config(Arc::new(reqwest::Client::new()), &config, GATEWAY_LIMIT) +} + +fn mixed_response() -> serde_json::Value { + serde_json::json!({ + "type": "search", + "query": {"original": "rust async", "show_strict_warning": false}, + "mixed": {"type": "mixed", "main": [{"type": "web", "index": 0, "all": false}]}, + "web": { + "type": "search", + "family_friendly": true, + "results": [ + { + "title": "Rust async guide", + "url": "https://example.com/rust", + "description": "A useful guide", + "page_age": "2026-09-01T10:00:00", + "age": "2 weeks ago", + "language": "en", + "family_friendly": true, + "extra_snippets": ["Use async carefully."], + "thumbnail": {"src": "https://imgs.search.brave.com/x", "original": "https://example.com/x.png"}, + "meta_url": {"scheme": "https", "netloc": "example.com", "hostname": "example.com"} + }, + { + "title": "Tokio", + "url": "https://docs.example.org/tokio", + "description": "Runtime", + "profile": {"name": "Example", "url": "https://docs.example.org"} + } + ] + }, + "news": { + "type": "news", + "results": [ + { + "title": "Async release", + "url": "https://news.example.com/async-release", + "description": "Released today", + "age": "3 hours ago", + "source": "Example News", + "breaking": false + } + ] + } + }) +} + +fn call(arguments: &str) -> FunctionToolCall { + FunctionToolCall { + id: "fc_brave".to_owned(), + call_id: "call_brave".to_owned(), + name: "web_search".to_owned(), + namespace: None, + arguments: arguments.to_owned(), + status: MessageStatus::Completed, + } +} + +#[tokio::test] +async fn brave_handler_maps_web_and_news_results_and_public_sources() { + let (base_url, mut captured, _handle) = spawn_mock_brave(StatusCode::OK, Vec::new(), mixed_response()).await; + let handler = brave_handler(&base_url, None); + let params = WebSearchToolParam::default(); + let arguments = r#"{"query":"rust async","count":50,"freshness":"week","country":"gb","language":"en-GB","safesearch":"moderate","livecrawl":"web"}"#; + + let output = handler + .execute("call_brave", "web_search", arguments, ¶ms) + .await + .unwrap(); + + let request = captured.recv().await.expect("mock Brave should receive the request"); + assert_eq!(request.subscription_token, "secret-brave-key"); + assert_eq!(request.accept.as_deref(), Some("application/json")); + assert_eq!( + request.accept_encoding, None, + "core reqwest has no gzip support, so Accept-Encoding must never be sent" + ); + assert_eq!( + request.params, + serde_json::json!({ + "q": "rust async", + "result_filter": "web,news", + "text_decorations": "false", + "count": 20, + "freshness": "pw", + "country": "GB", + "search_lang": "en-gb", + "safesearch": "moderate" + }), + "count is clamped to 20, freshness uses Brave syntax, You.com-only arguments are dropped" + ); + + assert_eq!(output.call_id, "call_brave"); + assert_eq!( + output.output, + concat!( + r#"{"query":"rust async","queries":["rust async"],"#, + r#""results":{"web":["#, + r#"{"url":"https://example.com/rust","title":"Rust async guide","description":"A useful guide","#, + r#""snippets":["Use async carefully."],"page_age":"2026-09-01T10:00:00"},"#, + r#"{"url":"https://docs.example.org/tokio","title":"Tokio","description":"Runtime"}],"#, + r#""news":[{"url":"https://news.example.com/async-release","title":"Async release","#, + r#""description":"Released today","page_age":"3 hours ago"}]},"#, + r#""metadata":[{"provider":"brave","query":"rust async"}]}"# + ) + ); + + let public = handler + .public_output(&call(arguments), &output, WebSearchCallStatus::Completed, ¶ms) + .expect("web_search_call public output"); + assert_eq!( + serde_json::to_value(&public).unwrap(), + serde_json::json!({ + "id": "ws_brave", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "rust async", + "queries": ["rust async"], + "sources": [ + {"url": "https://example.com/rust", "title": "Rust async guide"}, + {"url": "https://docs.example.org/tokio", "title": "Tokio"}, + {"url": "https://news.example.com/async-release", "title": "Async release"} + ] + } + }) + ); +} + +#[tokio::test] +async fn brave_handler_returns_empty_sections_without_error() { + let (base_url, _captured, _handle) = spawn_mock_brave( + StatusCode::OK, + Vec::new(), + serde_json::json!({"type": "search", "query": {"original": "nothing"}}), + ) + .await; + let handler = brave_handler(&base_url, None); + + let output = handler + .execute( + "call_brave", + "web_search", + r#"{"query":"nothing"}"#, + &WebSearchToolParam::default(), + ) + .await + .unwrap(); + + let output_json: serde_json::Value = serde_json::from_str(&output.output).unwrap(); + assert_eq!(output_json["results"], serde_json::json!({"web": [], "news": []})); + assert_eq!( + output_json["metadata"], + serde_json::json!([{"provider": "brave", "query": "nothing"}]) + ); + let public = handler + .public_output( + &call(r#"{"query":"nothing"}"#), + &output, + WebSearchCallStatus::Completed, + &WebSearchToolParam::default(), + ) + .unwrap(); + let OutputItem::WebSearchCall(item) = public else { + panic!("expected web_search_call"); + }; + let action = serde_json::to_value(&item).unwrap()["action"].clone(); + assert!( + action["sources"].as_array().is_none_or(Vec::is_empty), + "empty results must not invent sources: {action}" + ); +} + +#[tokio::test] +async fn brave_handler_applies_domain_filters_client_side() { + let (base_url, mut captured, _handle) = spawn_mock_brave(StatusCode::OK, Vec::new(), mixed_response()).await; + let handler = brave_handler(&base_url, None); + + // Tool-level allowlist wins over the model's arguments and is enforced + // locally: Brave never sees a domain parameter. + let params = WebSearchToolParam { + filters: Some(WebSearchFilters { + allowed_domains: Some(vec!["Example.com".to_owned()]), + blocked_domains: None, + }), + ..WebSearchToolParam::default() + }; + let output = handler + .execute( + "call_brave", + "web_search", + r#"{"query":"rust async","include_domains":["example.org"]}"#, + ¶ms, + ) + .await + .unwrap(); + let request = captured.recv().await.unwrap(); + assert!( + request + .params + .as_object() + .unwrap() + .keys() + .all(|key| !key.contains("domain")), + "{:?}", + request.params + ); + let output_json: serde_json::Value = serde_json::from_str(&output.output).unwrap(); + assert_eq!( + output_json["results"]["web"], + serde_json::json!([{ + "url": "https://example.com/rust", + "title": "Rust async guide", + "description": "A useful guide", + "snippets": ["Use async carefully."], + "page_age": "2026-09-01T10:00:00" + }]) + ); + assert_eq!( + output_json["results"]["news"][0]["url"], "https://news.example.com/async-release", + "subdomains of an allowed domain match" + ); + + // A blocklist from the model's arguments removes matching hosts only. + let output = handler + .execute( + "call_brave", + "web_search", + r#"{"query":"rust async","exclude_domains":["example.com"]}"#, + &WebSearchToolParam::default(), + ) + .await + .unwrap(); + let output_json: serde_json::Value = serde_json::from_str(&output.output).unwrap(); + assert_eq!( + output_json["results"]["web"], + serde_json::json!([{"url": "https://docs.example.org/tokio", "title": "Tokio", "description": "Runtime"}]) + ); + assert_eq!(output_json["results"]["news"], serde_json::json!([])); +} + +#[tokio::test] +async fn brave_handler_reports_rejected_credentials_without_leaking_them() { + for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] { + let (base_url, mut captured, _handle) = spawn_mock_brave( + status, + Vec::new(), + serde_json::json!({"type": "ErrorResponse", "error": {"status": status.as_u16(), "detail": "secret-brave-key is invalid"}}), + ) + .await; + let handler = brave_handler(&base_url, None); + + let error = handler + .execute( + "call_brave", + "web_search", + r#"{"query":"rust async"}"#, + &WebSearchToolParam::default(), + ) + .await + .unwrap_err(); + + assert_eq!(captured.recv().await.unwrap().subscription_token, "secret-brave-key"); + let message = error.to_string(); + assert_eq!( + message, + format!("execution failed: Brave Search rejected the API key ({status}); check BRAVE_API_KEY") + ); + assert!(!message.contains("secret-brave-key")); + } +} + +#[tokio::test] +async fn brave_handler_surfaces_rate_limits_without_retrying() { + let (base_url, mut captured, _handle) = spawn_mock_brave( + StatusCode::TOO_MANY_REQUESTS, + vec![("retry-after", "7"), ("x-ratelimit-reset", "7, 1234")], + serde_json::json!({"type": "ErrorResponse", "error": {"status": 429, "detail": "Rate limit exceeded"}}), + ) + .await; + let handler = brave_handler(&base_url, None); + + let error = handler + .execute( + "call_brave", + "web_search", + r#"{"query":"rust async"}"#, + &WebSearchToolParam::default(), + ) + .await + .unwrap_err(); + + assert_eq!( + error.to_string(), + "execution failed: Brave Search rate limited the request (429 Too Many Requests); \ + the gateway does not retry; retry after 7" + ); + captured.recv().await.expect("one request"); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!( + captured.try_recv().is_err(), + "a 429 must not trigger an automatic retry" + ); +} + +#[tokio::test] +async fn brave_handler_falls_back_to_rate_limit_reset_header() { + let (base_url, _captured, _handle) = spawn_mock_brave( + StatusCode::TOO_MANY_REQUESTS, + vec![("x-ratelimit-reset", "3")], + serde_json::json!({}), + ) + .await; + let handler = brave_handler(&base_url, None); + + let error = handler + .execute( + "call_brave", + "web_search", + r#"{"query":"q"}"#, + &WebSearchToolParam::default(), + ) + .await + .unwrap_err(); + assert!(error.to_string().ends_with("retry after 3"), "{error}"); +} + +#[tokio::test] +async fn brave_handler_reports_other_upstream_failures_with_status() { + let (base_url, _captured, _handle) = spawn_mock_brave( + StatusCode::UNPROCESSABLE_ENTITY, + Vec::new(), + serde_json::json!({"error": {"detail": "invalid search_lang"}}), + ) + .await; + let handler = brave_handler(&base_url, None); + + let error = handler + .execute( + "call_brave", + "web_search", + r#"{"query":"q"}"#, + &WebSearchToolParam::default(), + ) + .await + .unwrap_err(); + assert_eq!( + error.to_string(), + r#"execution failed: Brave Search returned 422 Unprocessable Entity: {"error":{"detail":"invalid search_lang"}}"# + ); +} + +/// Mock that records the peak number of in-flight requests. +async fn spawn_concurrency_tracking_brave() -> (String, Arc, tokio::task::JoinHandle<()>) { + let active = Arc::new(AtomicUsize::new(0)); + let max_active = Arc::new(AtomicUsize::new(0)); + let app = Router::new() + .route( + BRAVE_SEARCH_PATH, + get( + |State((active, max_active)): State<(Arc, Arc)>, uri: Uri| async move { + let now = active.fetch_add(1, Ordering::SeqCst) + 1; + max_active.fetch_max(now, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(30)).await; + active.fetch_sub(1, Ordering::SeqCst); + let query = support::query_params_as_json(&uri)["q"] + .as_str() + .unwrap_or("unknown") + .to_owned(); + Json(serde_json::json!({ + "web": {"results": [{"url": format!("https://example.com/{}", query.replace(' ', "-")), "title": query}]} + })) + }, + ), + ) + .with_state((active, Arc::clone(&max_active))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), max_active, handle) +} + +#[tokio::test] +async fn brave_handler_serializes_batched_queries_by_default() { + let (base_url, max_active, _handle) = spawn_concurrency_tracking_brave().await; + let handler = brave_handler(&base_url, None); + + let output = handler + .execute( + "call_brave", + "web_search", + r#"{"queries":["one","two","three","four","five"]}"#, + &WebSearchToolParam::default(), + ) + .await + .unwrap(); + + let output_json: serde_json::Value = serde_json::from_str(&output.output).unwrap(); + assert_eq!(output_json["results"]["web"].as_array().unwrap().len(), 5); + assert_eq!(output_json["metadata"].as_array().unwrap().len(), 5); + assert_eq!( + max_active.load(Ordering::SeqCst), + 1, + "Brave defaults to one in-flight request even though the gateway allows 5" + ); +} + +#[tokio::test] +async fn brave_handler_honors_a_raised_concurrency_override() { + let (base_url, max_active, _handle) = spawn_concurrency_tracking_brave().await; + let handler = brave_handler(&base_url, NonZeroUsize::new(3)); + + handler + .execute( + "call_brave", + "web_search", + r#"{"queries":["one","two","three","four","five"]}"#, + &WebSearchToolParam::default(), + ) + .await + .unwrap(); + + let peak = max_active.load(Ordering::SeqCst); + assert!( + (2..=3).contains(&peak), + "peak concurrency {peak} should reflect the override of 3" + ); +} + +#[tokio::test] +async fn brave_handler_preserves_http_date_retry_after() { + let retry_after = "Wed, 21 Oct 2026 07:28:00 GMT"; + let (base_url, _captured, _handle) = spawn_mock_brave( + StatusCode::TOO_MANY_REQUESTS, + vec![("retry-after", retry_after)], + serde_json::json!({}), + ) + .await; + let error = brave_handler(&base_url, None) + .execute( + "call_brave", + "web_search", + r#"{"query":"q"}"#, + &WebSearchToolParam::default(), + ) + .await + .unwrap_err(); + assert!(error.to_string().ends_with(retry_after), "{error}"); +} diff --git a/crates/agentic-server-core/tests/web_search_tool_test.rs b/crates/agentic-server-core/tests/web_search_tool_test.rs index d54e156d..bcc701a7 100644 --- a/crates/agentic-server-core/tests/web_search_tool_test.rs +++ b/crates/agentic-server-core/tests/web_search_tool_test.rs @@ -309,7 +309,7 @@ async fn spawn_mock_you_with_response( .and_then(|value| value.to_str().ok()) .unwrap_or_default() .to_owned(); - let body = query_params_as_json(&uri); + let body = support::query_params_as_json(&uri); tx.send(CapturedSearchRequest { api_key, body }).await.unwrap(); (status, Json(response_body.clone())) }, @@ -347,7 +347,7 @@ async fn spawn_mock_you_waiting_for_two_searches() -> ( .and_then(|value| value.to_str().ok()) .unwrap_or_default() .to_owned(); - let body = query_params_as_json(&uri); + let body = support::query_params_as_json(&uri); tx.send(CapturedSearchRequest { api_key, body: body.clone(), @@ -387,31 +387,6 @@ async fn spawn_mock_you_waiting_for_two_searches() -> ( (format!("http://{addr}"), rx, handle) } -fn query_params_as_json(uri: &Uri) -> serde_json::Value { - let mut params = serde_json::Map::new(); - for (key, value) in url::form_urlencoded::parse(uri.query().unwrap_or_default().as_bytes()) { - let value = if let Ok(number) = value.parse::() { - serde_json::Value::from(number) - } else { - serde_json::Value::String(value.into_owned()) - }; - let key = key.into_owned(); - match params.remove(&key) { - None => { - params.insert(key, value); - } - Some(serde_json::Value::Array(mut values)) => { - values.push(value); - params.insert(key, serde_json::Value::Array(values)); - } - Some(previous) => { - params.insert(key, serde_json::Value::Array(vec![previous, value])); - } - } - } - serde_json::Value::Object(params) -} - #[tokio::test] async fn web_search_handler_gets_query_params_from_you_and_formats_results() { let (base_url, mut captured, _handle) = spawn_mock_you().await; diff --git a/crates/agentic-server/src/config_file.rs b/crates/agentic-server/src/config_file.rs index c260f6af..abcb91b7 100644 --- a/crates/agentic-server/src/config_file.rs +++ b/crates/agentic-server/src/config_file.rs @@ -4,22 +4,33 @@ use std::num::NonZeroUsize; use std::path::Path; use agentic_core::McpServerEntry; -use agentic_core::config::CONFIG_FILE_NAME; +use agentic_core::config::{CONFIG_FILE_NAME, WebSearchProviderKind}; use agentic_core::error::Error; use serde::{Deserialize, Serialize}; #[derive(Debug, Default, Deserialize, Serialize)] #[serde(default, deny_unknown_fields)] pub(crate) struct WebSearchFileConfig { + /// Search backend (`you` or `brave`); unset selects You.com. + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, #[serde(skip_serializing_if = "Option::is_none")] pub base_url: Option, + /// Environment variable holding the provider's API key; unset uses the + /// provider's conventional variable (`YOU_API_KEY`, `BRAVE_API_KEY`). #[serde(skip_serializing_if = "Option::is_none")] pub api_key_env: Option, + /// Ceiling on concurrent provider requests within one batched search. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_concurrent_queries: Option, } impl WebSearchFileConfig { fn is_empty(&self) -> bool { - self.base_url.is_none() && self.api_key_env.is_none() + self.provider.is_none() + && self.base_url.is_none() + && self.api_key_env.is_none() + && self.max_concurrent_queries.is_none() } } @@ -247,11 +258,12 @@ impl FileConfig { #[cfg(test)] mod tests { use std::fs; + use std::num::NonZeroUsize; use agentic_core::McpServerEntry; use tempfile::tempdir; - use super::{FileConfig, McpFileConfig, ServerFileConfig, WebSearchFileConfig}; + use super::{FileConfig, McpFileConfig, ServerFileConfig, WebSearchFileConfig, WebSearchProviderKind}; #[test] fn missing_config_file_uses_defaults() { @@ -267,8 +279,10 @@ mod tests { let defaults = FileConfig { llm_api_base: Some("http://127.0.0.1:5050".to_owned()), web_search: WebSearchFileConfig { + provider: Some(WebSearchProviderKind::You), base_url: Some("https://api.ydc-index.io".to_owned()), api_key_env: Some("YOU_API_KEY".to_owned()), + max_concurrent_queries: None, }, mcp: McpFileConfig { allowed_hosts: vec!["mcp.example.com".to_owned()], @@ -285,7 +299,9 @@ mod tests { assert_eq!(config.llm_api_base.as_deref(), Some("http://127.0.0.1:5050")); assert!(contents.contains("llm_api_base = \"http://127.0.0.1:5050\"")); assert!(contents.contains("[web_search]")); + assert!(contents.contains("provider = \"you\"")); assert!(contents.contains("api_key_env = \"YOU_API_KEY\"")); + assert!(!contents.contains("max_concurrent_queries")); assert!(contents.contains("allowed_hosts = [\"mcp.example.com\"]")); assert!(contents.contains("[server]")); assert!(contents.contains("max_request_body_size_bytes = 20971520")); @@ -315,7 +331,9 @@ mod tests { assert_eq!(config.llm_api_base.as_deref(), Some("http://127.0.0.1:8000/v1")); assert_eq!(config.database_url.as_deref(), Some("sqlite:///tmp/agentic.db")); + assert_eq!(config.web_search.provider, None); assert_eq!(config.web_search.api_key_env.as_deref(), Some("YOU_API_KEY")); + assert_eq!(config.web_search.max_concurrent_queries, None); assert_eq!(config.mcp.allowed_hosts, vec!["mcp.example.com"]); assert!(matches!(config.mcp_servers["remote"], McpServerEntry::Http { .. })); assert_eq!( @@ -411,6 +429,38 @@ mod tests { ); } + #[test] + fn web_search_provider_settings_round_trip_and_reject_invalid_values() { + let home = tempdir().expect("temp home"); + fs::write( + home.path().join("config.toml"), + "[web_search]\nprovider = \"brave\"\napi_key_env = \"MY_BRAVE_KEY\"\nmax_concurrent_queries = 2\n", + ) + .expect("write config"); + let config = FileConfig::load(home.path()) + .expect("load config") + .expect("existing config"); + assert_eq!(config.web_search.provider, Some(WebSearchProviderKind::Brave)); + assert_eq!(config.web_search.api_key_env.as_deref(), Some("MY_BRAVE_KEY")); + assert_eq!(config.web_search.base_url, None); + assert_eq!(config.web_search.max_concurrent_queries.map(NonZeroUsize::get), Some(2)); + let rendered = toml::to_string(&config).expect("serialize config"); + assert!(rendered.contains("provider = \"brave\"")); + assert!(rendered.contains("max_concurrent_queries = 2")); + + fs::write(home.path().join("config.toml"), "[web_search]\nprovider = \"bing\"\n").expect("write config"); + let error = FileConfig::load(home.path()).expect_err("unknown provider must fail"); + assert!(error.to_string().contains("provider"), "{error}"); + + fs::write( + home.path().join("config.toml"), + "[web_search]\nmax_concurrent_queries = 0\n", + ) + .expect("write config"); + let error = FileConfig::load(home.path()).expect_err("zero concurrency must fail"); + assert!(error.to_string().contains("max_concurrent_queries"), "{error}"); + } + #[test] fn rejects_empty_api_key_environment_name() { let home = tempdir().expect("temp home"); diff --git a/crates/agentic-server/src/main.rs b/crates/agentic-server/src/main.rs index 008845e6..c2edf022 100644 --- a/crates/agentic-server/src/main.rs +++ b/crates/agentic-server/src/main.rs @@ -11,7 +11,7 @@ use agentic_core::config::{ DEFAULT_POSTGRES_MAX_LIFETIME_SECONDS, DEFAULT_POSTGRES_MIGRATION_TIMEOUT_SECONDS, DEFAULT_POSTGRES_STATEMENT_TIMEOUT_SECONDS, DEFAULT_SQLITE_JOURNAL_SIZE_LIMIT_BYTES, DEFAULT_SQLITE_MAX_CONNECTIONS, DEFAULT_SQLITE_MMAP_SIZE_BYTES, PostgresConfig, SqliteConfig, SqliteTempStore, - ToolRuntimeConfig, WebSearchProviderConfig, default_database_url, ensure_agentic_api_home, normalize_base_url, + ToolRuntimeConfig, default_database_url, ensure_agentic_api_home, normalize_base_url, }; use agentic_core::error::Error; use agentic_server::app::DEFAULT_MAX_REQUEST_BODY_SIZE; @@ -19,11 +19,11 @@ use agentic_server::auth::OidcConfig; mod config_file; mod server; +mod web_search_config; -use config_file::{ - FileConfig, McpFileConfig, MessagesGatewayFileConfig, ServerFileConfig, ToolsFileConfig, WebSearchFileConfig, -}; +use config_file::{FileConfig, McpFileConfig, MessagesGatewayFileConfig, ServerFileConfig, ToolsFileConfig}; use server::GatewayOptions; +use web_search_config::{generated_web_search_file_config, resolve_web_search_config}; /// Environment override for the serialized request-size ceiling. const MAX_REQUEST_BODY_SIZE_ENV: &str = "AGENTIC_MAX_REQUEST_BODY_SIZE_BYTES"; @@ -303,8 +303,7 @@ fn build_config(llm_api_base: String, common: &CommonArgs, file: &FileConfig) -> .or_else(|| file.database_url.clone()) .map_or_else(default_database_url, Ok)?; let (postgres, sqlite) = database_configs_from_env(&db_url)?; - let web_search_api_key = file.web_search.api_key_env.as_deref().and_then(environment_value); - let web_search_base_url = environment_value("YOU_API_BASE_URL").or_else(|| file.web_search.base_url.clone()); + let web_search = resolve_web_search_config(&file.web_search, environment_value)?; let mcp_allowed_hosts = environment_value("AGENTIC_MCP_ALLOWED_HOSTS") .map_or_else(|| file.mcp.allowed_hosts.clone(), |value| parse_comma_separated(&value)); let max_concurrent_gateway_calls_default = file @@ -325,7 +324,7 @@ fn build_config(llm_api_base: String, common: &CommonArgs, file: &FileConfig) -> postgres, sqlite, tools: ToolRuntimeConfig { - web_search: WebSearchProviderConfig::new(web_search_api_key, web_search_base_url), + web_search, mcp_servers: file.mcp_servers.clone(), mcp_allowed_hosts, messages_gateway_tool_aliases: file.messages_gateway.tool_aliases.clone(), @@ -353,10 +352,7 @@ fn gateway_options<'a>( fn generated_file_config(llm_api_base: String) -> FileConfig { FileConfig { llm_api_base: Some(llm_api_base), - web_search: WebSearchFileConfig { - base_url: environment_value("YOU_API_BASE_URL"), - api_key_env: Some("YOU_API_KEY".to_owned()), - }, + web_search: generated_web_search_file_config(environment_value), mcp: McpFileConfig { allowed_hosts: environment_value("AGENTIC_MCP_ALLOWED_HOSTS") .map_or_else(Vec::new, |value| parse_comma_separated(&value)), diff --git a/crates/agentic-server/src/web_search_config.rs b/crates/agentic-server/src/web_search_config.rs new file mode 100644 index 00000000..9c7ec766 --- /dev/null +++ b/crates/agentic-server/src/web_search_config.rs @@ -0,0 +1,248 @@ +//! Deployment configuration for the selected web-search provider. + +use std::num::NonZeroUsize; + +use agentic_core::config::{WebSearchProviderConfig, WebSearchProviderKind}; +use agentic_core::error::Error; + +use crate::config_file::WebSearchFileConfig; + +/// Environment override for the `web_search` backend (`you` or `brave`). +const WEB_SEARCH_PROVIDER_ENV: &str = "AGENTIC_WEB_SEARCH_PROVIDER"; +/// Provider-neutral environment override for the `web_search` endpoint. +const WEB_SEARCH_BASE_URL_ENV: &str = "AGENTIC_WEB_SEARCH_BASE_URL"; +/// Legacy You.com endpoint override, honored only when You.com is selected. +const YOU_API_BASE_URL_ENV: &str = "YOU_API_BASE_URL"; +/// Environment override for the concurrent-query ceiling of one batched search. +const WEB_SEARCH_MAX_CONCURRENT_QUERIES_ENV: &str = "AGENTIC_WEB_SEARCH_MAX_CONCURRENT_QUERIES"; + +/// Resolves the `web_search` provider settings as environment variable > +/// configuration file > provider default. +/// +/// The provider comes from `AGENTIC_WEB_SEARCH_PROVIDER` or `[web_search] +/// provider`. The API key is read from the variable named by `api_key_env`, +/// else the provider's conventional variable. The endpoint prefers +/// `AGENTIC_WEB_SEARCH_BASE_URL`, then `YOU_API_BASE_URL` (You.com only), then +/// the file, then the provider default. `max_concurrent_queries` is left unset +/// so the provider's own default applies. +pub(crate) fn resolve_web_search_config( + file: &WebSearchFileConfig, + env: impl Fn(&str) -> Option, +) -> Result { + let provider = match env(WEB_SEARCH_PROVIDER_ENV) { + Some(value) => value + .parse::() + .map_err(|error| Error::Config(format!("{WEB_SEARCH_PROVIDER_ENV}: {error}")))?, + None => file.provider.unwrap_or_default(), + }; + let api_key_env = file + .api_key_env + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| provider.default_api_key_env()); + let api_key = env(api_key_env); + let base_url = env(WEB_SEARCH_BASE_URL_ENV) + .or_else(|| provider.is_you().then(|| env(YOU_API_BASE_URL_ENV)).flatten()) + .or_else(|| file.base_url.clone()) + .or_else(|| provider.default_base_url().map(str::to_owned)); + let max_concurrent_queries = match env(WEB_SEARCH_MAX_CONCURRENT_QUERIES_ENV) { + Some(value) => Some(value.parse::().map_err(|error| { + Error::Config(format!( + "{WEB_SEARCH_MAX_CONCURRENT_QUERIES_ENV} must be a positive integer: {error}" + )) + })?), + None => file.max_concurrent_queries, + }; + Ok(WebSearchProviderConfig::new(api_key, base_url) + .with_provider(provider) + .with_max_concurrent_queries(max_concurrent_queries)) +} + +/// Seeds `[web_search]` in a generated configuration file from the current +/// environment. Credentials stay unpinned so changing providers selects the +/// corresponding default key variable; a malformed +/// provider value is ignored here and rejected at startup by +/// [`resolve_web_search_config`]. +pub(crate) fn generated_web_search_file_config(env: impl Fn(&str) -> Option) -> WebSearchFileConfig { + let provider = env(WEB_SEARCH_PROVIDER_ENV) + .and_then(|value| value.parse::().ok()) + .unwrap_or_default(); + WebSearchFileConfig { + provider: Some(provider), + base_url: env(WEB_SEARCH_BASE_URL_ENV) + .or_else(|| provider.is_you().then(|| env(YOU_API_BASE_URL_ENV)).flatten()), + api_key_env: None, + max_concurrent_queries: env(WEB_SEARCH_MAX_CONCURRENT_QUERIES_ENV) + .and_then(|value| value.parse::().ok()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Environment lookup over a fixed set of variables, mirroring `environment_value`. + fn env_from<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |name| { + pairs + .iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + } + } + + #[test] + fn web_search_config_defaults_to_you_with_legacy_variables() { + let file = WebSearchFileConfig { + base_url: Some("https://file.example".to_owned()), + ..WebSearchFileConfig::default() + }; + let config = resolve_web_search_config( + &file, + env_from(&[ + ("YOU_API_KEY", "you-secret"), + ("YOU_API_BASE_URL", "https://you.example"), + ]), + ) + .expect("resolve you.com"); + assert_eq!(config.provider, WebSearchProviderKind::You); + assert_eq!(config.api_key.as_deref(), Some("you-secret")); + assert_eq!(config.base_url.as_deref(), Some("https://you.example")); + assert_eq!(config.max_concurrent_queries, None); + + // Without any endpoint variable the file value is used; You.com has no default. + let config = resolve_web_search_config(&file, env_from(&[])).expect("resolve from file"); + assert_eq!(config.base_url.as_deref(), Some("https://file.example")); + assert_eq!(config.api_key, None); + let config = resolve_web_search_config(&WebSearchFileConfig::default(), env_from(&[])).expect("resolve empty"); + assert_eq!(config.base_url, None); + } + + #[test] + fn web_search_config_selects_brave_from_environment_or_file() { + let config = resolve_web_search_config( + &WebSearchFileConfig::default(), + env_from(&[ + ("AGENTIC_WEB_SEARCH_PROVIDER", "Brave"), + ("BRAVE_API_KEY", "brave-secret"), + ("YOU_API_KEY", "you-secret"), + ("YOU_API_BASE_URL", "https://you.example"), + ]), + ) + .expect("resolve brave"); + assert_eq!(config.provider, WebSearchProviderKind::Brave); + assert_eq!(config.api_key.as_deref(), Some("brave-secret")); + assert_eq!( + config.base_url.as_deref(), + Some("https://api.search.brave.com"), + "YOU_API_BASE_URL must not leak into the Brave endpoint" + ); + + let file = WebSearchFileConfig { + provider: Some(WebSearchProviderKind::Brave), + api_key_env: Some("MY_BRAVE_KEY".to_owned()), + max_concurrent_queries: NonZeroUsize::new(2), + ..WebSearchFileConfig::default() + }; + let config = resolve_web_search_config(&file, env_from(&[("MY_BRAVE_KEY", "custom-secret")])) + .expect("resolve brave from file"); + assert_eq!(config.provider, WebSearchProviderKind::Brave); + assert_eq!(config.api_key.as_deref(), Some("custom-secret")); + assert_eq!(config.max_concurrent_queries.map(NonZeroUsize::get), Some(2)); + + // The environment variable wins over the file for the provider itself. + let config = resolve_web_search_config(&file, env_from(&[("AGENTIC_WEB_SEARCH_PROVIDER", "you")])) + .expect("resolve override"); + assert_eq!(config.provider, WebSearchProviderKind::You); + } + + #[test] + fn web_search_config_applies_environment_precedence_for_endpoint_and_concurrency() { + let file = WebSearchFileConfig { + base_url: Some("https://file.example".to_owned()), + max_concurrent_queries: NonZeroUsize::new(2), + ..WebSearchFileConfig::default() + }; + let config = resolve_web_search_config( + &file, + env_from(&[ + ("AGENTIC_WEB_SEARCH_BASE_URL", "https://generic.example"), + ("YOU_API_BASE_URL", "https://legacy.example"), + ("AGENTIC_WEB_SEARCH_MAX_CONCURRENT_QUERIES", "4"), + ]), + ) + .expect("resolve overrides"); + assert_eq!(config.base_url.as_deref(), Some("https://generic.example")); + assert_eq!(config.max_concurrent_queries.map(NonZeroUsize::get), Some(4)); + + let config = resolve_web_search_config(&file, env_from(&[("YOU_API_BASE_URL", "https://legacy.example")])) + .expect("legacy endpoint"); + assert_eq!(config.base_url.as_deref(), Some("https://legacy.example")); + assert_eq!(config.max_concurrent_queries.map(NonZeroUsize::get), Some(2)); + } + + #[test] + fn web_search_config_rejects_invalid_environment_values() { + let error = resolve_web_search_config( + &WebSearchFileConfig::default(), + env_from(&[("AGENTIC_WEB_SEARCH_PROVIDER", "bing")]), + ) + .expect_err("unknown provider"); + assert_eq!( + error.to_string(), + "AGENTIC_WEB_SEARCH_PROVIDER: unknown web_search provider \"bing\"; expected one of: you, brave" + ); + + let error = resolve_web_search_config( + &WebSearchFileConfig::default(), + env_from(&[("AGENTIC_WEB_SEARCH_MAX_CONCURRENT_QUERIES", "0")]), + ) + .expect_err("zero concurrency"); + assert!( + error + .to_string() + .starts_with("AGENTIC_WEB_SEARCH_MAX_CONCURRENT_QUERIES must be a positive integer"), + "{error}" + ); + } + + #[test] + fn generated_web_search_config_documents_the_selected_provider() { + let generated = generated_web_search_file_config(env_from(&[("YOU_API_BASE_URL", "https://you.example")])); + assert_eq!(generated.provider, Some(WebSearchProviderKind::You)); + assert_eq!(generated.api_key_env, None); + assert_eq!(generated.base_url.as_deref(), Some("https://you.example")); + assert_eq!(generated.max_concurrent_queries, None); + + let generated = generated_web_search_file_config(env_from(&[ + ("AGENTIC_WEB_SEARCH_PROVIDER", "brave"), + ("YOU_API_BASE_URL", "https://you.example"), + ("AGENTIC_WEB_SEARCH_MAX_CONCURRENT_QUERIES", "3"), + ])); + assert_eq!(generated.provider, Some(WebSearchProviderKind::Brave)); + assert_eq!(generated.api_key_env, None); + assert_eq!( + generated.base_url, None, + "the Brave default endpoint is not pinned into the file" + ); + assert_eq!(generated.max_concurrent_queries.map(NonZeroUsize::get), Some(3)); + + let generated = generated_web_search_file_config(env_from(&[("AGENTIC_WEB_SEARCH_PROVIDER", "bing")])); + assert_eq!(generated.provider, Some(WebSearchProviderKind::You)); + } + + #[test] + fn generated_web_search_config_can_switch_provider_without_pinning_credentials() { + for (initial, next, key) in [("you", "brave", "BRAVE_API_KEY"), ("brave", "you", "YOU_API_KEY")] { + let generated = generated_web_search_file_config(env_from(&[("AGENTIC_WEB_SEARCH_PROVIDER", initial)])); + let config = resolve_web_search_config( + &generated, + env_from(&[("AGENTIC_WEB_SEARCH_PROVIDER", next), (key, "selected-secret")]), + ) + .expect("resolve switched provider"); + assert_eq!(config.api_key.as_deref(), Some("selected-secret")); + } + } +} diff --git a/docs/deploying/README.md b/docs/deploying/README.md index 7c5b248c..5b26fecf 100644 --- a/docs/deploying/README.md +++ b/docs/deploying/README.md @@ -388,7 +388,7 @@ its data directory lives on the PersistentVolumeClaim. ## Optional web search To enable the gateway-executed `web_search` built-in tool, add the provider settings -to the Deployment’s container environment: +to the Deployment’s container environment. The default provider is You.com: ```yaml - name: YOU_API_KEY @@ -410,6 +410,25 @@ kubectl create secret generic agentic-api-secrets \ --from-literal=you-api-key="$YOU_API_KEY" ``` +To use Brave Search instead, select the provider and supply its key; the endpoint +defaults to `https://api.search.brave.com`, and batched queries run one at a time +unless `AGENTIC_WEB_SEARCH_MAX_CONCURRENT_QUERIES` raises the ceiling for a paid plan: + +```yaml + - name: AGENTIC_WEB_SEARCH_PROVIDER + value: brave + - name: BRAVE_API_KEY + valueFrom: + secretKeyRef: + name: agentic-api-secrets + key: brave-api-key +``` + +```console +kubectl create secret generic agentic-api-secrets \ + --from-literal=brave-api-key="$BRAVE_API_KEY" +``` + Do not commit API keys to the manifest or source tree. ## Optional: deploy with llm-d diff --git a/docs/deploying/kubernetes.md b/docs/deploying/kubernetes.md index 6ebcd2b3..8d25e983 100644 --- a/docs/deploying/kubernetes.md +++ b/docs/deploying/kubernetes.md @@ -416,8 +416,12 @@ for a saturated database or inference service. ## Enable and verify web search -The `web_search_preview` built-in tool is executed by Agentic API when `YOU_API_KEY` and `YOU_API_BASE_URL` are -configured. Keep the key in a Secret and use the current You.com Search API base URL, `https://ydc-index.io`. +The `web_search_preview` built-in tool is executed by Agentic API against a configured search provider. With the +default You.com provider it is enabled when `YOU_API_KEY` and `YOU_API_BASE_URL` are configured. Keep the key in a +Secret and use the current You.com Search API base URL, `https://ydc-index.io`. To use Brave Search instead, set +`AGENTIC_WEB_SEARCH_PROVIDER=brave` in the ConfigMap and store `BRAVE_API_KEY` in the Secret; no base URL is needed. +The Brave free plan is rate limited to roughly one request per second, so the gateway runs batched queries serially by +default; raise `AGENTIC_WEB_SEARCH_MAX_CONCURRENT_QUERIES` only on a paid plan. Create the Secret from a protected environment file so the key does not enter shell history or process arguments: @@ -430,8 +434,9 @@ kubectl --namespace agentic-api create secret generic agentic-api-web-search \ kubectl apply --server-side --field-manager=agentic-api-operator --filename=- ``` -The file contains one line, `YOU_API_KEY=...`. Remove it securely after creating the Secret. Patch the environment in -the production overlay: +The file contains one line, `YOU_API_KEY=...` (or `BRAVE_API_KEY=...` for Brave Search). Remove it securely after +creating the Secret. Patch the environment in the production overlay (for Brave, replace the `YOU_API_BASE_URL` +operation with `path: /data/AGENTIC_WEB_SEARCH_PROVIDER`, `value: brave`): ```yaml patches: @@ -487,4 +492,6 @@ curl --fail --silent --show-error http://127.0.0.1:9000/v1/responses \ Remove the `Authorization` header when inbound OIDC validation is disabled. A top-level response can have `status: "completed"` even when an individual `web_search_call` failed, so inspect the output item status rather than only the response status. A `403 Forbidden` from You.com means the external search request was rejected; confirm the -documented base URL and refresh the Secret, restart the Deployment, and test again without printing the key. +documented base URL and refresh the Secret, restart the Deployment, and test again without printing the key. A failed +`web_search_call` naming `BRAVE_API_KEY` means Brave rejected the key; one reporting `429` means the Brave plan's rate +limit was hit, and the gateway does not retry it.