From 0bc40d146dcd199b4ef49e2fdd75e24378c5d29c Mon Sep 17 00:00:00 2001 From: Zhao Xiaohong Date: Mon, 18 May 2026 14:18:39 +0800 Subject: [PATCH] feat(web): add Metaso as a web search provider (metaso.cn) Adds Metaso AI Search as a new SearchProvider option alongside Bing, DuckDuckGo, Tavily, and Bocha. --- config.example.toml | 6 +- crates/tui/src/config.rs | 10 +- crates/tui/src/core/engine.rs | 3 +- crates/tui/src/tools/spec.rs | 3 +- crates/tui/src/tools/web_search.rs | 145 ++++++++++++++++++++++++++++- docs/CONFIGURATION.md | 7 +- 6 files changed, 163 insertions(+), 11 deletions(-) diff --git a/config.example.toml b/config.example.toml index 87af1a8e4..38ea481be 100644 --- a/config.example.toml +++ b/config.example.toml @@ -250,11 +250,13 @@ max_subagents = 10 # optional (1-20) # Switch to Tavily or Bocha for reliable search in mainland China. # # [search] -# provider = "bing" # bing | duckduckgo | tavily | bocha +# provider = "bing" # bing | duckduckgo | tavily | bocha | metaso # # duckduckgo: HTML scrape with Bing fallback # # tavily: https://tavily.com — AI search, needs api_key # # bocha: https://bochaai.com — 博查AI搜索,国内友好,需api_key -# api_key = "tvly-YOUR_KEY" # required for tavily and bocha +# # metaso: https://metaso.cn — 秘塔AI搜索,每天 100 次免费 +# # 设置 METASO_API_KEY 或 [search] api_key 可提升额度 +# api_key = "tvly-YOUR_KEY" # required for tavily, bocha, and metaso (optional for metaso) # # WARNING: treat config.toml like a secret file when # # storing API keys. Use env vars or `auth set` instead. # diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index b41712557..367b62d52 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -613,6 +613,10 @@ pub enum SearchProvider { Tavily, /// Bocha AI Search API (). Requires api_key. Bocha, + /// Metaso AI Search API (). Uses built-in default key + /// or `METASO_API_KEY` env var; configurable via `[search] api_key`. + #[serde(alias = "metaso")] + Metaso, } impl SearchProvider { @@ -623,6 +627,7 @@ impl SearchProvider { Self::DuckDuckGo => "duckduckgo", Self::Tavily => "tavily", Self::Bocha => "bocha", + Self::Metaso => "metaso", } } } @@ -630,10 +635,11 @@ impl SearchProvider { /// Web search provider configuration (`[search]` table in config.toml). #[derive(Debug, Clone, Deserialize, Default)] pub struct SearchConfig { - /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha`. Default: `bing`. + /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha` | `metaso`. Default: `bing`. #[serde(default)] pub provider: Option, - /// API key for Tavily or Bocha. Not required for Bing or DuckDuckGo. + /// API key for Tavily, Bocha, or Metaso. Not required for Bing or DuckDuckGo. + /// Metaso also falls back to `METASO_API_KEY` env var, then a built-in default. #[serde(default)] pub api_key: Option, } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 202cd1648..e85d684c4 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -164,7 +164,8 @@ pub struct EngineConfig { pub workshop: Option, /// Which search backend `web_search` should use. Default: Bing. pub search_provider: crate::config::SearchProvider, - /// API key for Tavily or Bocha. `None` for Bing or DuckDuckGo. + /// API key for Tavily, Bocha, or Metaso. `None` for Bing or DuckDuckGo. + /// Metaso also falls back to `METASO_API_KEY` env var, then a built-in key. pub search_api_key: Option, /// Per-step DeepSeek API timeout for sub-agent `create_message` requests. /// Resolved from `[subagents] api_timeout_secs` (clamped to 1..=1800) diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index 30a42c496..f13c65162 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -165,7 +165,8 @@ pub struct ToolContext { /// Which search backend `web_search` should use. Default: Bing. Set via /// `[search] provider` in config.toml. pub search_provider: crate::config::SearchProvider, - /// API key for Tavily or Bocha. `None` for Bing or DuckDuckGo. + /// API key for Tavily, Bocha, or Metaso. `None` for Bing or DuckDuckGo. + /// Metaso also falls back to `METASO_API_KEY` env var, then a built-in key. pub search_api_key: Option, /// Per-session workshop variable store (#548). Holds the raw content of diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index d46cac7e8..49e2544ec 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -1,11 +1,12 @@ //! Web search tool backed by multiple providers: Bing HTML scrape, DuckDuckGo -//! (HTML scrape with Bing fallback), Tavily API, and Bocha (博查) API. +//! (HTML scrape with Bing fallback), Tavily API, Bocha (博查) API, and +//! Metaso API (). //! //! This is the primary web search surface for agents. For browsing workflows //! (page open, click, screenshot) use a direct URL approach instead. //! //! Set `[search]` in config.toml to switch providers: -//! provider = "duckduckgo" # or tavily/bocha +//! provider = "duckduckgo" # or tavily/bocha/metaso //! api_key = "tvly-..." use super::spec::{ @@ -25,6 +26,10 @@ const DUCKDUCKGO_HOST: &str = "html.duckduckgo.com"; const BING_HOST: &str = "www.bing.com"; const TAVILY_ENDPOINT: &str = "https://api.tavily.com/search"; const BOCHA_ENDPOINT: &str = "https://api.bochaai.com/v1/ai/search"; +const METASO_ENDPOINT: &str = "https://metaso.cn/api/v1"; +/// Intentionally public default key provided by Metaso for open-source/community use. +/// Last-resort fallback after config and env var. Rate-limited to ~100 searches/day. +const METASO_DEFAULT_API_KEY: &str = "mk-E384C1DD5E8501BB7EFE27C949AFDE5B"; const ERROR_BODY_PREVIEW_BYTES: usize = 512; /// Returns `Ok(())` if the policy allows the call, or a `ToolError` otherwise. @@ -198,6 +203,13 @@ impl ToolSpec for WebSearchTool { .run_bocha_search(&query, max_results, timeout_ms, context) .await; } + SearchProvider::Metaso => { + let decider = context.network_policy.as_ref(); + check_policy(decider, "metaso.cn")?; + return self + .run_metaso_search(&query, max_results, timeout_ms, context) + .await; + } SearchProvider::Bing | SearchProvider::DuckDuckGo => {} } @@ -515,6 +527,109 @@ impl WebSearchTool { ToolResult::json(&response).map_err(|e| ToolError::execution_failed(e.to_string())) } + + /// Search via Metaso AI Search API (). Falls back to + /// `METASO_API_KEY` env var then a built-in default key if no config key + /// is set. + async fn run_metaso_search( + &self, + query: &str, + max_results: usize, + timeout_ms: u64, + context: &ToolContext, + ) -> Result { + let env_key = std::env::var("METASO_API_KEY").ok(); + let api_key = context + .search_api_key + .as_deref() + .or(env_key.as_deref()) + .unwrap_or(METASO_DEFAULT_API_KEY); + + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(timeout_ms)) + .build() + .map_err(|e| { + ToolError::execution_failed(format!("Failed to build HTTP client: {e}")) + })?; + + let size = max_results.clamp(1, 100); + let payload = json!({ + "q": query, + "scope": "webpage", + "size": size, + }); + + let resp = client + .post(format!("{METASO_ENDPOINT}/search")) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {api_key}")) + .json(&payload) + .send() + .await + .map_err(|e| { + ToolError::execution_failed(format!("Metaso search request failed: {e}")) + })?; + + let status = resp.status(); + let body = resp.text().await.map_err(|e| { + ToolError::execution_failed(format!("Failed to read Metaso response: {e}")) + })?; + + if !status.is_success() { + let msg = match status.as_u16() { + 401 | 403 => "Metaso API key rejected — check METASO_API_KEY or set `[search] api_key` in config.toml, or get one at https://metaso.cn/search-api/playground".to_string(), + 429 => "Metaso rate-limited — wait and retry, or get your own API key at https://metaso.cn/search-api/playground".to_string(), + _ => { + let truncated = truncate_error_body(&body); + format!("Metaso server error (HTTP {status}) — {truncated}") + } + }; + return Err(ToolError::execution_failed(msg)); + } + + let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| { + ToolError::execution_failed(format!("Failed to parse Metaso response: {e}")) + })?; + + // Check business-logic error codes in the response body. + if let Some(code) = parsed.get("code").and_then(|v| v.as_i64()) + && code != 0 + { + let msg = parsed + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + return Err(ToolError::execution_failed(match code { + 3003 => "Metaso: daily search limit reached — set METASO_API_KEY or get one at https://metaso.cn/search-api/playground".to_string(), + 2005 => "Metaso API key rejected — check METASO_API_KEY or set `[search] api_key` in config.toml".to_string(), + _ => format!("Metaso API error (code {code}: {msg})"), + })); + } + + let results: Vec = parsed + .get("webpages") + .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 = item + .get("snippet") + .or_else(|| item.get("summary")) + .and_then(|s| s.as_str()) + .map(|s| s.to_string()); + Some(WebSearchEntry { + title, + url, + snippet, + }) + }) + .take(size) + .collect(); + + search_tool_result(query.to_string(), "metaso", results, None) + } } fn truncate_error_body(body: &str) -> String { @@ -1210,4 +1325,30 @@ mod tests { "error must name the provider and missing key; got `{msg}`" ); } + + #[tokio::test] + async fn metaso_provider_uses_built_in_key_when_no_config_key_set() { + // Unlike Tavily/Bocha, Metaso falls back to a built-in default, so + // the call should NOT return an API-key-related error — it should + // either succeed or fail with a network-level error, but never a + // missing-key error. + use crate::config::SearchProvider; + use crate::tools::spec::{ToolContext, ToolSpec}; + + let tmp = tempfile::tempdir().expect("tempdir"); + let mut ctx = ToolContext::new(tmp.path().to_path_buf()); + ctx.search_provider = SearchProvider::Metaso; + ctx.search_api_key = None; + let result = WebSearchTool + .execute(json!({"query": "anything"}), &ctx) + .await; + let msg = match &result { + Ok(res) => format!("{res:?}"), + Err(e) => e.to_string(), + }; + assert!( + !msg.contains("API key"), + "should not complain about missing API key (built-in default); got `{msg}`" + ); + } } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 858bac7ea..dc8a72885 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -686,12 +686,13 @@ Use `codewhale-tui features list` to inspect known flags and their effective sta `web_search` uses Bing by default and does not require an API key. DuckDuckGo remains selectable for users who explicitly want it, and Tavily or Bocha can be -selected when an API-backed provider is preferred. +selected when an API-backed provider is preferred. **Metaso** ([metaso.cn](https://metaso.cn)) +100 searches/day free quota — set `METASO_API_KEY` or `[search] api_key` for a higher quota. ```toml [search] -provider = "bing" # bing | duckduckgo | tavily | bocha -# api_key = "tvly-YOUR_KEY" # required for tavily and bocha +provider = "bing" # bing | duckduckgo | tavily | bocha | metaso +# api_key = "YOUR_KEY" # required for tavily and bocha; optional for metaso (100 searches/day free quota) ``` ## Local Media Attachments