From 0b6b9e35a766dedda30509f74b44dee2b350e2e7 Mon Sep 17 00:00:00 2001 From: Zheng Lu Date: Fri, 18 Sep 2026 01:15:05 +0100 Subject: [PATCH] feat(tool): add Tavily as a selectable web_search provider (#327) Add Tavily as the third backend for the gateway-owned web_search tool (Phase 3 of #291). Each query is one typed POST /search with a bearer token; domain filters are forwarded natively and re-checked client-side, count is clamped to 20, freshness maps to time_range or a date range widened by one day on each side because Tavily's bounds are exclusive, language keeps Tavily's documented compound tags and otherwise reduces to the primary subtag, and 401/403/429/432/433 fail the web_search_call without retry or credential leakage. Move the shared response helpers into provider.rs to keep mod.rs within the file-size policy, and make WebSearchProviderKind::ALL a static slice so new providers do not change its type. Signed-off-by: Zheng Lu --- ARCHITECTURE.md | 4 +- CHANGELOG.md | 21 + README.md | 50 +- crates/agentic-server-core/src/config.rs | 34 +- .../src/tool/web_search/mod.rs | 50 +- .../src/tool/web_search/provider.rs | 41 +- .../src/tool/web_search/tavily.rs | 748 ++++++++++++++++++ .../tests/web_search_tavily_test.rs | 556 +++++++++++++ crates/agentic-server/src/config_file.rs | 20 +- .../agentic-server/src/web_search_config.rs | 50 +- docs/deploying/README.md | 18 + docs/deploying/kubernetes.md | 16 +- 12 files changed, 1539 insertions(+), 69 deletions(-) create mode 100644 crates/agentic-server-core/src/tool/web_search/tavily.rs create mode 100644 crates/agentic-server-core/tests/web_search_tavily_test.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 43787759..bba26c6a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -951,8 +951,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 the configured `WebSearchProvider` in `web_search/you.rs` - or `web_search/brave.rs`) and `mcp/handler.rs` (`McpHandler`, backed + (`WebSearchHandler`, backed by the configured `WebSearchProvider` in `web_search/you.rs`, + `web_search/brave.rs`, or `web_search/tavily.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 53ba7b6e..849a7fcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,22 @@ All notable changes to Agentic API are documented here. `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 Tavily as a selectable backend for the gateway-owned `web_search` tool (#327, Phase 3 of #291). Select it + with `AGENTIC_WEB_SEARCH_PROVIDER=tavily` or `[web_search] provider = "tavily"` and supply `TAVILY_API_KEY`; the + endpoint defaults to `https://api.tavily.com` and can be overridden with `AGENTIC_WEB_SEARCH_BASE_URL` or + `[web_search] base_url`. Each query is one `POST /search` with a JSON body and a bearer token; the key is never + placed in the body. `allowed_domains` / `blocked_domains` and the model's `include_domains` / `exclude_domains` are + forwarded to Tavily's native `include_domains` / `exclude_domains` and re-checked client-side, `count` is clamped + to Tavily's maximum of 20, `freshness` maps to `time_range` or to `start_date` / `end_date` widened by one day on + each side because Tavily's bounds are exclusive, `language` keeps Tavily's documented compound tags (`zh-cn`) and + otherwise reduces to its primary subtag, `safesearch` maps to the boolean `safe_search`, and `country` plus the + You.com-specific + `livecrawl`, `livecrawl_formats`, `crawl_timeout`, and `boost_domains` arguments are ignored. Results fill + `results.web` with `published_date` as `page_age`; `results.news` stays empty because a second news search per + query would double credit usage. Rejected credentials, HTTP 429, and Tavily's 432/433 plan-limit statuses 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 Tavily `metadata[]` entry carries `"provider": "tavily"`, Tavily's `request_id` as + `search_uuid`, and its `response_time` as `latency`. Tavily inherits the gateway concurrency limit. - 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 @@ -45,6 +61,11 @@ All notable changes to Agentic API are documented here. 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. +- `WebSearchProviderKind` gains a `Tavily` variant; `WebSearchProviderKind::ALL` is now a `&'static [Self]` slice + listing all three providers, so adding a provider no longer changes its type; and + `WebSearchHandler::from_config` builds the Tavily provider for it (#327). The shared `null_as_default` and + `read_response_limited` helpers moved from `tool/web_search/mod.rs` to `tool/web_search/provider.rs`; both were and + remain crate-private, so no public API changed. ### Fixed diff --git a/README.md b/README.md index 5567f928..e8bbe480 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) or [Brave Search](https://brave.com/search/api/), 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), [Brave Search](https://brave.com/search/api/), or [Tavily](https://tavily.com), 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. @@ -193,6 +193,13 @@ AGENTIC_WEB_SEARCH_PROVIDER=brave BRAVE_API_KEY= \ cargo run -p agentic-server -- --llm-api-base http://0.0.0.0:5050 ``` +Or [Tavily](https://tavily.com), a search API built for LLM agents with native domain filtering: + +```bash +AGENTIC_WEB_SEARCH_PROVIDER=tavily TAVILY_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. @@ -226,12 +233,12 @@ 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". +# Search backend for the gateway-owned web_search tool: "you" (default), "brave", or "tavily". 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). +# the provider default (Brave: 1; You.com and Tavily: max_concurrent_gateway_calls). # max_concurrent_queries = 1 [mcp] @@ -304,9 +311,9 @@ 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` | +| API key | variable named by `api_key_env` | `[web_search] api_key_env` | `YOU_API_KEY` / `BRAVE_API_KEY` / `TAVILY_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; `https://api.tavily.com` for Tavily | +| Concurrent queries | `AGENTIC_WEB_SEARCH_MAX_CONCURRENT_QUERIES` | `[web_search] max_concurrent_queries` | You.com and Tavily inherit `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 @@ -328,14 +335,36 @@ the shared tool contract to Brave: 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 +**Tavily** (`provider = "tavily"`) needs only `TAVILY_API_KEY`; its [plans](https://tavily.com) are metered in +credits, and each query costs one credit. Requests are `POST` bodies against `https://api.tavily.com/search` with the +key sent as a bearer token. The gateway adapts the shared tool contract to Tavily: + +- `allowed_domains` / `blocked_domains` (and the model's `include_domains` / `exclude_domains`) are forwarded to + Tavily's native `include_domains` / `exclude_domains` and re-checked by the gateway on the response as defense in + depth, so a filtered search can return fewer than `count` results. +- `count` is clamped to Tavily's maximum of 20 (`max_results`); `freshness` maps to `time_range` (`day`, `week`, + `month`, `year`) or to a `start_date` / `end_date` pair widened by one day on each side, since Tavily's bounds are + exclusive and the gateway's range is inclusive; `language` keeps Tavily's documented compound tags (`zh-CN` → + `zh-cn`) and otherwise reduces to its primary subtag (`en-GB` → `en`); `safesearch` becomes Tavily's boolean + `safe_search` (anything but `off` enables it). +- Every query is one `topic: "general"` search, so all hits land in `results.web` and `results.news` is always empty; + a second news search per query would double the credits spent. `page_age` carries Tavily's `published_date`. +- `country` is ignored (Tavily expects full country names rather than ISO codes), as are the You.com-specific + arguments above (all logged at debug level). +- Each per-query `metadata[]` entry carries `"provider": "tavily"` plus Tavily's `request_id` as `search_uuid` and + its `response_time` as `latency`. +- Batched queries inherit the gateway concurrency limit. A rate-limited request (HTTP 429) fails that + `web_search_call` without an automatic retry and reports the upstream `Retry-After` value; Tavily's plan-limit + statuses (432, 433) fail the same way without a retry. + +If you switch an existing deployment to Brave or Tavily, 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" +provider = "tavily" +api_key_env = "TAVILY_API_KEY" ``` Restrict the file to the service account (for example, `chmod 600 ~/.agentic-api/config.toml`), especially if you add @@ -437,7 +466,8 @@ Claude Code's own tools (Bash, Edit, Read, …) stay **client-owned** — Claude 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 -(You.com or Brave Search, see [Web search providers](#web-search-providers)); no MCP server or tool alias is required: +(You.com, Brave Search, or Tavily, 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 fbfa1b87..7c96e81f 100644 --- a/crates/agentic-server-core/src/config.rs +++ b/crates/agentic-server-core/src/config.rs @@ -101,11 +101,12 @@ pub enum WebSearchProviderKind { #[default] You, Brave, + Tavily, } impl WebSearchProviderKind { /// Every selectable provider, in the order operator-facing messages list them. - pub const ALL: [Self; 2] = [Self::You, Self::Brave]; + pub const ALL: &'static [Self] = &[Self::You, Self::Brave, Self::Tavily]; /// Environment variable that conventionally carries this provider's API key. #[must_use] @@ -113,6 +114,7 @@ impl WebSearchProviderKind { match self { Self::You => "YOU_API_KEY", Self::Brave => "BRAVE_API_KEY", + Self::Tavily => "TAVILY_API_KEY", } } @@ -124,16 +126,18 @@ impl WebSearchProviderKind { match self { Self::You => None, Self::Brave => Some("https://api.search.brave.com"), + Self::Tavily => Some("https://api.tavily.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. + /// request per second, so it defaults to serial queries; Tavily's plans + /// are metered per minute, so it inherits the gateway limit. #[must_use] pub const fn default_max_concurrent_queries(self) -> Option { match self { - Self::You => None, + Self::You | Self::Tavily => None, Self::Brave => Some(DEFAULT_BRAVE_MAX_CONCURRENT_QUERIES), } } @@ -144,15 +148,17 @@ impl WebSearchProviderKind { match self { Self::You => "You.com", Self::Brave => "Brave Search", + Self::Tavily => "Tavily", } } - /// Configuration label (`you`, `brave`) matching the serialized form. + /// Configuration label (`you`, `brave`, `tavily`) matching the serialized form. #[must_use] pub const fn config_name(self) -> &'static str { match self { Self::You => "you", Self::Brave => "brave", + Self::Tavily => "tavily", } } @@ -171,7 +177,8 @@ impl std::str::FromStr for WebSearchProviderKind { fn from_str(value: &str) -> Result { let trimmed = value.trim(); Self::ALL - .into_iter() + .iter() + .copied() .find(|kind| kind.config_name().eq_ignore_ascii_case(trimmed)) .ok_or_else(|| { let expected = Self::ALL @@ -450,6 +457,15 @@ mod tests { NonZeroUsize::new(1) ); assert!(!WebSearchProviderKind::Brave.is_you()); + + assert_eq!(WebSearchProviderKind::Tavily.to_string(), "Tavily"); + assert_eq!(WebSearchProviderKind::Tavily.default_api_key_env(), "TAVILY_API_KEY"); + assert_eq!( + WebSearchProviderKind::Tavily.default_base_url(), + Some("https://api.tavily.com") + ); + assert_eq!(WebSearchProviderKind::Tavily.default_max_concurrent_queries(), None); + assert!(!WebSearchProviderKind::Tavily.is_you()); } #[test] @@ -464,10 +480,14 @@ mod tests { "you".parse::().unwrap(), WebSearchProviderKind::You ); + assert_eq!( + " Tavily ".parse::().unwrap(), + WebSearchProviderKind::Tavily + ); let error = "bing".parse::().unwrap_err(); assert_eq!( error.to_string(), - "unknown web_search provider \"bing\"; expected one of: you, brave" + "unknown web_search provider \"bing\"; expected one of: you, brave, tavily" ); assert_eq!( @@ -478,7 +498,7 @@ mod tests { serde_json::from_str::("\"you\"").unwrap(), WebSearchProviderKind::You ); - for kind in WebSearchProviderKind::ALL { + for kind in WebSearchProviderKind::ALL.iter().copied() { assert_eq!(kind.config_name().parse::().unwrap(), kind); } } 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 94ce8d28..96da0095 100644 --- a/crates/agentic-server-core/src/tool/web_search/mod.rs +++ b/crates/agentic-server-core/src/tool/web_search/mod.rs @@ -2,12 +2,13 @@ //! //! `mod.rs` owns the OpenAI-facing adapter: the [`WebSearchHandler`], the //! 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. +//! private provider contract, normalized result types, and the response helpers every provider shares. [`args`] +//! parses the model's arguments; provider modules ([`you`], [`brave`], [`tavily`]) shape requests and map responses. pub(crate) mod args; pub(crate) mod brave; mod provider; +pub(crate) mod tavily; pub(crate) mod you; use std::collections::HashMap; @@ -18,7 +19,7 @@ use std::pin::Pin; use std::sync::Arc; use futures::{StreamExt, TryStreamExt}; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::Serialize; use serde_json::Value; use tokio::sync::Semaphore; @@ -26,7 +27,9 @@ use self::args::{MAX_WEB_SEARCH_QUERIES, WebSearchArguments}; use self::brave::BraveSearchProvider; use self::provider::{ ApiKey, WebSearchProvider, WebSearchProviderMetadata, WebSearchProviderResponse, WebSearchResult, clean_base_url, + null_as_default, read_response_limited, }; +use self::tavily::TavilySearchProvider; 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}; @@ -227,6 +230,15 @@ impl WebSearchHandler { .or(WebSearchProviderKind::Brave.default_max_concurrent_queries()) .unwrap_or(max_concurrent_gateway_calls), )), + WebSearchProviderKind::Tavily => Arc::new(TavilySearchProvider::from_values( + client, + config.api_key.clone(), + config.base_url.clone(), + config + .max_concurrent_queries + .or(WebSearchProviderKind::Tavily.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) @@ -357,38 +369,6 @@ struct WebSearchToolOutput<'a> { metadata: Vec, } -/// Deserializes an explicit JSON `null` as the field's default instead of -/// failing, so a degenerate provider response cannot fail the whole search. -pub(crate) fn null_as_default<'de, D, T>(deserializer: D) -> Result -where - D: Deserializer<'de>, - T: Default + Deserialize<'de>, -{ - Option::::deserialize(deserializer).map(Option::unwrap_or_default) -} - -/// Reads a provider HTTP response body, failing as soon as it exceeds -/// [`MAX_GATEWAY_TOOL_OUTPUT_BYTES`] so an oversized provider reply is never -/// buffered in full. Every provider module reads its responses through here. -pub(super) async fn read_response_limited( - resp: reqwest::Response, - provider: WebSearchProviderKind, -) -> Result { - let mut stream = resp.bytes_stream(); - let mut body = Vec::new(); - while let Some(chunk) = stream.next().await { - let chunk = chunk - .map_err(|error| ToolError::Execution(format!("failed to read {provider} search response: {error}")))?; - if chunk.len() > MAX_GATEWAY_TOOL_OUTPUT_BYTES.saturating_sub(body.len()) { - return Err(ToolError::Execution(format!( - "{provider} search response exceeded {MAX_GATEWAY_TOOL_OUTPUT_BYTES} bytes" - ))); - } - body.extend_from_slice(&chunk); - } - String::from_utf8(body).map_err(|_| ToolError::Execution(format!("{provider} search response was not valid UTF-8"))) -} - impl ToolHandler for WebSearchHandler { type ToolParams = WebSearchToolParam; diff --git a/crates/agentic-server-core/src/tool/web_search/provider.rs b/crates/agentic-server-core/src/tool/web_search/provider.rs index fc307dc0..26c01f24 100644 --- a/crates/agentic-server-core/src/tool/web_search/provider.rs +++ b/crates/agentic-server-core/src/tool/web_search/provider.rs @@ -1,16 +1,17 @@ -//! Shared search-provider contract and normalized result types. +//! Shared search-provider contract, normalized result types, and the +//! response helpers every provider module reads through. use std::fmt; use std::future::Future; use std::num::NonZeroUsize; use std::pin::Pin; -use serde::{Deserialize, Serialize}; +use futures::StreamExt; +use serde::{Deserialize, Deserializer, Serialize}; use super::args::WebSearchArguments; -use super::null_as_default; use crate::config::WebSearchProviderKind; -use crate::tool::handler::ToolError; +use crate::tool::handler::{MAX_GATEWAY_TOOL_OUTPUT_BYTES, ToolError}; use crate::types::tools::WebSearchToolParam; /// Provider credential whose `Debug` output never contains the secret. @@ -109,3 +110,35 @@ pub(crate) struct WebSearchProviderResponse { pub news: Vec, pub metadata: WebSearchProviderMetadata, } + +/// Deserializes an explicit JSON `null` as the field's default instead of +/// failing, so a degenerate provider response cannot fail the whole search. +pub(crate) fn null_as_default<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: Default + Deserialize<'de>, +{ + Option::::deserialize(deserializer).map(Option::unwrap_or_default) +} + +/// Reads a provider HTTP response body, failing as soon as it exceeds +/// [`MAX_GATEWAY_TOOL_OUTPUT_BYTES`] so an oversized provider reply is never +/// buffered in full. Every provider module reads its responses through here. +pub(super) async fn read_response_limited( + resp: reqwest::Response, + provider: WebSearchProviderKind, +) -> Result { + let mut stream = resp.bytes_stream(); + let mut body = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk + .map_err(|error| ToolError::Execution(format!("failed to read {provider} search response: {error}")))?; + if chunk.len() > MAX_GATEWAY_TOOL_OUTPUT_BYTES.saturating_sub(body.len()) { + return Err(ToolError::Execution(format!( + "{provider} search response exceeded {MAX_GATEWAY_TOOL_OUTPUT_BYTES} bytes" + ))); + } + body.extend_from_slice(&chunk); + } + String::from_utf8(body).map_err(|_| ToolError::Execution(format!("{provider} search response was not valid UTF-8"))) +} diff --git a/crates/agentic-server-core/src/tool/web_search/tavily.rs b/crates/agentic-server-core/src/tool/web_search/tavily.rs new file mode 100644 index 00000000..f9634530 --- /dev/null +++ b/crates/agentic-server-core/src/tool/web_search/tavily.rs @@ -0,0 +1,748 @@ +//! Tavily Search API provider for `web_search`. +//! +//! Owns request shaping against Tavily's `POST /search` and the mapping of +//! its JSON envelope onto the provider-neutral [`WebSearchProviderResponse`]. +//! Tavily differs from You.com and Brave in ways the gateway adapts here +//! rather than surfacing to the model: +//! +//! - the request is a JSON body, not query parameters, and the API key is +//! sent only as an `Authorization: Bearer` header, never in the body; +//! - `include_domains` / `exclude_domains` are applied server-side and are +//! still enforced client-side through [`DomainFilter`] as defense in depth; +//! - `max_results` is capped at [`TAVILY_MAX_RESULTS`] and clamped instead of rejected; +//! - `freshness` maps to `time_range` or a `start_date` / `end_date` pair; +//! - one `topic: "general"` request serves each query, so every hit lands in +//! the `web` section and `news` stays empty (a second `news` request would +//! double the credits spent per query); +//! - `country` (Tavily wants full country names, not ISO codes) and the +//! You.com-specific arguments are dropped. +//! +//! `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, Serialize}; + +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 TAVILY_API_KEY: &str = WebSearchProviderKind::Tavily.default_api_key_env(); + +/// Largest `max_results` Tavily accepts per request. +pub(crate) const TAVILY_MAX_RESULTS: u8 = 20; + +const SEARCH_PATH: &str = "/search"; +const DATE_FORMAT: &str = "%Y-%m-%d"; +/// One credit per request; `advanced` costs two and is not exposed. +const SEARCH_DEPTH: &str = "basic"; +const TOPIC: &str = "general"; +/// Tavily's plan-limit statuses, outside the IANA registry. +const PLAN_LIMIT_EXCEEDED: u16 = 432; +const PAYG_LIMIT_EXCEEDED: u16 = 433; + +#[derive(Debug, Clone)] +pub(crate) struct TavilySearchProvider { + client: Arc, + api_key: Option, + base_url: String, + max_concurrent_requests: NonZeroUsize, +} + +impl TavilySearchProvider { + /// 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 Tavily'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::Tavily.default_base_url().map(str::to_owned)) + .unwrap_or_default(); + Self { + client, + api_key, + base_url, + max_concurrent_requests, + } + } +} + +impl WebSearchProvider for TavilySearchProvider { + 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!("{TAVILY_API_KEY} must be set to use the web_search tool")))?; + let request = TavilySearchRequest::from_args_and_config(query, args, config)?; + let body = serde_json::to_vec(&request.body) + .map_err(|e| ToolError::Execution(format!("failed to serialize Tavily search request: {e}")))?; + let resp = self + .client + .post(format!("{}{SEARCH_PATH}", self.base_url)) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .bearer_auth(&api_key.0) + .body(body) + .send() + .await + .map_err(|e| ToolError::Execution(format!("Tavily 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::Tavily).await?; + let response: TavilySearchResponse = serde_json::from_str(&response_text) + .map_err(|e| ToolError::Execution(format!("Tavily search returned invalid JSON: {e}")))?; + Ok(response.into_provider_response(&request.body.query, &request.domain_filter)) + }) + } + + fn max_concurrent_requests(&self) -> Option { + Some(self.max_concurrent_requests) + } +} + +/// Maps a non-2xx Tavily 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` so the caller can back +/// off; Tavily's `432`/`433` plan-limit statuses are reported without retrying. +async fn failure_from_status(resp: reqwest::Response) -> ToolError { + let status = resp.status(); + match status { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => ToolError::Execution(format!( + "Tavily rejected the API key ({status}); check {TAVILY_API_KEY}" + )), + StatusCode::TOO_MANY_REQUESTS => { + let retry_after = resp + .headers() + .get("retry-after") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let hint = retry_after.map_or_else( + || "; no Retry-After header was provided".to_owned(), + |value| format!("; retry after {value}"), + ); + ToolError::Execution(format!( + "Tavily rate limited the request ({status}); the gateway does not retry{hint}" + )) + } + _ if matches!(status.as_u16(), PLAN_LIMIT_EXCEEDED | PAYG_LIMIT_EXCEEDED) => ToolError::Execution(format!( + "Tavily reported the plan usage limit was exceeded ({}); the gateway does not retry", + status.as_u16() + )), + _ => { + let body = read_response_limited(resp, WebSearchProviderKind::Tavily) + .await + .unwrap_or_default(); + ToolError::Execution(format!("Tavily search returned {status}: {body}")) + } + } +} + +/// Tavily's `POST /search` body, derived from the model's arguments and the +/// request-level tool configuration, plus the client-side [`DomainFilter`] +/// that re-checks Tavily's own domain filtering. +#[derive(Debug, PartialEq, Eq)] +struct TavilySearchRequest { + body: TavilySearchBody, + domain_filter: DomainFilter, +} + +/// JSON body sent to Tavily. Field order is the serialized order; `None` and +/// empty lists are omitted so the payload only carries what the model asked for. +#[derive(Debug, PartialEq, Eq, Serialize)] +struct TavilySearchBody { + query: String, + search_depth: &'static str, + topic: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + max_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + time_range: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + start_date: Option, + #[serde(skip_serializing_if = "Option::is_none")] + end_date: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + include_domains: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + exclude_domains: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + language: Option, + #[serde(skip_serializing_if = "Option::is_none")] + safe_search: Option, + /// Requested on every search so `page_age` is populated for the + /// `general` topic too (Tavily enables it automatically only for `news`). + include_published_date: bool, +} + +impl TavilySearchRequest { + fn from_args_and_config( + query: &str, + args: &WebSearchArguments, + config: &WebSearchToolParam, + ) -> Result { + let max_results = args + .count + .or_else(|| { + config + .search_context_size + .map(WebSearchContextSize::default_count) + .map(u16::from) + }) + .map(validate_count) + .transpose()? + .map(clamp_max_results); + 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, config); + let (time_range, start_date, end_date) = args.freshness.map(tavily_freshness).unwrap_or_default(); + let domain_filter = DomainFilter::new(include_domains.as_deref(), exclude_domains.as_deref()); + + Ok(Self { + body: TavilySearchBody { + query: query.trim().to_owned(), + search_depth: SEARCH_DEPTH, + topic: TOPIC, + max_results, + time_range, + start_date, + end_date, + include_domains: include_domains.unwrap_or_default(), + exclude_domains: exclude_domains.unwrap_or_default(), + language: args.language.as_deref().map(tavily_language), + safe_search: args.safesearch.as_deref().map(tavily_safe_search), + include_published_date: true, + }, + domain_filter, + }) + } +} + +/// Tavily accepts at most [`TAVILY_MAX_RESULTS`] results; the model cannot +/// know provider limits, so a larger request is clamped rather than rejected. +fn clamp_max_results(count: u8) -> u8 { + if count > TAVILY_MAX_RESULTS { + tracing::debug!( + requested = count, + max = TAVILY_MAX_RESULTS, + "clamped web_search count to Tavily maximum" + ); + TAVILY_MAX_RESULTS + } else { + count + } +} + +/// `country` is dropped because Tavily expects full lowercase country names +/// rather than the ISO 3166-1 codes the tool contract carries; You.com-specific +/// arguments have no Tavily equivalent and are dropped; `boost_domains` has no +/// filtering semantics, so it is dropped too. +fn log_ignored_arguments(args: &WebSearchArguments, config: &WebSearchToolParam) { + let config_country = config + .user_location + .as_ref() + .is_some_and(|location| clean_string(location.country.as_deref()).is_some()); + let ignored: Vec<&str> = [ + ("country", config_country || args.country.is_some()), + ("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 web_search arguments without a Tavily equivalent"); + } +} + +/// Renders the typed freshness filter as Tavily's `time_range` or as an +/// explicit `start_date` / `end_date` pair. +/// +/// The gateway's `YYYY-MM-DDtoYYYY-MM-DD` range is inclusive, while Tavily +/// documents `start_date` as "results after this date" and `end_date` as +/// "results before this date". Each bound is widened by one day so both +/// boundary dates stay in scope; a date that cannot be widened (the edge of +/// the calendar) is sent as-is. +fn tavily_freshness(freshness: Freshness) -> (Option<&'static str>, Option, Option) { + match freshness { + Freshness::Day => (Some("day"), None, None), + Freshness::Week => (Some("week"), None, None), + Freshness::Month => (Some("month"), None, None), + Freshness::Year => (Some("year"), None, None), + Freshness::Range { from, to } => ( + None, + Some(from.pred_opt().unwrap_or(from).format(DATE_FORMAT).to_string()), + Some(to.succ_opt().unwrap_or(to).format(DATE_FORMAT).to_string()), + ), + } +} + +/// Compound language tags Tavily documents beyond bare ISO 639-1 codes. +const TAVILY_COMPOUND_LANGUAGES: [&str; 1] = ["zh-cn"]; + +/// Tavily takes an ISO 639-1 code plus the compound tags in +/// [`TAVILY_COMPOUND_LANGUAGES`]. A documented compound tag is sent lowercased +/// (`zh-CN` → `zh-cn`); any other BCP 47 tag such as `en-GB` is reduced to its +/// lowercase primary subtag so a regional variant never fails the request. +fn tavily_language(language: &str) -> String { + let normalized = language.replace('_', "-").to_ascii_lowercase(); + if TAVILY_COMPOUND_LANGUAGES.contains(&normalized.as_str()) { + return normalized; + } + normalized.split('-').next().map_or(normalized.clone(), str::to_owned) +} + +/// Tavily's `safe_search` is boolean: anything but an explicit `off` enables it. +fn tavily_safe_search(safesearch: &str) -> bool { + !safesearch.eq_ignore_ascii_case("off") +} + +/// Tavily's `POST /search` envelope. Only `results`, `response_time`, and +/// `request_id` are modeled; `answer`, `images`, `auto_parameters`, `usage`, +/// and unknown keys are ignored so upstream additions never break the provider. +#[derive(Debug, Default, Deserialize)] +struct TavilySearchResponse { + #[serde(default, deserialize_with = "null_as_default")] + results: Vec, + /// Provider-reported latency in seconds. + #[serde(default)] + response_time: Option, + #[serde(default)] + request_id: Option, +} + +/// One Tavily hit. `content` is the cleaned snippet; `raw_content`, `score`, +/// `favicon`, `images`, and `id` are not modeled. +#[derive(Debug, Default, Deserialize)] +struct TavilyResult { + #[serde(default)] + url: String, + #[serde(default)] + title: Option, + #[serde(default)] + content: Option, + #[serde(default)] + published_date: Option, +} + +impl From for WebSearchResult { + fn from(result: TavilyResult) -> Self { + Self { + url: result.url.trim().to_owned(), + title: clean_string(result.title.as_deref()), + description: clean_string(result.content.as_deref()), + snippets: Vec::new(), + page_age: clean_string(result.published_date.as_deref()), + contents: None, + } + } +} + +impl TavilySearchResponse { + fn into_provider_response(self, query: &str, domain_filter: &DomainFilter) -> WebSearchProviderResponse { + let mut web: Vec = self.results.into_iter().map(Into::into).collect(); + domain_filter.retain(&mut web); + WebSearchProviderResponse { + web, + news: Vec::new(), + metadata: WebSearchProviderMetadata { + provider: WebSearchProviderKind::Tavily, + query: query.to_owned(), + search_uuid: clean_string(self.request_id.as_deref()), + latency: self.response_time, + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::tools::{WebSearchFilters, WebSearchUserLocation}; + + fn build_provider(api_key: Option<&str>, base_url: Option<&str>) -> TavilySearchProvider { + TavilySearchProvider::from_values( + Arc::new(reqwest::Client::new()), + api_key.map(str::to_owned), + base_url.map(str::to_owned), + NonZeroUsize::new(5).unwrap(), + ) + } + + fn args(json: &str) -> WebSearchArguments { + WebSearchArguments::from_json(json).unwrap() + } + + fn body_json(request: &TavilySearchRequest) -> serde_json::Value { + serde_json::to_value(&request.body).unwrap() + } + + #[test] + fn provider_debug_is_redacted_and_defaults_base_url() { + let provider = build_provider(Some("tvly-super-secret"), None); + let rendered = format!("{provider:?}"); + assert!(!rendered.contains("tvly-super-secret")); + assert!(rendered.contains("ApiKey()")); + assert_eq!(provider.base_url, "https://api.tavily.com"); + assert_eq!(provider.max_concurrent_requests(), NonZeroUsize::new(5)); + + let provider = build_provider(Some(" "), Some(" https://tavily.example/// ")); + assert!(provider.api_key.is_none()); + assert_eq!(provider.base_url, "https://tavily.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: TAVILY_API_KEY must be set to use the web_search tool" + ); + } + + #[test] + fn request_renders_every_argument_in_tavily_syntax() { + let request = TavilySearchRequest::from_args_and_config( + " rust async ", + &args( + r#"{"query":"rust async","count":7,"freshness":"week","language":"en-GB","safesearch":"strict","exclude_domains":[" Spam.example ", ""]}"#, + ), + &WebSearchToolParam::default(), + ) + .unwrap(); + assert_eq!( + body_json(&request), + serde_json::json!({ + "query": "rust async", + "search_depth": "basic", + "topic": "general", + "max_results": 7, + "time_range": "week", + "exclude_domains": ["Spam.example"], + "language": "en", + "safe_search": true, + "include_published_date": true + }) + ); + assert!(!request.domain_filter.allows("https://spam.example/x")); + assert!(request.domain_filter.allows("https://other.org/x")); + } + + #[test] + fn request_omits_optional_fields_when_unset() { + let request = + TavilySearchRequest::from_args_and_config("q", &args(r#"{"query":"q"}"#), &WebSearchToolParam::default()) + .unwrap(); + assert_eq!( + body_json(&request), + serde_json::json!({ + "query": "q", + "search_depth": "basic", + "topic": "general", + "include_published_date": true + }) + ); + assert!(request.domain_filter.is_empty()); + assert!(!serde_json::to_string(&request.body).unwrap().contains("api_key")); + } + + #[test] + fn request_clamps_count_and_applies_context_size_default() { + let request = TavilySearchRequest::from_args_and_config( + "q", + &args(r#"{"query":"q","count":50}"#), + &WebSearchToolParam::default(), + ) + .unwrap(); + assert_eq!(request.body.max_results, Some(TAVILY_MAX_RESULTS)); + + let request = TavilySearchRequest::from_args_and_config( + "q", + &args(r#"{"query":"q","count":20}"#), + &WebSearchToolParam::default(), + ) + .unwrap(); + assert_eq!(request.body.max_results, Some(20)); + + let config = WebSearchToolParam { + search_context_size: Some(WebSearchContextSize::High), + ..WebSearchToolParam::default() + }; + let request = TavilySearchRequest::from_args_and_config("q", &args(r#"{"query":"q"}"#), &config).unwrap(); + assert_eq!( + request.body.max_results.map(u16::from), + Some(u16::from(WebSearchContextSize::High.default_count())) + ); + + let error = TavilySearchRequest::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_time_range_or_date_bounds() { + assert_eq!(tavily_freshness(Freshness::Day), (Some("day"), None, None)); + assert_eq!(tavily_freshness(Freshness::Week), (Some("week"), None, None)); + assert_eq!(tavily_freshness(Freshness::Month), (Some("month"), None, None)); + assert_eq!(tavily_freshness(Freshness::Year), (Some("year"), None, None)); + // Tavily's bounds are exclusive, so the inclusive range is widened by a day on each side. + let range: Freshness = "2026-01-02to2026-02-03".parse().unwrap(); + assert_eq!( + tavily_freshness(range), + (None, Some("2026-01-01".to_owned()), Some("2026-02-04".to_owned())) + ); + let range: Freshness = "2026-03-01to2026-12-31".parse().unwrap(); + assert_eq!( + tavily_freshness(range), + (None, Some("2026-02-28".to_owned()), Some("2027-01-01".to_owned())) + ); + + let request = TavilySearchRequest::from_args_and_config( + "q", + &args(r#"{"query":"q","freshness":"2026-01-02to2026-02-03"}"#), + &WebSearchToolParam::default(), + ) + .unwrap(); + let body = body_json(&request); + assert_eq!(body["start_date"], "2026-01-01"); + assert_eq!(body["end_date"], "2026-02-04"); + assert!(body.get("time_range").is_none()); + } + + #[test] + fn language_and_safe_search_are_reduced_to_tavily_values() { + assert_eq!(tavily_language("en-GB"), "en"); + assert_eq!(tavily_language("pt_BR"), "pt"); + assert_eq!(tavily_language("FR"), "fr"); + assert_eq!(tavily_language("zh-CN"), "zh-cn", "documented compound tags are kept"); + assert_eq!(tavily_language("zh_cn"), "zh-cn"); + assert_eq!( + tavily_language("zh-TW"), + "zh", + "undocumented regional tags fall back to the primary subtag" + ); + assert!(tavily_safe_search("strict")); + assert!(tavily_safe_search("moderate")); + assert!(!tavily_safe_search("off")); + assert!(!tavily_safe_search("OFF")); + } + + #[test] + fn request_ignores_arguments_without_a_tavily_equivalent() { + let request = TavilySearchRequest::from_args_and_config( + "q", + &args( + r#"{"query":"q","country":"gb","livecrawl":"web","livecrawl_formats":["markdown"],"crawl_timeout":5}"#, + ), + &WebSearchToolParam::default(), + ) + .unwrap(); + let body = body_json(&request); + for key in [ + "country", + "livecrawl", + "livecrawl_formats", + "crawl_timeout", + "boost_domains", + ] { + assert!(body.get(key).is_none(), "{key} must not be forwarded"); + } + } + + #[test] + fn request_prefers_tool_config_filters_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 = TavilySearchRequest::from_args_and_config( + "q", + &args(r#"{"query":"q","include_domains":["example.com"]}"#), + &config, + ) + .unwrap(); + assert_eq!( + body_json(&request)["include_domains"], + serde_json::json!(["rust-lang.org"]) + ); + assert!(body_json(&request).get("country").is_none()); + 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 = TavilySearchRequest::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 = TavilySearchRequest::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_results_and_tolerates_unknown_fields() { + let response: TavilySearchResponse = serde_json::from_str( + r#"{ + "query": "rust", + "answer": null, + "images": [], + "results": [ + { + "title": " Rust ", + "url": " https://www.rust-lang.org/ ", + "content": "A language", + "score": 0.98, + "raw_content": "", + "published_date": "2026-01-02", + "favicon": "https://www.rust-lang.org/favicon.ico", + "id": "res_1" + }, + {"url": "https://example.com/no-title", "title": "", "content": null, "published_date": null} + ], + "auto_parameters": {"topic": "general"}, + "response_time": 1.25, + "request_id": "req_123" + }"#, + ) + .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::new(), + page_age: Some("2026-01-02".to_owned()), + contents: None, + }, + WebSearchResult { + url: "https://example.com/no-title".to_owned(), + ..WebSearchResult::default() + }, + ] + ); + assert!(response.news.is_empty()); + assert_eq!( + response.metadata, + WebSearchProviderMetadata { + provider: WebSearchProviderKind::Tavily, + query: "rust".to_owned(), + search_uuid: Some("req_123".to_owned()), + latency: Some(1.25), + } + ); + assert_eq!( + serde_json::to_string(&response.metadata).unwrap(), + r#"{"provider":"tavily","query":"rust","search_uuid":"req_123","latency":1.25}"# + ); + } + + #[test] + fn response_tolerates_missing_and_null_fields() { + for body in ["{}", r#"{"results":null,"response_time":null,"request_id":null}"#] { + let response: TavilySearchResponse = 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()); + assert_eq!( + serde_json::to_string(&response.metadata).unwrap(), + r#"{"provider":"tavily","query":"q"}"# + ); + } + } + + #[test] + fn response_applies_domain_filter_as_defense_in_depth() { + let response: TavilySearchResponse = serde_json::from_str( + r#"{"results": [ + {"url": "https://docs.example.com/a"}, + {"url": "https://notexample.com/b"}, + {"url": "https://EXAMPLE.COM./c"}, + {"url": "not a url"} + ]}"#, + ) + .unwrap(); + let filter = DomainFilter::new(Some(&["example.com".to_owned()]), None); + let response = response.into_provider_response("q", &filter); + let urls: Vec<_> = response.web.iter().map(|r| r.url.as_str()).collect(); + assert_eq!(urls, ["https://docs.example.com/a", "https://EXAMPLE.COM./c"]); + } +} diff --git a/crates/agentic-server-core/tests/web_search_tavily_test.rs b/crates/agentic-server-core/tests/web_search_tavily_test.rs new file mode 100644 index 00000000..cf07c8e3 --- /dev/null +++ b/crates/agentic-server-core/tests/web_search_tavily_test.rs @@ -0,0 +1,556 @@ +//! Tavily provider behavior against a local Axum mock (#327). +//! +//! 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::body::Bytes; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::post; +use axum::{Json, Router}; +use tokio::net::TcpListener; +use tokio::sync::mpsc; + +mod support; + +const TAVILY_SEARCH_PATH: &str = "/search"; +const GATEWAY_LIMIT: NonZeroUsize = NonZeroUsize::new(5).expect("nonzero gateway limit"); +const SECRET_KEY: &str = "tvly-secret-key"; + +#[derive(Debug)] +struct CapturedTavilyRequest { + authorization: Option, + content_type: Option, + accept: Option, + accept_encoding: Option, + body: serde_json::Value, +} + +#[derive(Clone)] +struct MockTavily { + tx: mpsc::Sender, + status: StatusCode, + headers: Vec<(&'static str, &'static str)>, + body: serde_json::Value, +} + +fn capture(headers: &HeaderMap, body: &Bytes) -> CapturedTavilyRequest { + let header = |name: &str| { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }; + CapturedTavilyRequest { + authorization: header("authorization"), + content_type: header("content-type"), + accept: header("accept"), + accept_encoding: header("accept-encoding"), + body: serde_json::from_slice(body).expect("Tavily request body must be JSON"), + } +} + +async fn spawn_mock_tavily( + 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( + TAVILY_SEARCH_PATH, + post( + |State(mock): State, headers: HeaderMap, body: Bytes| async move { + mock.tx + .try_send(capture(&headers, &body)) + .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(MockTavily { + 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 tavily_handler(base_url: &str, max_concurrent_queries: Option) -> WebSearchHandler { + let config = WebSearchProviderConfig::new(Some(SECRET_KEY.to_owned()), Some(base_url.to_owned())) + .with_provider(WebSearchProviderKind::Tavily) + .with_max_concurrent_queries(max_concurrent_queries); + WebSearchHandler::from_config(Arc::new(reqwest::Client::new()), &config, GATEWAY_LIMIT) +} + +fn search_response() -> serde_json::Value { + serde_json::json!({ + "query": "rust async", + "answer": null, + "images": [], + "results": [ + { + "title": "Rust async guide", + "url": "https://example.com/rust", + "content": "A useful guide", + "score": 0.97, + "raw_content": null, + "published_date": "2026-09-01", + "favicon": "https://example.com/favicon.ico", + "id": "res_1" + }, + { + "title": "Tokio", + "url": "https://docs.example.org/tokio", + "content": "Runtime", + "score": 0.81, + "published_date": null + } + ], + "auto_parameters": {"topic": "general", "search_depth": "basic"}, + "response_time": 1.42, + "request_id": "req_tavily_1" + }) +} + +fn error_body(message: &str) -> serde_json::Value { + serde_json::json!({"detail": {"error": message}}) +} + +fn call(arguments: &str) -> FunctionToolCall { + FunctionToolCall { + id: "fc_tavily".to_owned(), + call_id: "call_tavily".to_owned(), + name: "web_search".to_owned(), + namespace: None, + arguments: arguments.to_owned(), + status: MessageStatus::Completed, + } +} + +async fn execute(handler: &WebSearchHandler, arguments: &str) -> Result { + handler + .execute("call_tavily", "web_search", arguments, &WebSearchToolParam::default()) + .await + .map_err(|error| error.to_string()) +} + +#[tokio::test] +async fn tavily_handler_posts_json_and_maps_results_and_public_sources() { + let (base_url, mut captured, _handle) = spawn_mock_tavily(StatusCode::OK, Vec::new(), search_response()).await; + let handler = tavily_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","exclude_domains":["spam.example"]}"#; + + let output = handler + .execute("call_tavily", "web_search", arguments, ¶ms) + .await + .unwrap(); + + let request = captured.recv().await.expect("mock Tavily should receive the request"); + assert_eq!(request.authorization.as_deref(), Some("Bearer tvly-secret-key")); + assert_eq!(request.content_type.as_deref(), Some("application/json")); + 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.body, + serde_json::json!({ + "query": "rust async", + "search_depth": "basic", + "topic": "general", + "max_results": 20, + "time_range": "week", + "exclude_domains": ["spam.example"], + "language": "en", + "safe_search": true, + "include_published_date": true + }), + "count is clamped to 20, freshness maps to time_range, country and You.com-only arguments are dropped" + ); + assert!( + request.body.get("api_key").is_none(), + "the credential travels only in the Authorization header" + ); + + assert_eq!(output.call_id, "call_tavily"); + 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#""page_age":"2026-09-01"},"#, + r#"{"url":"https://docs.example.org/tokio","title":"Tokio","description":"Runtime"}],"#, + r#""news":[]},"#, + r#""metadata":[{"provider":"tavily","query":"rust async","search_uuid":"req_tavily_1","latency":1.42}]}"# + ) + ); + + 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_tavily", + "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"} + ] + } + }) + ); +} + +#[tokio::test] +async fn tavily_handler_sends_date_bounds_for_a_freshness_range() { + let (base_url, mut captured, _handle) = spawn_mock_tavily(StatusCode::OK, Vec::new(), search_response()).await; + let handler = tavily_handler(&base_url, None); + + execute(&handler, r#"{"query":"rust","freshness":"2026-01-02to2026-02-03"}"#) + .await + .unwrap(); + + let request = captured.recv().await.unwrap(); + assert_eq!( + request.body["start_date"], "2026-01-01", + "Tavily's start_date is exclusive, so the inclusive gateway range is widened by one day" + ); + assert_eq!(request.body["end_date"], "2026-02-04"); + assert!(request.body.get("time_range").is_none()); + assert!( + request.body.get("max_results").is_none(), + "no count means Tavily's default" + ); +} + +#[tokio::test] +async fn tavily_handler_returns_empty_sections_without_error() { + let (base_url, _captured, _handle) = spawn_mock_tavily( + StatusCode::OK, + Vec::new(), + serde_json::json!({"query": "nothing", "results": [], "response_time": 0.3}), + ) + .await; + let handler = tavily_handler(&base_url, None); + + let output = execute(&handler, r#"{"query":"nothing"}"#).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": "tavily", "query": "nothing", "latency": 0.3}]) + ); + 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"); + }; + assert_eq!(item.status, WebSearchCallStatus::Completed); + 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 tavily_handler_forwards_domain_filters_and_reapplies_them_client_side() { + // The mock ignores `include_domains`, as a misconfigured proxy might, and + // returns an off-list result the gateway must still drop. + let (base_url, mut captured, _handle) = spawn_mock_tavily( + StatusCode::OK, + Vec::new(), + serde_json::json!({"results": [ + {"url": "https://doc.rust-lang.org/book", "title": "The Book", "content": "On list"}, + {"url": "https://notrust-lang.org/x", "title": "Lookalike", "content": "Off list"}, + {"url": "https://example.com/off-list", "title": "Off list", "content": "Off list"} + ]}), + ) + .await; + let handler = tavily_handler(&base_url, None); + let params = WebSearchToolParam { + filters: Some(WebSearchFilters { + allowed_domains: Some(vec!["rust-lang.org".to_owned()]), + blocked_domains: None, + }), + ..WebSearchToolParam::default() + }; + + let output = handler + .execute( + "call_tavily", + "web_search", + r#"{"query":"rust","include_domains":["ignored.example"]}"#, + ¶ms, + ) + .await + .unwrap(); + + let request = captured.recv().await.unwrap(); + assert_eq!( + request.body["include_domains"], + serde_json::json!(["rust-lang.org"]), + "the request-level allowlist wins over the model's list and is forwarded natively" + ); + assert!(request.body.get("exclude_domains").is_none()); + let output_json: serde_json::Value = serde_json::from_str(&output.output).unwrap(); + assert_eq!( + output_json["results"]["web"], + serde_json::json!([{"url": "https://doc.rust-lang.org/book", "title": "The Book", "description": "On list"}]) + ); + + let (base_url, mut captured, _handle) = spawn_mock_tavily( + StatusCode::OK, + Vec::new(), + serde_json::json!({"results": [ + {"url": "https://spam.example/a", "title": "Spam"}, + {"url": "https://ham.example/b", "title": "Ham"} + ]}), + ) + .await; + let handler = tavily_handler(&base_url, None); + let output = execute(&handler, r#"{"query":"rust","exclude_domains":["spam.example"]}"#) + .await + .unwrap(); + assert_eq!( + captured.recv().await.unwrap().body["exclude_domains"], + serde_json::json!(["spam.example"]) + ); + let output_json: serde_json::Value = serde_json::from_str(&output.output).unwrap(); + assert_eq!( + output_json["results"]["web"], + serde_json::json!([{"url": "https://ham.example/b", "title": "Ham"}]) + ); +} + +#[tokio::test] +async fn tavily_handler_reports_rejected_credentials_without_leaking_them() { + for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] { + let (base_url, mut captured, _handle) = + spawn_mock_tavily(status, Vec::new(), error_body("tvly-secret-key is invalid")).await; + let handler = tavily_handler(&base_url, None); + + let message = execute(&handler, r#"{"query":"rust async"}"#).await.unwrap_err(); + + assert_eq!( + captured.recv().await.unwrap().authorization.as_deref(), + Some("Bearer tvly-secret-key") + ); + assert_eq!( + message, + format!("execution failed: Tavily rejected the API key ({status}); check TAVILY_API_KEY") + ); + assert!(!message.contains(SECRET_KEY)); + assert!(!message.contains("is invalid"), "the upstream body is not echoed"); + } +} + +#[tokio::test] +async fn tavily_handler_surfaces_rate_limits_without_retrying() { + let (base_url, mut captured, _handle) = spawn_mock_tavily( + StatusCode::TOO_MANY_REQUESTS, + vec![("retry-after", "7")], + error_body("Rate limit exceeded"), + ) + .await; + let handler = tavily_handler(&base_url, None); + + let message = execute(&handler, r#"{"query":"rust async"}"#).await.unwrap_err(); + + assert_eq!( + message, + "execution failed: Tavily 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 tavily_handler_preserves_http_date_retry_after_and_reports_its_absence() { + let retry_after = "Wed, 21 Oct 2026 07:28:00 GMT"; + let (base_url, _captured, _handle) = spawn_mock_tavily( + StatusCode::TOO_MANY_REQUESTS, + vec![("retry-after", retry_after)], + serde_json::json!({}), + ) + .await; + let message = execute(&tavily_handler(&base_url, None), r#"{"query":"q"}"#) + .await + .unwrap_err(); + assert!(message.ends_with(retry_after), "{message}"); + + let (base_url, _captured, _handle) = + spawn_mock_tavily(StatusCode::TOO_MANY_REQUESTS, Vec::new(), serde_json::json!({})).await; + let message = execute(&tavily_handler(&base_url, None), r#"{"query":"q"}"#) + .await + .unwrap_err(); + assert!(message.ends_with("no Retry-After header was provided"), "{message}"); +} + +#[tokio::test] +async fn tavily_handler_reports_plan_limits_without_retrying() { + for code in [432_u16, 433] { + let status = StatusCode::from_u16(code).unwrap(); + let (base_url, mut captured, _handle) = + spawn_mock_tavily(status, Vec::new(), error_body("Usage limit exceeded")).await; + + let message = execute(&tavily_handler(&base_url, None), r#"{"query":"q"}"#) + .await + .unwrap_err(); + + assert_eq!( + message, + format!( + "execution failed: Tavily reported the plan usage limit was exceeded ({code}); the gateway does not retry" + ) + ); + captured.recv().await.expect("one request"); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(captured.try_recv().is_err(), "a {code} must not trigger a retry"); + } +} + +#[tokio::test] +async fn tavily_handler_reports_other_upstream_failures_with_status() { + let (base_url, _captured, _handle) = spawn_mock_tavily( + StatusCode::INTERNAL_SERVER_ERROR, + Vec::new(), + error_body("upstream exploded"), + ) + .await; + + let message = execute(&tavily_handler(&base_url, None), r#"{"query":"q"}"#) + .await + .unwrap_err(); + + assert_eq!( + message, + r#"execution failed: Tavily search returned 500 Internal Server Error: {"detail":{"error":"upstream exploded"}}"# + ); +} + +#[tokio::test] +async fn tavily_handler_rejects_invalid_json_bodies() { + let app = Router::new().route(TAVILY_SEARCH_PATH, post(|| async { "not json" })); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let _handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let message = execute(&tavily_handler(&base_url, None), r#"{"query":"q"}"#) + .await + .unwrap_err(); + assert!( + message.starts_with("execution failed: Tavily search returned invalid JSON:"), + "{message}" + ); +} + +/// Mock that records the peak number of in-flight requests. +async fn spawn_concurrency_tracking_tavily() -> (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( + TAVILY_SEARCH_PATH, + post( + |State((active, max_active)): State<(Arc, Arc)>, + Json(body): Json| 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 = body["query"].as_str().unwrap_or("unknown").to_owned(); + Json(serde_json::json!({ + "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 tavily_handler_runs_batched_queries_concurrently_by_default() { + let (base_url, max_active, _handle) = spawn_concurrency_tracking_tavily().await; + let handler = tavily_handler(&base_url, None); + + let output = execute(&handler, r#"{"queries":["one","two","three","four","five"]}"#) + .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); + let peak = max_active.load(Ordering::SeqCst); + assert!( + (2..=5).contains(&peak), + "Tavily inherits the gateway ceiling of 5, so batched queries overlap (peak {peak})" + ); +} + +#[tokio::test] +async fn tavily_handler_honors_a_lowered_concurrency_override() { + let (base_url, max_active, _handle) = spawn_concurrency_tracking_tavily().await; + let handler = tavily_handler(&base_url, NonZeroUsize::new(1)); + + execute(&handler, r#"{"queries":["one","two","three","four","five"]}"#) + .await + .unwrap(); + + assert_eq!( + max_active.load(Ordering::SeqCst), + 1, + "max_concurrent_queries = 1 serializes the batch" + ); +} diff --git a/crates/agentic-server/src/config_file.rs b/crates/agentic-server/src/config_file.rs index 743dfc99..2a48aacf 100644 --- a/crates/agentic-server/src/config_file.rs +++ b/crates/agentic-server/src/config_file.rs @@ -12,13 +12,13 @@ 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. + /// Search backend (`you`, `brave`, or `tavily`); 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`). + /// provider's conventional variable (`YOU_API_KEY`, `BRAVE_API_KEY`, `TAVILY_API_KEY`). #[serde(skip_serializing_if = "Option::is_none")] pub api_key_env: Option, /// Ceiling on concurrent provider requests within one batched search. @@ -608,6 +608,22 @@ mod tests { assert!(rendered.contains("provider = \"brave\"")); assert!(rendered.contains("max_concurrent_queries = 2")); + fs::write( + home.path().join("config.toml"), + "[web_search]\nprovider = \"tavily\"\nbase_url = \"https://tavily.example\"\n", + ) + .expect("write config"); + let config = FileConfig::load(home.path()) + .expect("load config") + .expect("existing config"); + assert_eq!(config.web_search.provider, Some(WebSearchProviderKind::Tavily)); + assert_eq!(config.web_search.base_url.as_deref(), Some("https://tavily.example")); + assert!( + toml::to_string(&config) + .expect("serialize config") + .contains("provider = \"tavily\"") + ); + 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}"); diff --git a/crates/agentic-server/src/web_search_config.rs b/crates/agentic-server/src/web_search_config.rs index 9c7ec766..a0384795 100644 --- a/crates/agentic-server/src/web_search_config.rs +++ b/crates/agentic-server/src/web_search_config.rs @@ -7,7 +7,7 @@ use agentic_core::error::Error; use crate::config_file::WebSearchFileConfig; -/// Environment override for the `web_search` backend (`you` or `brave`). +/// Environment override for the `web_search` backend (`you`, `brave`, or `tavily`). 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"; @@ -158,6 +158,45 @@ mod tests { assert_eq!(config.provider, WebSearchProviderKind::You); } + #[test] + fn web_search_config_selects_tavily_from_environment_or_file() { + let config = resolve_web_search_config( + &WebSearchFileConfig::default(), + env_from(&[ + ("AGENTIC_WEB_SEARCH_PROVIDER", "Tavily"), + ("TAVILY_API_KEY", "tvly-secret"), + ("BRAVE_API_KEY", "brave-secret"), + ("YOU_API_KEY", "you-secret"), + ("YOU_API_BASE_URL", "https://you.example"), + ]), + ) + .expect("resolve tavily"); + assert_eq!(config.provider, WebSearchProviderKind::Tavily); + assert_eq!(config.api_key.as_deref(), Some("tvly-secret")); + assert_eq!( + config.base_url.as_deref(), + Some("https://api.tavily.com"), + "YOU_API_BASE_URL must not leak into the Tavily endpoint" + ); + assert_eq!( + config.max_concurrent_queries, None, + "Tavily inherits the gateway ceiling" + ); + + let file = WebSearchFileConfig { + provider: Some(WebSearchProviderKind::Tavily), + api_key_env: Some("MY_TAVILY_KEY".to_owned()), + base_url: Some("https://tavily.example".to_owned()), + max_concurrent_queries: NonZeroUsize::new(3), + }; + let config = resolve_web_search_config(&file, env_from(&[("MY_TAVILY_KEY", "custom-secret")])) + .expect("resolve tavily from file"); + assert_eq!(config.provider, WebSearchProviderKind::Tavily); + assert_eq!(config.api_key.as_deref(), Some("custom-secret")); + assert_eq!(config.base_url.as_deref(), Some("https://tavily.example")); + assert_eq!(config.max_concurrent_queries.map(NonZeroUsize::get), Some(3)); + } + #[test] fn web_search_config_applies_environment_precedence_for_endpoint_and_concurrency() { let file = WebSearchFileConfig { @@ -192,7 +231,7 @@ mod tests { .expect_err("unknown provider"); assert_eq!( error.to_string(), - "AGENTIC_WEB_SEARCH_PROVIDER: unknown web_search provider \"bing\"; expected one of: you, brave" + "AGENTIC_WEB_SEARCH_PROVIDER: unknown web_search provider \"bing\"; expected one of: you, brave, tavily" ); let error = resolve_web_search_config( @@ -235,7 +274,12 @@ mod tests { #[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")] { + for (initial, next, key) in [ + ("you", "brave", "BRAVE_API_KEY"), + ("brave", "you", "YOU_API_KEY"), + ("brave", "tavily", "TAVILY_API_KEY"), + ("tavily", "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, diff --git a/docs/deploying/README.md b/docs/deploying/README.md index 5b26fecf..2efc78c9 100644 --- a/docs/deploying/README.md +++ b/docs/deploying/README.md @@ -429,6 +429,24 @@ kubectl create secret generic agentic-api-secrets \ --from-literal=brave-api-key="$BRAVE_API_KEY" ``` +To use Tavily instead, select the provider and supply its key; the endpoint defaults +to `https://api.tavily.com` and batched queries inherit the gateway concurrency limit: + +```yaml + - name: AGENTIC_WEB_SEARCH_PROVIDER + value: tavily + - name: TAVILY_API_KEY + valueFrom: + secretKeyRef: + name: agentic-api-secrets + key: tavily-api-key +``` + +```console +kubectl create secret generic agentic-api-secrets \ + --from-literal=tavily-api-key="$TAVILY_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 8d25e983..7c4cb6b2 100644 --- a/docs/deploying/kubernetes.md +++ b/docs/deploying/kubernetes.md @@ -421,7 +421,9 @@ default You.com provider it is enabled when `YOU_API_KEY` and `YOU_API_BASE_URL` 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. +default; raise `AGENTIC_WEB_SEARCH_MAX_CONCURRENT_QUERIES` only on a paid plan. To use Tavily, set +`AGENTIC_WEB_SEARCH_PROVIDER=tavily` and store `TAVILY_API_KEY` in the Secret; the endpoint defaults to +`https://api.tavily.com` and batched queries inherit the gateway concurrency limit. Create the Secret from a protected environment file so the key does not enter shell history or process arguments: @@ -434,9 +436,10 @@ 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=...` (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`): +The file contains one line, `YOU_API_KEY=...` (or `BRAVE_API_KEY=...` for Brave Search, `TAVILY_API_KEY=...` for +Tavily). Remove it securely after creating the Secret. Patch the environment in the production overlay (for Brave or +Tavily, replace the `YOU_API_BASE_URL` operation with `path: /data/AGENTIC_WEB_SEARCH_PROVIDER` and `value: brave` or +`value: tavily`): ```yaml patches: @@ -493,5 +496,6 @@ Remove the `Authorization` header when inbound OIDC validation is disabled. A to `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. 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. +`web_search_call` naming `BRAVE_API_KEY` or `TAVILY_API_KEY` means that provider rejected the key; one reporting `429` +means the plan's rate limit was hit, and one reporting `432` or `433` means the Tavily plan's credit limit was +exceeded. The gateway does not retry any of these.