Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The example API key tvly-YOUR_KEY is specific to Tavily. To avoid confusion for users configuring other providers like Bocha or Metaso, it would be better to use a generic placeholder like YOUR_KEY. This would also make it consistent with the change in docs/CONFIGURATION.md.

# api_key = "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.
#
Expand Down
10 changes: 8 additions & 2 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,10 @@ pub enum SearchProvider {
Tavily,
/// Bocha AI Search API (<https://bochaai.com>). Requires api_key.
Bocha,
/// Metaso AI Search API (<https://metaso.cn>). Uses built-in default key
/// or `METASO_API_KEY` env var; configurable via `[search] api_key`.
#[serde(alias = "metaso")]
Metaso,
}

impl SearchProvider {
Expand All @@ -623,17 +627,19 @@ impl SearchProvider {
Self::DuckDuckGo => "duckduckgo",
Self::Tavily => "tavily",
Self::Bocha => "bocha",
Self::Metaso => "metaso",
}
}
}

/// 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<SearchProvider>,
/// 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<String>,
}
Expand Down
3 changes: 2 additions & 1 deletion crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ pub struct EngineConfig {
pub workshop: Option<crate::tools::large_output_router::WorkshopConfig>,
/// 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<String>,
/// Per-step DeepSeek API timeout for sub-agent `create_message` requests.
/// Resolved from `[subagents] api_timeout_secs` (clamped to 1..=1800)
Expand Down
3 changes: 2 additions & 1 deletion crates/tui/src/tools/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// Per-session workshop variable store (#548). Holds the raw content of
Expand Down
145 changes: 143 additions & 2 deletions crates/tui/src/tools/web_search.rs
Original file line number Diff line number Diff line change
@@ -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 (<https://metaso.cn>).
//!
//! 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::{
Expand All @@ -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";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid runtime string formatting, it's more efficient to define the full search endpoint URL as a constant. You'll need to update its usage in run_metaso_search from format!("{METASO_ENDPOINT}/search") to just METASO_ENDPOINT.

const METASO_ENDPOINT: &str = "https://metaso.cn/api/v1/search";

/// 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";
Comment on lines +30 to +32
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Hardcoding API keys, even public ones with rate limits, poses a security risk. This key can be easily extracted and abused by malicious actors, potentially leading to the exhaustion of the daily quota for all users of this application (a form of denial-of-service). If the key is revoked by Metaso, this functionality will break for everyone relying on the default.

A safer approach would be to require users to provide their own key, even if it's free. This would be consistent with how other providers like Tavily and Bocha are handled.

const ERROR_BODY_PREVIEW_BYTES: usize = 512;

/// Returns `Ok(())` if the policy allows the call, or a `ToolError` otherwise.
Expand Down Expand Up @@ -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 => {}
}

Expand Down Expand Up @@ -515,6 +527,109 @@ impl WebSearchTool {

ToolResult::json(&response).map_err(|e| ToolError::execution_failed(e.to_string()))
}

/// Search via Metaso AI Search API (<https://metaso.cn>). 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<ToolResult, ToolError> {
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<WebSearchEntry> = 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 {
Expand Down Expand Up @@ -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}`"
);
}
}
7 changes: 4 additions & 3 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down