From 87ea320f0346d18ad5dddf75bca89e041d38f5ac Mon Sep 17 00:00:00 2001 From: Valeriy Selitskiy <239034+iamwavecut@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:32:27 +0100 Subject: [PATCH] feat: add DeepSeek web search provider --- .env.example | 4 + README.md | 11 +- crates/openplotva-app/src/deepseek.rs | 441 ++++++++++++++++++++++++++ crates/openplotva-app/src/lib.rs | 59 +++- crates/openplotva-app/src/serper.rs | 28 +- crates/openplotva-config/src/lib.rs | 88 +++++ 6 files changed, 612 insertions(+), 19 deletions(-) create mode 100644 crates/openplotva-app/src/deepseek.rs diff --git a/.env.example b/.env.example index bae54a6f..b863f7df 100644 --- a/.env.example +++ b/.env.example @@ -131,8 +131,12 @@ # Search, translation, and browser fetch providers # ----------------------------------------------------------------------------- # MARKET_RATES_TIMEOUT_SECONDS=15 +# WEB_SEARCH_PROVIDER=serper # SERPER_API_KEY= # SERPER_TIMEOUT_SECONDS=30 +# Select deepseek with WEB_SEARCH_PROVIDER=deepseek. Serper remains the default. +# DEEPSEEK_API_KEY= +# DEEPSEEK_SEARCH_TIMEOUT_SECONDS=30 # DEEPL_KEY= # DEEPL_URL= diff --git a/README.md b/README.md index 0601c28b..8bf14ea7 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,16 @@ The app reads `.env` files and environment variables. Important groups: - `ADMINS_ADMIN_IDS` for administrative Telegram users. - `RUNTIME_API_*` for the optional diagnostic API. - `PERSISTENT_QUEUE_*` for worker and queue behavior. -- `DISCOVERY_*`, `DIALOG_*`, `GOOGLEAI_*`, `OPENROUTER_*`, `ACESTEP_*`, `MEMORY_*`, `SHIELD_*`, `VISION_*`, and `SERPER_*` for provider integrations. +- `DISCOVERY_*`, `DIALOG_*`, `GOOGLEAI_*`, `OPENROUTER_*`, `ACESTEP_*`, `MEMORY_*`, `SHIELD_*`, `VISION_*`, `SERPER_*`, and `DEEPSEEK_*` for provider integrations. + +Web search uses Serper by default. Set `WEB_SEARCH_PROVIDER=deepseek` and +`DEEPSEEK_API_KEY` to use the [DeepSeek Search API](https://api-docs.deepseek.com/guides/search/). +`DEEPSEEK_SEARCH_TIMEOUT_SECONDS` defaults to 30 and bounds the complete request, +including retries. Queries must contain 1 to 128 Unicode characters. Results +include source titles, URLs, snippets, and summaries. Empty results are valid. +The selected provider requires its own key; requests never switch providers on failure. +`crawl_url` continues to fetch pages directly over HTTP and needs no Serper key +when DeepSeek is selected. `SERPER_TIMEOUT_SECONDS` still controls these page fetches. Service connections are opt-in by default for local shell runs: diff --git a/crates/openplotva-app/src/deepseek.rs b/crates/openplotva-app/src/deepseek.rs new file mode 100644 index 00000000..d8afc439 --- /dev/null +++ b/crates/openplotva-app/src/deepseek.rs @@ -0,0 +1,441 @@ +//! DeepSeek Search API adapter for dialog tools. + +use std::{fmt, sync::Arc, time::Duration}; + +use openplotva_config::DeepSeekConfig; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::dialog_tools::{WebSearchFuture, WebSearchProvider}; + +const SEARCH_ENDPOINT: &str = "https://api.deepseek.com/v1/tools/search"; +const MAX_RESPONSE_BYTES: usize = 256 * 1024; + +#[derive(Clone)] +pub struct DeepSeekClient { + http: reqwest::Client, + api_key: Arc, + endpoint: Arc, + timeout: Duration, +} + +impl fmt::Debug for DeepSeekClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DeepSeekClient").finish_non_exhaustive() + } +} + +#[derive(Debug, Error)] +pub enum DeepSeekError { + #[error("DeepSeek search query must contain 1 to 128 Unicode characters")] + InvalidQuery, + #[error("DeepSeek search timed out")] + Timeout, + #[error("DeepSeek search HTTP status {0}")] + Status(reqwest::StatusCode), + #[error("DeepSeek search transport error: {0}")] + Transport(#[source] reqwest::Error), + #[error("DeepSeek search returned an invalid response")] + InvalidResponse, +} + +#[derive(Serialize)] +struct SearchRequest<'a> { + query: &'a str, + count: usize, + need_content: bool, +} + +#[derive(Deserialize, Serialize)] +struct SearchResponse { + results: Vec, +} + +#[derive(Deserialize, Serialize)] +struct SearchResult { + title: String, + url: String, + snippet: String, + summary: String, + #[serde(flatten)] + metadata: serde_json::Map, +} + +impl DeepSeekClient { + pub fn from_config(config: &DeepSeekConfig) -> Result, DeepSeekError> { + Self::with_endpoint(config, SEARCH_ENDPOINT) + } + + fn with_endpoint( + config: &DeepSeekConfig, + endpoint: &str, + ) -> Result, DeepSeekError> { + if config.api_key.trim().is_empty() { + return Ok(None); + } + let timeout = Duration::from_secs(config.timeout_seconds.max(1) as u64); + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(timeout) + .build() + .map_err(DeepSeekError::Transport)?; + Ok(Some(Self { + http, + api_key: Arc::from(config.api_key.trim()), + endpoint: Arc::from(endpoint), + timeout, + })) + } + + pub async fn search(&self, query: &str) -> Result { + let query = query.trim(); + if query.is_empty() || query.chars().count() > 128 { + return Err(DeepSeekError::InvalidQuery); + } + tokio::time::timeout(self.timeout, self.search_with_retry(query)) + .await + .map_err(|_| DeepSeekError::Timeout)? + } + + async fn search_with_retry(&self, query: &str) -> Result { + for attempt in 0..3 { + if attempt > 0 { + tokio::time::sleep(Duration::from_secs(attempt)).await; + } + let response = self + .http + .post(&*self.endpoint) + .bearer_auth(&*self.api_key) + .json(&SearchRequest { + query, + count: 10, + need_content: false, + }) + .send() + .await; + let mut response = match response { + Ok(response) => response, + Err(error) if attempt < 2 && (error.is_connect() || error.is_timeout()) => continue, + Err(error) => return Err(DeepSeekError::Transport(error.without_url())), + }; + let status = response.status(); + if !status.is_success() { + if attempt < 2 + && (status.is_server_error() + || status == reqwest::StatusCode::REQUEST_TIMEOUT + || status == reqwest::StatusCode::TOO_MANY_REQUESTS) + { + continue; + } + return Err(DeepSeekError::Status(status)); + } + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| DeepSeekError::Transport(error.without_url()))? + { + if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES { + return Err(DeepSeekError::InvalidResponse); + } + body.extend_from_slice(&chunk); + } + let result: SearchResponse = + serde_json::from_slice(&body).map_err(|_| DeepSeekError::InvalidResponse)?; + return serde_json::to_string(&result).map_err(|_| DeepSeekError::InvalidResponse); + } + unreachable!("each final attempt returns its result") + } +} + +impl WebSearchProvider for DeepSeekClient { + fn search<'a>(&'a self, query: &'a str) -> WebSearchFuture<'a> { + Box::pin(async move { self.search(query).await.map_err(Into::into) }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + Json, Router, + extract::State, + http::{HeaderMap, StatusCode}, + routing::post, + }; + use serde_json::{Value, json}; + use std::{collections::VecDeque, sync::Mutex}; + + type TestResult = Result<(), Box>; + const RESULT: &str = r#"{"results":[{"rerank_score":2.4,"title":"Source title","url":"https://example.com/article","snippet":"Short snippet","summary":"Longer source summary","content":"","site_name":null,"site_icon":null,"date":null,"authority":3}]}"#; + + #[derive(Default)] + struct FixtureState { + responses: Mutex>, + requests: Mutex>, + delay: Duration, + } + + struct Fixture { + state: Arc, + client: DeepSeekClient, + task: tokio::task::JoinHandle<()>, + } + + impl Drop for Fixture { + fn drop(&mut self) { + self.task.abort(); + } + } + + async fn fixture( + responses: Vec<(StatusCode, String)>, + delay: Duration, + timeout: i32, + ) -> Result> { + let state = Arc::new(FixtureState { + responses: Mutex::new(responses.into()), + delay, + ..FixtureState::default() + }); + let router = Router::new() + .route( + "/v1/tools/search", + post( + async |State(state): State>, + headers: HeaderMap, + Json(body): Json| { + state + .requests + .lock() + .expect("requests lock") + .push((headers, body)); + tokio::time::sleep(state.delay).await; + state + .responses + .lock() + .expect("responses lock") + .pop_front() + .expect("unexpected retry") + }, + ), + ) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let endpoint = format!("http://{}/v1/tools/search", listener.local_addr()?); + let client = DeepSeekClient::with_endpoint( + &DeepSeekConfig { + api_key: " deepseek-test-secret ".to_owned(), + timeout_seconds: timeout, + }, + &endpoint, + )? + .expect("configured client"); + let task = tokio::spawn(async move { + axum::serve(listener, router).await.expect("fixture server"); + }); + Ok(Fixture { + state, + client, + task, + }) + } + + #[tokio::test] + async fn search_posts_documented_contract_and_returns_sources_through_provider_trait() + -> TestResult { + let fixture = fixture(vec![(StatusCode::OK, RESULT.to_owned())], Duration::ZERO, 5).await?; + let result = + WebSearchProvider::search(&fixture.client, " DeepSeek site:example.com lang:ru ") + .await?; + let sources: Value = serde_json::from_str(&result)?; + assert_eq!(sources["results"][0]["title"], "Source title"); + assert_eq!(sources["results"][0]["url"], "https://example.com/article"); + assert_eq!(sources["results"][0]["snippet"], "Short snippet"); + assert_eq!(sources["results"][0]["summary"], "Longer source summary"); + assert_eq!(sources["results"][0]["authority"], 3); + assert_eq!(sources["results"][0]["rerank_score"], 2.4); + let requests = fixture.state.requests.lock().expect("requests lock"); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].0["authorization"], + "Bearer deepseek-test-secret" + ); + assert_eq!(requests[0].0["content-type"], "application/json"); + assert_eq!( + requests[0].1, + json!({"query":"DeepSeek site:example.com lang:ru", "count":10, "need_content":false}) + ); + Ok(()) + } + + #[tokio::test] + async fn query_limit_counts_unicode_characters_and_rejects_invalid_queries_locally() + -> TestResult { + let fixture = fixture( + vec![(StatusCode::OK, "{\"results\":[]}".to_owned())], + Duration::ZERO, + 5, + ) + .await?; + for query in ["".to_owned(), " \n ".to_owned(), "я".repeat(129)] { + assert!(matches!( + fixture.client.search(&query).await, + Err(DeepSeekError::InvalidQuery) + )); + } + assert!( + fixture + .state + .requests + .lock() + .expect("requests lock") + .is_empty() + ); + assert_eq!( + fixture.client.search(&"я".repeat(128)).await?, + "{\"results\":[]}" + ); + Ok(()) + } + + #[tokio::test] + async fn retries_rate_limit_and_server_errors_then_returns_results() -> TestResult { + let fixture = fixture( + vec![ + (StatusCode::SERVICE_UNAVAILABLE, String::new()), + (StatusCode::TOO_MANY_REQUESTS, String::new()), + (StatusCode::OK, RESULT.to_owned()), + ], + Duration::ZERO, + 10, + ) + .await?; + assert!( + fixture + .client + .search("query") + .await? + .contains("Source title") + ); + assert_eq!( + fixture.state.requests.lock().expect("requests lock").len(), + 3 + ); + Ok(()) + } + + #[tokio::test] + async fn does_not_retry_permanent_errors_or_expose_response_bodies() -> TestResult { + for status in [ + StatusCode::BAD_REQUEST, + StatusCode::UNAUTHORIZED, + StatusCode::PAYMENT_REQUIRED, + StatusCode::FORBIDDEN, + StatusCode::FOUND, + ] { + let fixture = fixture( + vec![(status, "deepseek-test-secret private error".to_owned())], + Duration::ZERO, + 5, + ) + .await?; + let error = fixture + .client + .search("query") + .await + .expect_err("HTTP error"); + assert!(matches!(error, DeepSeekError::Status(actual) if actual == status)); + assert!(!error.to_string().contains("secret")); + assert_eq!( + fixture.state.requests.lock().expect("requests lock").len(), + 1 + ); + } + Ok(()) + } + + #[tokio::test] + async fn stops_after_three_retryable_failures() -> TestResult { + let fixture = fixture( + vec![(StatusCode::INTERNAL_SERVER_ERROR, String::new()); 3], + Duration::ZERO, + 10, + ) + .await?; + assert!(matches!( + fixture.client.search("query").await, + Err(DeepSeekError::Status(StatusCode::INTERNAL_SERVER_ERROR)) + )); + assert_eq!( + fixture.state.requests.lock().expect("requests lock").len(), + 3 + ); + Ok(()) + } + + #[tokio::test] + async fn rejects_malformed_and_oversized_success_responses() -> TestResult { + for body in [ + "not JSON".to_owned(), + "{}".to_owned(), + "{\"results\":[{\"title\":\"missing URL\"}]}".to_owned(), + " ".repeat(MAX_RESPONSE_BYTES + 1), + ] { + let fixture = fixture(vec![(StatusCode::OK, body)], Duration::ZERO, 5).await?; + assert!(matches!( + fixture.client.search("query").await, + Err(DeepSeekError::InvalidResponse) + )); + assert_eq!( + fixture.state.requests.lock().expect("requests lock").len(), + 1 + ); + } + Ok(()) + } + + #[tokio::test] + async fn total_timeout_bounds_retries_and_slow_responses() -> TestResult { + let fixture = fixture( + vec![(StatusCode::SERVICE_UNAVAILABLE, String::new())], + Duration::ZERO, + 1, + ) + .await?; + assert!(matches!( + fixture.client.search("query").await, + Err(DeepSeekError::Timeout) + )); + assert_eq!( + fixture.state.requests.lock().expect("requests lock").len(), + 1 + ); + let fixture = self::fixture( + vec![(StatusCode::OK, RESULT.to_owned())], + Duration::from_secs(2), + 1, + ) + .await?; + assert!(fixture.client.search("query").await.is_err()); + Ok(()) + } + + #[test] + fn missing_key_disables_client_and_debug_hides_credentials() -> TestResult { + assert!( + DeepSeekClient::from_config(&DeepSeekConfig { + api_key: " \n ".to_owned(), + timeout_seconds: 30 + })? + .is_none() + ); + let client = DeepSeekClient::from_config(&DeepSeekConfig { + api_key: "deepseek-test-secret".to_owned(), + timeout_seconds: 30, + })? + .expect("configured client"); + assert!(!format!("{client:?}").contains("secret")); + Ok(()) + } +} diff --git a/crates/openplotva-app/src/lib.rs b/crates/openplotva-app/src/lib.rs index 3b710092..88d8e9a6 100644 --- a/crates/openplotva-app/src/lib.rs +++ b/crates/openplotva-app/src/lib.rs @@ -7,6 +7,7 @@ pub mod asr; pub mod callbacks; pub mod checkin; pub mod control_jobs; +pub mod deepseek; pub mod delete_drawing; pub mod delete_lyrics; pub mod delete_message; @@ -12759,7 +12760,7 @@ async fn start_runtime_workers( Ok(Some(client)) => { readiness_checks.push(ReadinessCheck::ok( "serper", - "Serper web_search/crawl_url dialog tools wired as the primary search provider", + "Serper HTTP adapter configured", )); Some(Arc::new(client)) } @@ -12821,11 +12822,59 @@ async fn start_runtime_workers( app_dialog_toolbox = app_dialog_toolbox.with_youtube_summarizer(youtube_summarizer); } if let Some(serper) = serper_client.as_ref() { - let web_searcher: Arc = serper.clone(); let url_crawler: Arc = serper.clone(); - app_dialog_toolbox = app_dialog_toolbox - .with_web_searcher(web_searcher) - .with_url_crawler(url_crawler); + app_dialog_toolbox = app_dialog_toolbox.with_url_crawler(url_crawler); + } + match config.web_search_provider { + openplotva_config::SearchProvider::Serper => { + if let Some(serper) = serper_client { + app_dialog_toolbox = app_dialog_toolbox.with_web_searcher(serper); + readiness_checks.push(ReadinessCheck::ok( + "web_search", + "Serper search provider wired", + )); + } else { + readiness_checks.push(ReadinessCheck::skipped( + "web_search", + "SERPER_API_KEY is not configured", + )); + } + } + openplotva_config::SearchProvider::Deepseek => { + match deepseek::DeepSeekClient::from_config(&config.deepseek) { + Ok(Some(client)) => { + app_dialog_toolbox = app_dialog_toolbox.with_web_searcher(Arc::new(client)); + readiness_checks.push(ReadinessCheck::ok( + "web_search", + "DeepSeek search provider wired", + )); + } + Ok(None) => readiness_checks.push(ReadinessCheck::skipped( + "web_search", + "DEEPSEEK_API_KEY is not configured", + )), + Err(error) => { + tracing::warn!(%error, "DeepSeek search provider unavailable"); + readiness_checks.push(ReadinessCheck::skipped( + "web_search", + format!("DeepSeek unavailable: {error}"), + )); + } + } + if serper_client.is_none() { + match serper::SerperClient::for_crawling(config.serper.timeout_seconds) { + Ok(crawler) => { + app_dialog_toolbox = app_dialog_toolbox.with_url_crawler(Arc::new(crawler)) + } + Err(error) => { + readiness_checks.push(ReadinessCheck::skipped( + "crawl_url", + format!("HTTP crawler unavailable: {error}"), + )); + } + } + } + } } if config.sandbox.url.is_empty() { readiness_checks.push(ReadinessCheck::skipped( diff --git a/crates/openplotva-app/src/serper.rs b/crates/openplotva-app/src/serper.rs index 80281cf8..2f64be66 100644 --- a/crates/openplotva-app/src/serper.rs +++ b/crates/openplotva-app/src/serper.rs @@ -57,8 +57,16 @@ impl SerperClient { if api_key.is_empty() { return Ok(None); } - let timeout_seconds = if config.timeout_seconds > 0 { - config.timeout_seconds + Self::build(api_key, config.timeout_seconds, base_url).map(Some) + } + + pub fn for_crawling(timeout_seconds: i32) -> Result { + Self::build("", timeout_seconds, SERPER_BASE_URL) + } + + fn build(api_key: &str, timeout_seconds: i32, base_url: &str) -> Result { + let timeout_seconds = if timeout_seconds > 0 { + timeout_seconds } else { DEFAULT_SERPER_TIMEOUT_SECONDS }; @@ -68,12 +76,12 @@ impl SerperClient { .timeout(timeout) .build() .map_err(SerperError::HttpClient)?; - Ok(Some(Self { + Ok(Self { http, api_key: Arc::from(api_key), base_url: Arc::from(base_url.trim_end_matches('/')), timeout, - })) + }) } pub async fn search(&self, query: &str) -> Result { @@ -608,15 +616,9 @@ mod tests { } #[tokio::test] - async fn crawl_rejects_private_and_loopback_destinations() -> Result<(), Box> { - let config = AppConfig::from_raw(RawConfig { - serper_api_key: Some("serper-test-key".to_owned()), - serper_timeout_seconds: Some("1".to_owned()), - ..RawConfig::default() - })?; - let client = - SerperClient::from_config_with_base_url(&config.serper, "https://google.serper.dev")? - .ok_or_else(|| std::io::Error::other("Serper client was not built"))?; + async fn crawler_without_serper_key_rejects_private_and_loopback_destinations() + -> Result<(), Box> { + let client = SerperClient::for_crawling(1)?; for url in [ "http://127.0.0.1/private", diff --git a/crates/openplotva-config/src/lib.rs b/crates/openplotva-config/src/lib.rs index 3ef52558..78116e92 100644 --- a/crates/openplotva-config/src/lib.rs +++ b/crates/openplotva-config/src/lib.rs @@ -448,6 +448,8 @@ pub struct AppConfig { pub market_rates: MarketRatesConfig, /// Serper web search provider configuration. pub serper: SerperConfig, + pub web_search_provider: SearchProvider, + pub deepseek: DeepSeekConfig, /// Translation provider configuration. pub translation: TranslationConfig, pub google_ai: GoogleAiConfig, @@ -731,6 +733,19 @@ pub struct SerperConfig { pub timeout_seconds: i32, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SearchProvider { + Serper, + Deepseek, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct DeepSeekConfig { + pub api_key: String, + pub timeout_seconds: i32, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct TranslationConfig { /// DeepL API-compatible configuration, from `DEEPL_*`. @@ -1319,6 +1334,12 @@ pub struct RawConfig { pub serper_api_key: Option, /// `SERPER_TIMEOUT_SECONDS`. pub serper_timeout_seconds: Option, + /// `WEB_SEARCH_PROVIDER` (`serper` or `deepseek`). + pub web_search_provider: Option, + /// `DEEPSEEK_API_KEY`. + pub deepseek_api_key: Option, + /// `DEEPSEEK_SEARCH_TIMEOUT_SECONDS`. + pub deepseek_search_timeout_seconds: Option, /// `DEEPL_KEY`. pub deepl_key: Option, /// `DEEPL_URL`. @@ -1697,6 +1718,8 @@ pub struct RawConfig { /// Configuration loading failures. #[derive(Debug, Error)] pub enum ConfigError { + #[error("invalid WEB_SEARCH_PROVIDER: expected serper or deepseek")] + InvalidSearchProvider, #[error("invalid maintenance configuration: {reason}")] InvalidMaintenance { reason: &'static str }, /// `.env` exists but could not be loaded. @@ -2149,6 +2172,27 @@ impl AppConfig { DEFAULT_SERPER_TIMEOUT_SECONDS, )?, }; + let web_search_provider = match raw + .web_search_provider + .as_deref() + .unwrap_or("serper") + .trim() + { + "serper" => SearchProvider::Serper, + "deepseek" => SearchProvider::Deepseek, + _ => return Err(ConfigError::InvalidSearchProvider), + }; + let deepseek = DeepSeekConfig { + api_key: raw.deepseek_api_key.unwrap_or_default().trim().to_owned(), + timeout_seconds: validate_positive_i32( + "DEEPSEEK_SEARCH_TIMEOUT_SECONDS", + parse_i32( + "DEEPSEEK_SEARCH_TIMEOUT_SECONDS", + raw.deepseek_search_timeout_seconds, + 30, + )?, + )?, + }; let boogu_image_timeout_seconds = validate_positive_i32( "BOOGU_IMAGE_TIMEOUT_SECONDS", parse_i32( @@ -2476,6 +2520,8 @@ impl AppConfig { )?, }, serper, + web_search_provider, + deepseek, translation: TranslationConfig { deepl: DeeplConfig { key: raw.deepl_key.unwrap_or_default(), @@ -3286,6 +3332,9 @@ impl RawConfig { rbc_timeout_seconds: env("RBC_TIMEOUT_SECONDS"), serper_api_key: env("SERPER_API_KEY"), serper_timeout_seconds: env("SERPER_TIMEOUT_SECONDS"), + web_search_provider: env("WEB_SEARCH_PROVIDER"), + deepseek_api_key: env("DEEPSEEK_API_KEY"), + deepseek_search_timeout_seconds: env("DEEPSEEK_SEARCH_TIMEOUT_SECONDS"), deepl_key: env("DEEPL_KEY"), deepl_url: env("DEEPL_URL"), googleai_key: env("GOOGLEAI_KEY"), @@ -4867,6 +4916,45 @@ mod tests { Ok(()) } + #[test] + fn web_search_defaults_preserve_serper() -> Result<(), Box> { + let config = AppConfig::from_raw(RawConfig::default())?; + let serialized = serde_json::to_value(config)?; + assert_eq!(serialized["web_search_provider"], "serper"); + assert_eq!(serialized["deepseek"]["timeout_seconds"], 30); + Ok(()) + } + + #[test] + fn web_search_selects_deepseek_and_validates_settings() -> Result<(), super::ConfigError> { + let config = AppConfig::from_raw(RawConfig { + web_search_provider: Some("deepseek".to_owned()), + deepseek_api_key: Some(" deepseek-key ".to_owned()), + deepseek_search_timeout_seconds: Some("7".to_owned()), + ..RawConfig::default() + })?; + assert_eq!(config.web_search_provider, super::SearchProvider::Deepseek); + assert_eq!(config.deepseek.api_key, "deepseek-key"); + assert_eq!(config.deepseek.timeout_seconds, 7); + assert!(matches!( + AppConfig::from_raw(RawConfig { + web_search_provider: Some("unknown".to_owned()), + ..RawConfig::default() + }), + Err(super::ConfigError::InvalidSearchProvider) + )); + for timeout in ["0", "-1", "invalid"] { + assert!( + AppConfig::from_raw(RawConfig { + deepseek_search_timeout_seconds: Some(timeout.to_owned()), + ..RawConfig::default() + }) + .is_err() + ); + } + Ok(()) + } + #[test] fn serper_config_loads_go_env_values() -> Result<(), super::ConfigError> { let config = AppConfig::from_raw(RawConfig {