From 2ad4f75a30b16ddbf011c51b7c5fd21691128692 Mon Sep 17 00:00:00 2001 From: serply Date: Fri, 11 Sep 2026 16:00:53 -0400 Subject: [PATCH 1/2] feat(web_search): add Serply search provider Add Serply (https://serply.io) as an opt-in `[search]` provider alongside Sofya and the other keyed backends. `provider = "serply"` sends `GET https://api.serply.io/v1/search?q=...&num=...` with the key in an `X-Api-Key` header and maps `results[].title/link/description` onto `WebSearchEntry`; ads, knowledge graph and related questions are ignored. The key comes from `[search] api_key` or the `SERPLY_API_KEY` env var. Preflight fails closed with a clear message when neither is set, and `SERPLY_API_KEY` joins the workspace `.env` credential allowlist. `BackendId::Serply` records receipts, `api.serply.io` is the network-policy and `default_backend_host` host, and `search_probe_target` covers it for `doctor`. Firecrawl stays the default; existing configs are unchanged. Docs: config.example.toml, docs/CONFIGURATION.md and the zh_hans copy. Signed-off-by: googio <59339358+googio@users.noreply.github.com> --- config.example.toml | 10 +- crates/tui/src/config/search.rs | 11 +- crates/tui/src/config/tests.rs | 29 ++++ crates/tui/src/lib.rs | 1 + crates/tui/src/tools/web/backend.rs | 8 +- crates/tui/src/tools/web/contract.rs | 2 + crates/tui/src/tools/web_search.rs | 212 ++++++++++++++++++++++++++- docs/CONFIGURATION.md | 7 +- docs/zh_hans/CONFIGURATION.md | 4 +- 9 files changed, 271 insertions(+), 13 deletions(-) diff --git a/config.example.toml b/config.example.toml index b4edf51175..07a60b3db4 100644 --- a/config.example.toml +++ b/config.example.toml @@ -995,12 +995,12 @@ max_subagents = 10 # optional (default 64, clamped to 1-128) # Choose which backend the Web tool's search uses. Default is keyless Firecrawl # (bounded per-IP quota; `[search] api_key` or FIRECRAWL_API_KEY raises it). # Bing and DuckDuckGo HTML scraping need no API key. Tavily, Bocha, Metaso, -# Baidu, Volcengine and Sofya need an api_key; SearXNG needs base_url. +# Baidu, Volcengine, Sofya and Serply need an api_key; SearXNG needs base_url. # API runtime failures and empty responses visibly degrade through DuckDuckGo # then Bing. Missing configuration and network-policy denials fail closed. # # [search] -# provider = "firecrawl" # firecrawl | bing | duckduckgo | tavily | bocha | metaso | searxng | baidu | volcengine | sofya +# provider = "firecrawl" # firecrawl | bing | duckduckgo | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply # # firecrawl: Firecrawl Cloud search, keyless with a bounded per-IP quota # # duckduckgo: HTML scrape with Bing fallback # # bing: HTML scrape, no API key @@ -1016,9 +1016,12 @@ max_subagents = 10 # optional (default 64, clamped to 1-128) # # sofya: https://sofya.co — AI search returning full page # # content (not snippets), needs api_key (ay_live_...); # # also falls back to the SOFYA_API_KEY env var +# # serply: https://serply.io Google organic results with +# # snippets, needs api_key; +# # also falls back to the SERPLY_API_KEY env var # base_url = "https://search.example/" # optional DuckDuckGo-compatible HTML endpoint; # # required SearXNG root or /search endpoint -# api_key = "YOUR_SEARCH_KEY" # required for tavily, bocha, metaso, baidu, volcengine, and sofya; optional for firecrawl (raises the keyless quota); unused by searxng +# api_key = "YOUR_SEARCH_KEY" # required for tavily, bocha, metaso, baidu, volcengine, sofya, and serply; optional for firecrawl (raises the keyless quota); unused by searxng # # WARNING: treat config.toml like a secret file when # # storing API keys. Prefer env vars for local smoke tests. # @@ -1034,6 +1037,7 @@ max_subagents = 10 # optional (default 64, clamped to 1-128) # BAIDU_SEARCH_API_KEY → baidu key fallback # VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY → volcengine key fallback # SOFYA_API_KEY → sofya key fallback +# SERPLY_API_KEY → serply key fallback # ───────────────────────────────────────────────────────────────────────────────── # Network Policy (#135) diff --git a/crates/tui/src/config/search.rs b/crates/tui/src/config/search.rs index a2e75fb40b..c4ddf3a57d 100644 --- a/crates/tui/src/config/search.rs +++ b/crates/tui/src/config/search.rs @@ -60,6 +60,10 @@ pub enum SearchProvider { /// snippets; falls back to the `SOFYA_API_KEY` env var when /// `[search] api_key` is not set. Sofya, + /// Serply Google search API (). Requires api_key; + /// returns Google organic results with snippets. Falls back to the + /// `SERPLY_API_KEY` env var when `[search] api_key` is not set. + Serply, } impl SearchProvider { @@ -78,6 +82,7 @@ impl SearchProvider { } "volcengine" | "ark" | "volc" | "volcengine-ark" => Some(Self::Volcengine), "sofya" => Some(Self::Sofya), + "serply" => Some(Self::Serply), _ => None, } } @@ -95,12 +100,13 @@ impl SearchProvider { Self::Baidu => "baidu", Self::Volcengine => "volcengine", Self::Sofya => "sofya", + Self::Serply => "serply", } } #[must_use] pub fn names_hint() -> &'static str { - "bing, duckduckgo, firecrawl, tavily, bocha, metaso, searxng, baidu, volcengine, sofya" + "bing, duckduckgo, firecrawl, tavily, bocha, metaso, searxng, baidu, volcengine, sofya, serply" } } @@ -139,9 +145,10 @@ pub struct SearchConfig { /// SearXNG instance root or `/search` endpoint. #[serde(default)] pub base_url: Option, - /// Optional for Firecrawl; required for Tavily, Bocha, Metaso, Baidu, Volcengine, or Sofya. + /// Optional for Firecrawl; required for Tavily, Bocha, Metaso, Baidu, Volcengine, Sofya, or Serply. /// Metaso also falls back to the `METASO_API_KEY` env var. /// Baidu also falls back to `BAIDU_SEARCH_API_KEY` env var. + /// Serply also falls back to the `SERPLY_API_KEY` env var. /// Volcengine also falls back to `VOLCENGINE_API_KEY` / `VOLCENGINE_ARK_API_KEY` / `ARK_API_KEY` env vars. #[serde(default)] pub api_key: Option, diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs index a6a59811f1..f5da6c92b9 100644 --- a/crates/tui/src/config/tests.rs +++ b/crates/tui/src/config/tests.rs @@ -2008,6 +2008,35 @@ fn sofya_search_provider_parses_and_round_trips() { assert_eq!(SearchProvider::Sofya.as_str(), "sofya"); } +#[test] +fn explicit_serply_search_provider_is_preserved() { + let config: Config = toml::from_str( + r#" + [search] + provider = "serply" + "#, + ) + .expect("serply search config"); + + assert_eq!( + config.search.and_then(|search| search.provider), + Some(SearchProvider::Serply) + ); +} + +#[test] +fn serply_search_provider_parses_and_round_trips() { + assert_eq!( + SearchProvider::parse("serply"), + Some(SearchProvider::Serply) + ); + assert_eq!( + SearchProvider::parse("Serply"), + Some(SearchProvider::Serply) + ); + assert_eq!(SearchProvider::Serply.as_str(), "serply"); +} + #[test] fn live_search_provider_update_preserves_environment_precedence() { let _guard = lock_test_env(); diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index b2b26503bc..edc9e1063a 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -2831,6 +2831,7 @@ fn is_workspace_dotenv_credential_key(key: &str) -> bool { key, "DEEPSEEK_SEARCH_API_KEY" | "SOFYA_API_KEY" + | "SERPLY_API_KEY" | "METASO_API_KEY" | "BAIDU_SEARCH_API_KEY" | "DEEPSEEK_SANDBOX_API_KEY" diff --git a/crates/tui/src/tools/web/backend.rs b/crates/tui/src/tools/web/backend.rs index 70a739067a..c75b7e60ad 100644 --- a/crates/tui/src/tools/web/backend.rs +++ b/crates/tui/src/tools/web/backend.rs @@ -37,6 +37,7 @@ pub(crate) enum ConfiguredSearchBackend<'a> { Baidu(BackendContext<'a>), Volcengine(BackendContext<'a>), Sofya(BackendContext<'a>), + Serply(BackendContext<'a>), } #[derive(Clone, Copy)] @@ -61,6 +62,7 @@ impl<'a> ConfiguredSearchBackend<'a> { SearchProvider::Baidu => Self::Baidu(backend), SearchProvider::Volcengine => Self::Volcengine(backend), SearchProvider::Sofya => Self::Sofya(backend), + SearchProvider::Serply => Self::Serply(backend), } } @@ -76,6 +78,7 @@ impl<'a> ConfiguredSearchBackend<'a> { Self::Baidu(_) => SearchProvider::Baidu, Self::Volcengine(_) => SearchProvider::Volcengine, Self::Sofya(_) => SearchProvider::Sofya, + Self::Serply(_) => SearchProvider::Serply, } } @@ -90,7 +93,8 @@ impl<'a> ConfiguredSearchBackend<'a> { | Self::Searxng(context) | Self::Baidu(context) | Self::Volcengine(context) - | Self::Sofya(context) => context, + | Self::Sofya(context) + | Self::Serply(context) => context, } } } @@ -288,6 +292,7 @@ impl SearchBackend for ConfiguredSearchBackend<'_> { SearchProvider::Baidu => BackendId::Baidu, SearchProvider::Volcengine => BackendId::Volcengine, SearchProvider::Sofya => BackendId::Sofya, + SearchProvider::Serply => BackendId::Serply, } } @@ -516,6 +521,7 @@ mod tests { (SearchProvider::Baidu, BackendId::Baidu), (SearchProvider::Volcengine, BackendId::Volcengine), (SearchProvider::Sofya, BackendId::Sofya), + (SearchProvider::Serply, BackendId::Serply), ]; for (provider, expected) in cases { diff --git a/crates/tui/src/tools/web/contract.rs b/crates/tui/src/tools/web/contract.rs index 17d55be2a8..d33f232f1a 100644 --- a/crates/tui/src/tools/web/contract.rs +++ b/crates/tui/src/tools/web/contract.rs @@ -30,6 +30,7 @@ pub(crate) enum BackendId { Baidu, Volcengine, Sofya, + Serply, } impl BackendId { @@ -47,6 +48,7 @@ impl BackendId { Self::Baidu => "baidu", Self::Volcengine => "volcengine", Self::Sofya => "sofya", + Self::Serply => "serply", } } } diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index 8c7065c730..af6dbbc373 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -1,6 +1,6 @@ //! Bounded provider-native/configured web search with explicit fallback receipts. //! Adapters include Firecrawl, Tavily, Bocha, Metaso, SearXNG, Baidu, -//! Volcengine, and Sofya; browsing remains a separate `web.run` workflow. +//! Volcengine, Sofya, and Serply; browsing remains a separate `web.run` workflow. //! `[search]` example: //! provider = "firecrawl" # keyless on Firecrawl Cloud; optional api_key //! base_url = `"https://search.example/"` # DDG-compatible URL or SearXNG instance @@ -40,6 +40,7 @@ const METASO_ENDPOINT: &str = "https://metaso.cn/api/v1"; const BAIDU_ENDPOINT: &str = "https://qianfan.baidubce.com/v2/ai_search/web_search"; const VOLCENGINE_RESPONSES_ENDPOINT: &str = "https://ark.cn-beijing.volces.com/api/v3/responses"; const SOFYA_ENDPOINT: &str = "https://sofya.co/v1/search"; +const SERPLY_ENDPOINT: &str = "https://api.serply.io/v1/search"; const ERROR_BODY_PREVIEW_BYTES: usize = 512; const PROVIDER_NATIVE_MIN_TIMEOUT_MS: u64 = 45_000; const KIMI_K3_FORMULA_MIN_TIMEOUT_MS: u64 = 180_000; @@ -103,6 +104,7 @@ pub(crate) fn search_probe_target( SearchProvider::Baidu => (BAIDU_ENDPOINT, false), SearchProvider::Volcengine => (VOLCENGINE_RESPONSES_ENDPOINT, false), SearchProvider::Sofya => (SOFYA_ENDPOINT, false), + SearchProvider::Serply => (SERPLY_ENDPOINT, false), }; let mut url = reqwest::Url::parse(raw).map_err(|_| SearchProbeTargetError::Invalid)?; @@ -178,7 +180,7 @@ impl ToolSpec for WebSearchTool { } fn description(&self) -> &'static str { - "Search the web and return ranked results with URLs, snippets, session-scoped ref_ids, and an execution receipt. Open a result ref_id with `web.run` when the short summary is not enough; fetch only the few sources needed. When the exact active route reports a documented first-party server-side search tool, it is tried first; otherwise keyless Firecrawl is the default. Configured API backends visibly degrade through DuckDuckGo then Bing when unavailable, and every hop is recorded. Configuration and network-policy errors fail closed. Explicit Bing and private DuckDuckGo-compatible routes do not cross providers. Set `[search] provider = \"firecrawl\" | \"bing\" | \"tavily\" | \"bocha\" | \"metaso\" | \"searxng\" | \"baidu\" | \"volcengine\" | \"sofya\"` in config.toml. Firecrawl Cloud works keyless with a bounded quota. For a known canonical URL, prefer `fetch_url` directly." + "Search the web and return ranked results with URLs, snippets, session-scoped ref_ids, and an execution receipt. Open a result ref_id with `web.run` when the short summary is not enough; fetch only the few sources needed. When the exact active route reports a documented first-party server-side search tool, it is tried first; otherwise keyless Firecrawl is the default. Configured API backends visibly degrade through DuckDuckGo then Bing when unavailable, and every hop is recorded. Configuration and network-policy errors fail closed. Explicit Bing and private DuckDuckGo-compatible routes do not cross providers. Set `[search] provider = \"firecrawl\" | \"bing\" | \"tavily\" | \"bocha\" | \"metaso\" | \"searxng\" | \"baidu\" | \"volcengine\" | \"sofya\" | \"serply\"` in config.toml. Firecrawl Cloud works keyless with a bounded quota. For a known canonical URL, prefer `fetch_url` directly." } fn input_schema(&self) -> Value { @@ -525,6 +527,63 @@ impl WebSearchTool { Ok(parse_sofya_results(&parsed, max_results)) } + /// Search Serply (); it returns Google organic results and + /// accepts `SERPLY_API_KEY`. + async fn run_serply_search( + &self, + query: &str, + max_results: usize, + timeout_ms: u64, + context: &ToolContext, + ) -> Result, ToolError> { + let env_key = std::env::var("SERPLY_API_KEY").ok(); + let api_key = context + .search_api_key + .as_deref() + .or(env_key.as_deref()) + .ok_or_else(|| { + ToolError::execution_failed( + "Serply search requires an API key. Set `[search] api_key` in config.toml or the SERPLY_API_KEY env var.", + ) + })?; + + let client = crate::tls::reqwest_client_builder() + .timeout(Duration::from_millis(timeout_ms)) + .build() + .map_err(|e| { + ToolError::execution_failed(format!("Failed to build HTTP client: {e}")) + })?; + + let resp = client + .get(serply_search_url(query, max_results)?) + .header("X-Api-Key", api_key) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| { + ToolError::execution_failed(format!("Serply search request failed: {e}")) + })?; + + let status = resp.status(); + let body = resp.text().await.map_err(|e| { + ToolError::execution_failed(format!("Failed to read Serply response: {e}")) + })?; + + if !status.is_success() { + let truncated = truncate_error_body(&body); + return Err(ToolError::execution_failed(format!( + "Serply search failed: HTTP {}: {truncated}", + status.as_u16() + ))); + } + + let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| { + ToolError::execution_failed(format!("Failed to parse Serply response: {e}")) + })?; + + Ok(parse_serply_results(&parsed, max_results)) + } + /// Search via Bocha AI Search API (). async fn run_bocha_search( &self, @@ -1048,6 +1107,9 @@ fn preflight_search_provider(context: &ToolContext) -> Result<(), ToolError> { SearchProvider::Sofya if !configured_key && !env_key("SOFYA_API_KEY") => not_configured( "Sofya search is not configured: it requires an API key. Set `[search] api_key = \"ay_live_...\"` in config.toml or the SOFYA_API_KEY env var.", ), + SearchProvider::Serply if !configured_key && !env_key("SERPLY_API_KEY") => not_configured( + "Serply search is not configured: it requires an API key. Set `[search] api_key` in config.toml or the SERPLY_API_KEY env var.", + ), SearchProvider::Searxng if configured_search_base_url(context.search_base_url.as_deref()).is_none() => { @@ -1096,6 +1158,7 @@ const fn default_backend_host(backend: BackendId) -> Option<&'static str> { BackendId::Baidu => Some("qianfan.baidubce.com"), BackendId::Volcengine => Some("ark.cn-beijing.volces.com"), BackendId::Sofya => Some("sofya.co"), + BackendId::Serply => Some("api.serply.io"), } } @@ -1317,6 +1380,14 @@ pub(crate) async fn run_backend_search( .await?, )) } + SearchProvider::Serply => { + check_policy(context.network_policy.as_ref(), "api.serply.io")?; + Ok(simple( + BackendId::Serply, + tool.run_serply_search(&query.query, max_results, timeout_ms, context) + .await?, + )) + } SearchProvider::Bing | SearchProvider::DuckDuckGo => { run_scrape_search(provider, query, timeout_ms, context).await } @@ -1787,6 +1858,39 @@ fn parse_sofya_results(parsed: &Value, max_results: usize) -> Vec Result { + let mut url = reqwest::Url::parse(SERPLY_ENDPOINT) + .map_err(|error| ToolError::invalid_input(format!("Invalid Serply endpoint: {error}")))?; + url.query_pairs_mut() + .append_pair("q", query) + .append_pair("num", &max_results.to_string()); + Ok(url) +} + +/// Parse Serply `/v1/search` output: `results[]` rows carry `title`, `link`, and +/// a `description` snippet; ads, knowledge graph, and related questions are +/// top-level siblings and are ignored. +fn parse_serply_results(parsed: &Value, max_results: usize) -> Vec { + parsed + .get("results") + .and_then(|v| v.as_array()) + .into_iter() + .flat_map(|arr| arr.iter()) + .filter_map(|item| { + let title = item.get("title")?.as_str()?.to_string(); + let url = item.get("link")?.as_str()?.to_string(); + let snippet = first_non_empty_string(item, &["description", "snippet"]); + Some(WebSearchEntry { + title, + url, + snippet, + }) + }) + .take(max_results) + .collect() +} + fn first_non_empty_string(item: &Value, keys: &[&str]) -> Option { keys.iter().find_map(|key| { item.get(*key) @@ -2163,10 +2267,10 @@ mod tests { baidu_search_payload, bocha_error_message, domain_matches, duckduckgo_search_url, extract_search_query, finalize_search_response, optional_search_max_results, parse_baidu_results, parse_bocha_results, parse_metaso_results, parse_searxng_results, - parse_sofya_results, parse_tavily_results, parse_volcengine_results, + parse_serply_results, parse_sofya_results, parse_tavily_results, parse_volcengine_results, register_search_citations, rerank, run_scrape_search_with_endpoints, sanitize_error_body, - search_probe_target, search_timeout_budgets, searxng_search_url, truncate_error_body, - volcengine_extract_text, + search_probe_target, search_timeout_budgets, searxng_search_url, serply_search_url, + truncate_error_body, volcengine_extract_text, }; use crate::config::SearchProvider; use crate::tools::web::contract::{ @@ -2224,6 +2328,7 @@ mod tests { "https://ark.cn-beijing.volces.com/api/v3/responses", ), (SearchProvider::Sofya, "https://sofya.co/v1/search"), + (SearchProvider::Serply, "https://api.serply.io/v1/search"), ]; for (provider, expected) in cases { @@ -2569,6 +2674,72 @@ mod tests { ); } + #[test] + fn serply_search_url_encodes_query_and_result_count() { + let url = serply_search_url("rust tui & ratatui", 7).expect("serply url"); + + assert_eq!(url.host_str(), Some("api.serply.io")); + assert_eq!(url.path(), "/v1/search"); + let pairs: Vec<(String, String)> = url + .query_pairs() + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + assert_eq!( + pairs, + vec![ + ("q".to_string(), "rust tui & ratatui".to_string()), + ("num".to_string(), "7".to_string()), + ] + ); + } + + #[test] + fn parse_serply_results_reads_link_and_description_and_skips_malformed_rows() { + let body = json!({ + "results": [ + { + "title": "Ratatui", + "link": "https://ratatui.rs/", + "description": "Cook up delicious terminal user interfaces in Rust.", + "position": 1, + "realPosition": 1 + }, + { + "title": "No description", + "link": "https://example.com/plain", + "description": "" + }, + { + "title": "Missing link", + "description": "dropped because there is no link" + }, + "not an object", + { + "title": "Fourth", + "link": "https://example.com/fourth", + "description": "beyond max_results" + } + ], + "knowledge_graph": {"title": "ignored sidebar"}, + "related_questions": [{"question": "ignored"}], + "ads": [{"title": "ignored ad", "link": "https://ads.example.com"}] + }); + + let results = parse_serply_results(&body, 2); + + assert_eq!(results.len(), 2); + assert_eq!(results[0].title, "Ratatui"); + assert_eq!(results[0].url, "https://ratatui.rs/"); + assert_eq!( + results[0].snippet.as_deref(), + Some("Cook up delicious terminal user interfaces in Rust.") + ); + assert_eq!(results[1].url, "https://example.com/plain"); + assert_eq!(results[1].snippet, None); + + assert!(parse_serply_results(&json!({"total": 0}), 5).is_empty()); + } + #[test] fn parse_sofya_results_falls_back_to_description_for_empty_content() { let body = json!({ @@ -2797,6 +2968,37 @@ mod tests { ); } + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn serply_provider_without_api_key_surfaces_clear_error_not_silent_fallback() { + use crate::config::SearchProvider; + use crate::tools::spec::{ToolContext, ToolSpec}; + + let _guard = crate::test_support::lock_test_env(); + let prev = std::env::var_os("SERPLY_API_KEY"); + unsafe { std::env::remove_var("SERPLY_API_KEY") }; + + let tmp = tempfile::tempdir().expect("tempdir"); + let mut ctx = ToolContext::new(tmp.path().to_path_buf()); + ctx.search_provider = SearchProvider::Serply; + ctx.search_api_key = None; + let err = WebSearchTool + .execute(json!({"query": "anything"}), &ctx) + .await + .expect_err("missing api_key must surface as ToolError"); + + match prev { + Some(value) => unsafe { std::env::set_var("SERPLY_API_KEY", value) }, + None => unsafe { std::env::remove_var("SERPLY_API_KEY") }, + } + + let msg = err.to_string(); + assert!( + msg.contains("Serply") && msg.contains("API key"), + "error must name the provider and missing key; got `{msg}`" + ); + } + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn sofya_provider_without_api_key_surfaces_clear_error_not_silent_fallback() { diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a88fe6c7b6..cbf6ec6cfc 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -2848,9 +2848,14 @@ rather than snippets. Set `[search] api_key` to your `ay_live_...` key, or the `SOFYA_API_KEY` env var. This is a search-tool backend only; it does not add a Sofya model provider. +**Serply** ([serply.io](https://serply.io)) returns Google organic results with +title, URL, and snippet. Set `[search] api_key` to your Serply key, or the +`SERPLY_API_KEY` env var. This is a search-tool backend only; it does not add a +Serply model provider. + ```toml [search] -provider = "firecrawl" # also duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya +provider = "firecrawl" # also duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply # base_url = "https://search.example/" # optional with provider = "duckduckgo"; required with "searxng" # api_key = "YOUR_KEY" # optional for firecrawl; required by the other API providers ``` diff --git a/docs/zh_hans/CONFIGURATION.md b/docs/zh_hans/CONFIGURATION.md index 7d16589572..33cb2db8f6 100644 --- a/docs/zh_hans/CONFIGURATION.md +++ b/docs/zh_hans/CONFIGURATION.md @@ -1338,9 +1338,11 @@ exec_policy = true **Sofya**([sofya.co](https://sofya.co))返回完整提取页面内容而不是片段。把 `[search] api_key` 设为你的 `ay_live_...` key,或用 `SOFYA_API_KEY` 环境变量。这只是搜索工具后端;它不添加 Sofya 模型 provider。 +**Serply**([serply.io](https://serply.io))返回 Google 自然搜索结果,包含标题、URL 和摘要片段。把 `[search] api_key` 设为你的 Serply key,或用 `SERPLY_API_KEY` 环境变量。这只是搜索工具后端;它不添加 Serply 模型 provider。 + ```toml [search] -provider = "firecrawl" # 也 duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya +provider = "firecrawl" # 也 duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply # base_url = "https://search.example/" # provider = "duckduckgo" 时可选;"searxng" 时必填 # api_key = "YOUR_KEY" # firecrawl 可选;其他 API 提供商必填 ``` From 77417533365e5d1bb50c5b134a8823db6cf52b1e Mon Sep 17 00:00:00 2001 From: serply Date: Fri, 11 Sep 2026 18:23:06 -0400 Subject: [PATCH 2/2] fix(web_search): fail closed on a missing Serply key inside the backend chain run_serply_search classified a missing API key as ExecutionFailed, which run_backend_chain treats as recoverable, so a configured Serply route that reached the adapter after a failed provider-native attempt degraded to DuckDuckGo instead of stopping. Classify it as InvalidInput, matching the preflight not_configured path, and pin the classification with a test that fails without the change. Signed-off-by: googio <59339358+googio@users.noreply.github.com> --- crates/tui/src/tools/web_search.rs | 32 +++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index af6dbbc373..8999e1c886 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -542,7 +542,7 @@ impl WebSearchTool { .as_deref() .or(env_key.as_deref()) .ok_or_else(|| { - ToolError::execution_failed( + ToolError::invalid_input( "Serply search requires an API key. Set `[search] api_key` in config.toml or the SERPLY_API_KEY env var.", ) })?; @@ -2968,6 +2968,36 @@ mod tests { ); } + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn serply_missing_key_is_fail_closed_inside_the_backend_chain() { + use crate::tools::spec::ToolContext; + + let _guard = crate::test_support::lock_test_env(); + let prev = std::env::var_os("SERPLY_API_KEY"); + unsafe { std::env::remove_var("SERPLY_API_KEY") }; + + let tmp = tempfile::tempdir().expect("tempdir"); + let mut ctx = ToolContext::new(tmp.path().to_path_buf()); + ctx.search_api_key = None; + let err = WebSearchTool + .run_serply_search("anything", 5, 1_000, &ctx) + .await + .expect_err("missing api_key must be an error"); + + match prev { + Some(value) => unsafe { std::env::set_var("SERPLY_API_KEY", value) }, + None => unsafe { std::env::remove_var("SERPLY_API_KEY") }, + } + + // A configured Serply route that reaches the adapter after a failed + // provider-native attempt must stop the chain, not degrade to DuckDuckGo. + assert!( + matches!(err, crate::tools::spec::ToolError::InvalidInput { .. }), + "missing key must be classified fail-closed; got `{err:?}`" + ); + } + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn serply_provider_without_api_key_surfaces_clear_error_not_silent_fallback() {