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:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-18 22:31:50 -07:00
1 parent d5d7fd698f
commit 85688ac230
10 files changed
+174 -9

No files matched your search

+5
View File
@@ -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
+1
View File
@@ -534,6 +534,7 @@ pub enum CommandSearchProvider {
Volcengine,
Sofya,
Serply,
DeepSeek,
}
/// Operator-notification delivery method.
+5
View File
@@ -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
+2
View File
@@ -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,
}
}
+7 -2
View File
@@ -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 -3
View File
@@ -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\""));
+3
View File
@@ -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",
}
}
}
+124 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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"