mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
feat(search): DeepSeek Search API as a web_search backend
DeepSeek opened a standalone Search API on 2026-10-17 (POST https://api.deepseek.com/v1/tools/search, Bearer auth, a JSON `results` array; api-docs.deepseek.com/guides/search). Codewhale already asks DeepSeek's server-side search first when the active route is DeepSeek (the provider-native Responses path). This adds the raw API as an ordinary configured backend, usable from any model route without a model round trip: `[search] provider = "deepseek"` (aliases `deep-seek` and `deepseek-search`). - config/search.rs and command-contract CommandSearchProvider: the variant, parse/as_str/names_hint and the config_host conversions. - web_search.rs: run_deepseek_search posts {query (truncated to 128 chars), count (clamped to 1..=50)}. The key is resolved by deepseek_search_key: `[search] api_key`, then DEEPSEEK_API_KEY. The same function backs preflight, so a missing key fails closed before any network access. The key is redacted from error bodies, and HTTP 402 says to top up. parse_deepseek_results maps title/url, snippet (falling back to summary) and date. BackendId::DeepSeek's policy host is api.deepseek.com. - web/backend.rs, web/contract.rs: backend registration, the serialized id "deepseek", and the existing provider tables. - docs/CONFIGURATION.md, docs/features.toml: configuration, limits and the registry row. Known limits: - The key saved through /provider is not reused: ToolContext carries no Config, so reaching it needs plumbing. - No extension-host transform: parsing stays in Rust when WebSearchHost is on. - recency and locale are not sent (`localization` takes an IP address). - DeepSeek bills $0.75 per 1,000 successful searches from 2026-11-01. - No live call: the local DeepSeek key lives only in the secret store, which this backend does not read. Coded by GPT-6 Astra (codex, workspace-write, no builds) after Gemini 4 Argon (agy) hit its quota before editing. Claude reviewed it, removed a retry loop that slept up to 3 s inside the backend (no other backend retries, and the chain already falls back), and added the command-contract and config_host variants that were outside Astra's file boundary. Verification: - RUST_MIN_STACK=8388608 cargo test -p codewhale-tui --lib -- search web_ config::: 850 passed, 0 failed, 1 ignored - cargo test -p codewhale-tui --test feature_registry: 2 passed - cargo test -p codewhale-command-contract: 79 passed - cargo clippy -p codewhale-tui -p codewhale-command-contract --all-targets --all-features with CI's flags: clean; cargo fmt: clean Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
1 parent
d5d7fd698f
commit
85688ac230
10 files changed
+174
-9
No files matched your search
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- `web_search` can use DeepSeek's new Search API: set
|
||||
`[search] provider = "deepseek"` and give it your DeepSeek key through
|
||||
`[search] api_key` or `DEEPSEEK_API_KEY`. It returns raw results without
|
||||
a model round trip. DeepSeek bills $0.75 per 1,000 searches from
|
||||
November 1, 2026.
|
||||
- In the desktop and web apps, a conversation stays compacted after
|
||||
Codewhale restarts or unloads it. Before, reloading replayed every earlier
|
||||
turn and then added the summary, so the next request carried everything
|
||||
|
||||
@@ -534,6 +534,7 @@ pub enum CommandSearchProvider {
|
||||
Volcengine,
|
||||
Sofya,
|
||||
Serply,
|
||||
DeepSeek,
|
||||
}
|
||||
|
||||
/// Operator-notification delivery method.
|
||||
|
||||
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- `web_search` can use DeepSeek's new Search API: set
|
||||
`[search] provider = "deepseek"` and give it your DeepSeek key through
|
||||
`[search] api_key` or `DEEPSEEK_API_KEY`. It returns raw results without
|
||||
a model round trip. DeepSeek bills $0.75 per 1,000 searches from
|
||||
November 1, 2026.
|
||||
- In the desktop and web apps, a conversation stays compacted after
|
||||
Codewhale restarts or unloads it. Before, reloading replayed every earlier
|
||||
turn and then added the summary, so the next request carried everything
|
||||
|
||||
@@ -238,6 +238,7 @@ fn to_search_provider(provider: CommandSearchProvider) -> SearchProvider {
|
||||
CommandSearchProvider::Volcengine => SearchProvider::Volcengine,
|
||||
CommandSearchProvider::Sofya => SearchProvider::Sofya,
|
||||
CommandSearchProvider::Serply => SearchProvider::Serply,
|
||||
CommandSearchProvider::DeepSeek => SearchProvider::DeepSeek,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +255,7 @@ fn from_search_provider(provider: SearchProvider) -> CommandSearchProvider {
|
||||
SearchProvider::Volcengine => CommandSearchProvider::Volcengine,
|
||||
SearchProvider::Sofya => CommandSearchProvider::Sofya,
|
||||
SearchProvider::Serply => CommandSearchProvider::Serply,
|
||||
SearchProvider::DeepSeek => CommandSearchProvider::DeepSeek,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ pub enum SearchProvider {
|
||||
/// returns Google organic results with snippets. Falls back to the
|
||||
/// `SERPLY_API_KEY` env var when `[search] api_key` is not set.
|
||||
Serply,
|
||||
/// DeepSeek Search API (<https://api-docs.deepseek.com/guides/search>). Requires api_key.
|
||||
#[serde(rename = "deepseek", alias = "deep-seek", alias = "deepseek-search")]
|
||||
DeepSeek,
|
||||
}
|
||||
|
||||
impl SearchProvider {
|
||||
@@ -83,6 +86,7 @@ impl SearchProvider {
|
||||
"volcengine" | "ark" | "volc" | "volcengine-ark" => Some(Self::Volcengine),
|
||||
"sofya" => Some(Self::Sofya),
|
||||
"serply" => Some(Self::Serply),
|
||||
"deepseek" | "deep-seek" | "deepseek-search" => Some(Self::DeepSeek),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -101,12 +105,13 @@ impl SearchProvider {
|
||||
Self::Volcengine => "volcengine",
|
||||
Self::Sofya => "sofya",
|
||||
Self::Serply => "serply",
|
||||
Self::DeepSeek => "deepseek",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn names_hint() -> &'static str {
|
||||
"bing, duckduckgo, firecrawl, tavily, bocha, metaso, searxng, baidu, volcengine, sofya, serply"
|
||||
"bing, duckduckgo, firecrawl, tavily, bocha, metaso, searxng, baidu, volcengine, sofya, serply, deepseek"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +198,7 @@ pub struct SearchConfig {
|
||||
/// SearXNG instance root or `/search` endpoint.
|
||||
#[serde(default)]
|
||||
pub base_url: Option<String>,
|
||||
/// Optional for Firecrawl; required for Tavily, Bocha, Metaso, Baidu, Volcengine, Sofya, or Serply.
|
||||
/// Optional for Firecrawl; required for Tavily, Bocha, Metaso, Baidu, Volcengine, Sofya, Serply, or DeepSeek.
|
||||
/// 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.
|
||||
|
||||
@@ -13,10 +13,11 @@ use crate::tools::spec::{ToolContext, ToolError};
|
||||
|
||||
const SEARCH_BACKEND_CONFIGURATION_HINT: &str = concat!(
|
||||
"Check network access, or configure `[search] provider` and `[search] api_key` in ",
|
||||
"config.toml. Keyed providers include tavily, bocha, metaso, baidu, volcengine, serply, ",
|
||||
"config.toml. Keyed providers include tavily, bocha, metaso, baidu, volcengine, serply, deepseek, ",
|
||||
"and sofya; metaso also accepts METASO_API_KEY, tavily accepts TAVILY_API_KEY, baidu ",
|
||||
"accepts BAIDU_SEARCH_API_KEY, volcengine accepts VOLCENGINE_API_KEY / ",
|
||||
"VOLCENGINE_ARK_API_KEY / ARK_API_KEY, serply accepts SERPLY_API_KEY, and sofya ",
|
||||
"VOLCENGINE_ARK_API_KEY / ARK_API_KEY, serply accepts SERPLY_API_KEY, deepseek accepts ",
|
||||
"DEEPSEEK_API_KEY, and sofya ",
|
||||
"accepts SOFYA_API_KEY. SearXNG needs a trusted self-hosted `[search] base_url`. For a ",
|
||||
"keyless route, use the default `[search] provider = \"firecrawl\"` or ",
|
||||
"`[search] provider = \"bing\"`."
|
||||
@@ -46,6 +47,7 @@ pub(crate) enum ConfiguredSearchBackend<'a> {
|
||||
Volcengine(BackendContext<'a>),
|
||||
Sofya(BackendContext<'a>),
|
||||
Serply(BackendContext<'a>),
|
||||
DeepSeek(BackendContext<'a>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -71,6 +73,7 @@ impl<'a> ConfiguredSearchBackend<'a> {
|
||||
SearchProvider::Volcengine => Self::Volcengine(backend),
|
||||
SearchProvider::Sofya => Self::Sofya(backend),
|
||||
SearchProvider::Serply => Self::Serply(backend),
|
||||
SearchProvider::DeepSeek => Self::DeepSeek(backend),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +90,7 @@ impl<'a> ConfiguredSearchBackend<'a> {
|
||||
Self::Volcengine(_) => SearchProvider::Volcengine,
|
||||
Self::Sofya(_) => SearchProvider::Sofya,
|
||||
Self::Serply(_) => SearchProvider::Serply,
|
||||
Self::DeepSeek(_) => SearchProvider::DeepSeek,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +106,8 @@ impl<'a> ConfiguredSearchBackend<'a> {
|
||||
| Self::Baidu(context)
|
||||
| Self::Volcengine(context)
|
||||
| Self::Sofya(context)
|
||||
| Self::Serply(context) => context,
|
||||
| Self::Serply(context)
|
||||
| Self::DeepSeek(context) => context,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,6 +322,7 @@ impl SearchBackend for ConfiguredSearchBackend<'_> {
|
||||
SearchProvider::Volcengine => BackendId::Volcengine,
|
||||
SearchProvider::Sofya => BackendId::Sofya,
|
||||
SearchProvider::Serply => BackendId::Serply,
|
||||
SearchProvider::DeepSeek => BackendId::DeepSeek,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,6 +591,7 @@ mod tests {
|
||||
(SearchProvider::Volcengine, BackendId::Volcengine),
|
||||
(SearchProvider::Sofya, BackendId::Sofya),
|
||||
(SearchProvider::Serply, BackendId::Serply),
|
||||
(SearchProvider::DeepSeek, BackendId::DeepSeek),
|
||||
];
|
||||
|
||||
for (provider, expected) in cases {
|
||||
@@ -619,6 +626,7 @@ mod tests {
|
||||
QueryCapabilityState::Unsupported,
|
||||
),
|
||||
(SearchProvider::Sofya, QueryCapabilityState::Unsupported),
|
||||
(SearchProvider::DeepSeek, QueryCapabilityState::Unsupported),
|
||||
];
|
||||
for (provider, expected_locale) in cases {
|
||||
let mut context = ToolContext::new(std::path::PathBuf::from("."));
|
||||
@@ -921,6 +929,7 @@ mod tests {
|
||||
"baidu",
|
||||
"volcengine",
|
||||
"serply",
|
||||
"deepseek",
|
||||
"sofya",
|
||||
] {
|
||||
assert!(
|
||||
@@ -938,6 +947,7 @@ mod tests {
|
||||
assert!(message.contains("VOLCENGINE_ARK_API_KEY"));
|
||||
assert!(message.contains("ARK_API_KEY"));
|
||||
assert!(message.contains("SERPLY_API_KEY"));
|
||||
assert!(message.contains("DEEPSEEK_API_KEY"));
|
||||
assert!(message.contains("SOFYA_API_KEY"));
|
||||
assert!(message.contains("[search] base_url"));
|
||||
assert!(message.contains("provider = \"firecrawl\""));
|
||||
|
||||
@@ -31,6 +31,8 @@ pub(crate) enum BackendId {
|
||||
Volcengine,
|
||||
Sofya,
|
||||
Serply,
|
||||
#[serde(rename = "deepseek")]
|
||||
DeepSeek,
|
||||
}
|
||||
|
||||
impl BackendId {
|
||||
@@ -49,6 +51,7 @@ impl BackendId {
|
||||
Self::Volcengine => "volcengine",
|
||||
Self::Sofya => "sofya",
|
||||
Self::Serply => "serply",
|
||||
Self::DeepSeek => "deepseek",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Bounded provider-native/configured web search with explicit fallback receipts.
|
||||
//! Adapters include Firecrawl, Tavily, Bocha, Metaso, SearXNG, Baidu,
|
||||
//! Volcengine, Sofya, and Serply; browsing remains a separate `web.run` workflow.
|
||||
//! Volcengine, Sofya, Serply, and DeepSeek; 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
|
||||
@@ -42,6 +42,7 @@ const BAIDU_ENDPOINT: &str = "https://qianfan.baidubce.com/v2/ai_search/web_sear
|
||||
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 DEEPSEEK_ENDPOINT: &str = "https://api.deepseek.com/v1/tools/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;
|
||||
@@ -497,6 +498,7 @@ pub(crate) fn search_probe_target(
|
||||
SearchProvider::Volcengine => (VOLCENGINE_RESPONSES_ENDPOINT, false),
|
||||
SearchProvider::Sofya => (SOFYA_ENDPOINT, false),
|
||||
SearchProvider::Serply => (SERPLY_ENDPOINT, false),
|
||||
SearchProvider::DeepSeek => (DEEPSEEK_ENDPOINT, false),
|
||||
};
|
||||
|
||||
let mut url = reqwest::Url::parse(raw).map_err(|_| SearchProbeTargetError::Invalid)?;
|
||||
@@ -573,7 +575,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. `web.run` opens a result ref_id when the short summary is not enough. 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. `fetch_url` retrieves a known canonical URL directly."
|
||||
"Search the web and return ranked results with URLs, snippets, session-scoped ref_ids, and an execution receipt. `web.run` opens a result ref_id when the short summary is not enough. 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\" | \"deepseek\"` in config.toml. Firecrawl Cloud works keyless with a bounded quota. `fetch_url` retrieves a known canonical URL directly."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
@@ -1176,6 +1178,66 @@ impl WebSearchTool {
|
||||
Ok(parse_serply_results(&parsed, max_results))
|
||||
}
|
||||
|
||||
async fn run_deepseek_search(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
timeout_ms: u64,
|
||||
context: &ToolContext,
|
||||
) -> AdapterResult<Vec<SearchResult>> {
|
||||
let api_key = deepseek_search_key(context).ok_or_else(|| {
|
||||
ToolError::invalid_input(
|
||||
"DeepSeek search requires an API key. Set `[search] api_key` in config.toml or the DEEPSEEK_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 count = max_results.clamp(1, 50);
|
||||
let payload = json!({
|
||||
"query": query.chars().take(128).collect::<String>(),
|
||||
"count": count,
|
||||
});
|
||||
let resp = client
|
||||
.post(DEEPSEEK_ENDPOINT)
|
||||
.header("Content-Type", "application/json")
|
||||
.bearer_auth(&api_key)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!("DeepSeek search request failed: {e}"))
|
||||
})?;
|
||||
let status = resp.status();
|
||||
let body = if adapter::search_selected(context) {
|
||||
adapter::read_response(resp, context).await?
|
||||
} else {
|
||||
resp.text().await.map_err(|e| {
|
||||
ToolError::execution_failed(format!("Failed to read DeepSeek response: {e}"))
|
||||
})?
|
||||
};
|
||||
if !status.is_success() {
|
||||
let message = if status.as_u16() == 402 {
|
||||
"DeepSeek search failed: HTTP 402 — top up your DeepSeek account to continue."
|
||||
.to_string()
|
||||
} else {
|
||||
let truncated = truncate_error_body(&body.replace(&api_key, "[REDACTED]"));
|
||||
format!(
|
||||
"DeepSeek search failed: HTTP {}: {truncated}",
|
||||
status.as_u16()
|
||||
)
|
||||
};
|
||||
return Err(ToolError::execution_failed(message).into());
|
||||
}
|
||||
let parsed: Value = serde_json::from_str(&body).map_err(|e| {
|
||||
ToolError::execution_failed(format!("Failed to parse DeepSeek response: {e}"))
|
||||
})?;
|
||||
Ok(parse_deepseek_results(&parsed, count))
|
||||
}
|
||||
|
||||
/// Search via Bocha AI Search API (<https://bochaai.com>).
|
||||
async fn run_bocha_search(
|
||||
&self,
|
||||
@@ -1803,6 +1865,21 @@ fn register_search_citations(response: &mut SearchResponse, context: &ToolContex
|
||||
}
|
||||
}
|
||||
|
||||
fn deepseek_search_key(context: &ToolContext) -> Option<String> {
|
||||
context
|
||||
.search_api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|key| !key.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
std::env::var("DEEPSEEK_API_KEY")
|
||||
.ok()
|
||||
.map(|key| key.trim().to_string())
|
||||
.filter(|key| !key.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
/// Reject misconfiguration before cache lookup or network access.
|
||||
fn preflight_search_provider(context: &ToolContext) -> Result<(), ToolError> {
|
||||
let configured_key = context
|
||||
@@ -1843,6 +1920,9 @@ fn preflight_search_provider(context: &ToolContext) -> Result<(), ToolError> {
|
||||
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::DeepSeek if deepseek_search_key(context).is_none() => not_configured(
|
||||
"DeepSeek search is not configured: it requires an API key. Set `[search] api_key` in config.toml or the DEEPSEEK_API_KEY env var.",
|
||||
),
|
||||
SearchProvider::Searxng
|
||||
if configured_search_base_url(context.search_base_url.as_deref()).is_none() =>
|
||||
{
|
||||
@@ -1892,6 +1972,7 @@ const fn default_backend_host(backend: BackendId) -> Option<&'static str> {
|
||||
BackendId::Volcengine => Some("ark.cn-beijing.volces.com"),
|
||||
BackendId::Sofya => Some("sofya.co"),
|
||||
BackendId::Serply => Some("api.serply.io"),
|
||||
BackendId::DeepSeek => Some("api.deepseek.com"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2271,6 +2352,19 @@ pub(crate) async fn run_backend_search(
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
SearchProvider::DeepSeek => {
|
||||
check_policy(context.network_policy.as_ref(), "api.deepseek.com")?;
|
||||
Ok(BackendSearch {
|
||||
backend: BackendId::DeepSeek,
|
||||
source: "deepseek".to_string(),
|
||||
backend_detail: None,
|
||||
results: tool
|
||||
.run_deepseek_search(&query.query, max_results, timeout_ms, context)
|
||||
.await?,
|
||||
degraded: Vec::new(),
|
||||
note: None,
|
||||
})
|
||||
}
|
||||
SearchProvider::Bing | SearchProvider::DuckDuckGo => {
|
||||
run_scrape_search(provider, query, timeout_ms, context).await
|
||||
}
|
||||
@@ -3046,6 +3140,30 @@ fn parse_serply_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntr
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_deepseek_results(parsed: &Value, max_results: usize) -> Vec<SearchResult> {
|
||||
parsed
|
||||
.get("results")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|item| {
|
||||
let title = first_non_empty_string(item, &["title"])?;
|
||||
let url = first_non_empty_string(item, &["url"])?;
|
||||
Some((
|
||||
title,
|
||||
url,
|
||||
first_non_empty_string(item, &["snippet", "summary"]),
|
||||
first_non_empty_string(item, &["date"]),
|
||||
))
|
||||
})
|
||||
.take(max_results)
|
||||
.enumerate()
|
||||
.map(|(index, (title, url, snippet, published))| {
|
||||
SearchResult::new(index + 1, title, url, snippet, published)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn first_non_empty_string(item: &Value, keys: &[&str]) -> Option<String> {
|
||||
keys.iter().find_map(|key| {
|
||||
item.get(*key)
|
||||
@@ -3836,6 +3954,10 @@ mod tests {
|
||||
),
|
||||
(SearchProvider::Sofya, "https://sofya.co/v1/search"),
|
||||
(SearchProvider::Serply, "https://api.serply.io/v1/search"),
|
||||
(
|
||||
SearchProvider::DeepSeek,
|
||||
"https://api.deepseek.com/v1/tools/search",
|
||||
),
|
||||
];
|
||||
|
||||
for (provider, expected) in cases {
|
||||
|
||||
+13
-1
@@ -3323,6 +3323,7 @@ ignored:
|
||||
| Tavily | `time_range` | not sent (Tavily takes country names) |
|
||||
| SearXNG | `time_range` | `language`, as given |
|
||||
| Serply | not sent (undocumented) | `hl` language, `gl` country |
|
||||
| DeepSeek | not sent | not sent (`localization` takes an IP address) |
|
||||
|
||||
Recency is rounded up to the backend's nearest window (day, week, month,
|
||||
year), so `recency = 10` searches the last month. Other backends ignore both
|
||||
@@ -3391,6 +3392,17 @@ 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.
|
||||
|
||||
**DeepSeek** ([docs](https://api-docs.deepseek.com/guides/search)) uses
|
||||
`POST https://api.deepseek.com/v1/tools/search`. Set `provider = "deepseek"`
|
||||
(`deep-seek` and `deepseek-search` are also accepted) and supply `[search] api_key`
|
||||
or the `DEEPSEEK_API_KEY` env var, in that order. The saved DeepSeek model-provider
|
||||
key is not reused by this search backend. Queries are truncated to 128 Unicode
|
||||
characters. The backend clamps counts to 1–50; the shared search tool currently
|
||||
caps requests at 10 results. Query operators such as `site:`, `lang:`,
|
||||
`after:`, and `before:` are passed through; the `recency` and `locale` knobs are
|
||||
ignored. Results include title, URL, and snippet, falling back to summary when a
|
||||
snippet is empty.
|
||||
|
||||
**Tavily** ([tavily.com](https://tavily.com)) is selected automatically when a
|
||||
Tavily key is present and no provider is pinned: `TAVILY_API_KEY` set, or
|
||||
`[search] api_key` / `CODEWHALE_SEARCH_API_KEY` in the `tvly-` family. Doctor
|
||||
@@ -3404,7 +3416,7 @@ any non-empty `[search] api_key` and is configured by that key or
|
||||
|
||||
```toml
|
||||
[search]
|
||||
provider = "firecrawl" # also duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply
|
||||
provider = "firecrawl" # also duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply | deepseek
|
||||
# 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
|
||||
# native = false # provider-native search: unset = only when no provider is configured
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ owner = "crates/tui/src/tools/subagent"
|
||||
[[feature]]
|
||||
id = "web-search"
|
||||
name = "Web search"
|
||||
summary = "Searches and fetches the web for the agent (web.run, with the web_search alias)."
|
||||
summary = "Searches and fetches the web for the agent, including DeepSeek Search (web.run, with the web_search alias)."
|
||||
status = "stable"
|
||||
surfaces = { tui = "stable" }
|
||||
since = "0.1.0"
|
||||
|
||||
Reference in new issue
Block a user