//! Blocking HTTP client for OpenAI/Anthropic-compatible chat completion APIs, //! supporting both non-streaming and SSE-streaming requests with automatic retry. use rand_core::RngCore; use std::sync::atomic::AtomicBool; use std::time::Duration; use zesdex_domain::core::{ ChatMessage, ChatRequest, ChatResponse, SseParser, StreamEvent, StreamOptions, ToolDef, }; const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1"; const DEFAULT_MODEL: &str = "deepseek-v4-flash-free"; pub const DEFAULT_API_KEY: &str = ""; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); // --------------------------------------------------------------------------- // Retry helpers // --------------------------------------------------------------------------- fn backoff_seconds(attempt: u32, cap: u64) -> Duration { let base = 2u64.pow(attempt.saturating_sub(1)); let delay = std::cmp::min(base, cap); // ±25% jitter let jitter_factor = 0.75 + (rand_core::OsRng.next_u32() % 51) as f64 / 100.0; Duration::from_secs_f64(delay as f64 * jitter_factor) } /// Is the error an auth / billing failure that retrying won't fix? pub fn is_auth_error(err_str: &str) -> bool { let err_lower = err_str.to_lowercase(); (err_str.contains("API error 401") || err_str.contains("API error 402") || err_str.contains("API error 403")) || err_lower.contains("unauthorized") || err_lower.contains("forbidden") || err_lower.contains("authentication failed") } fn is_rate_limit(err_str: &str) -> bool { err_str.contains("API error 429") || err_str.to_lowercase().contains("rate limit") } fn backoff_for_error(attempt: u32, err_str: &str) -> Duration { if is_rate_limit(err_str) { backoff_seconds(attempt, 60) } else { backoff_seconds(attempt, 30) } } // --------------------------------------------------------------------------- // Client // --------------------------------------------------------------------------- /// Blocking HTTP client for a single LLM provider endpoint. pub struct LlmClient { pub client: reqwest::blocking::Client, pub api_key: String, pub base_url: String, pub model: String, } impl LlmClient { pub fn new(mut api_key: String, model: String, base_url: Option) -> Self { if api_key.is_empty() { api_key = DEFAULT_API_KEY.to_string(); } let model = if model.is_empty() { DEFAULT_MODEL.to_string() } else { model }; let client = match reqwest::blocking::Client::builder() .timeout(REQUEST_TIMEOUT) .connect_timeout(CONNECT_TIMEOUT) .build() { Ok(c) => c, Err(e) => { tracing::warn!( "failed to build reqwest client with connect timeout: {}. \ retrying without connect timeout", e, ); match reqwest::blocking::Client::builder() .timeout(REQUEST_TIMEOUT) .build() { Ok(c) => c, Err(e2) => { tracing::warn!("also failed: {e2}. using default client"); reqwest::blocking::Client::new() } } } }; LlmClient { client, api_key, base_url: base_url .filter(|s| !s.is_empty()) .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()), model, } } pub fn chat_with_tools_non_streaming( &self, messages: &[ChatMessage], tools: Option>, max_tokens: Option, temperature: Option, abort_flag: Option<&AtomicBool>, ) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> { let req = ChatRequest { model: self.model.clone(), messages: messages.to_vec(), max_tokens: Some(max_tokens.unwrap_or(4096)), temperature: Some(temperature.unwrap_or(0.7)), tools, stream: Some(false), stop: None, stream_options: None, tool_choice: None, top_p: None, }; let url = format!("{}/chat/completions", self.base_url); let max_retries = 10; let mut attempt = 0u32; loop { attempt += 1; if let Some(ref flag) = abort_flag { if flag.load(std::sync::atomic::Ordering::Relaxed) { anyhow::bail!("aborted"); } } let mut http_req = self .client .post(&url) .header("Content-Type", "application/json"); if !self.api_key.is_empty() { http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key)); } let result = (|| -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> { let resp = http_req.json(&req).send().map_err(|e| { if e.is_timeout() { anyhow::anyhow!( "API request timed out after {REQUEST_TIMEOUT:?}. \ Check your network or try again." ) } else if e.is_connect() { anyhow::anyhow!( "Could not connect to {}. \ Is the URL correct and is the service reachable?", self.base_url ) } else { anyhow::anyhow!("API request failed: {e}") } })?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); anyhow::bail!("API error {} from {}: {}", status, self.base_url, body); } let data: ChatResponse = resp.json()?; let usage = data.usage.map(|u| { (u64::from(u.prompt_tokens), u64::from(u.completion_tokens)) }); let message = data .choices .into_iter() .next() .and_then(|c| c.message) .ok_or_else(|| anyhow::anyhow!("API response had no choices"))?; Ok((message, usage)) })(); match result { Ok((msg, usage)) => return Ok((msg, usage)), Err(e) => { let err_str = e.to_string(); if attempt >= max_retries || is_auth_error(&err_str) { return Err(e); } let delay = backoff_for_error(attempt, &err_str); std::thread::sleep(delay); } } } } pub fn chat_with_tools_streaming( &self, messages: &[ChatMessage], tools: Option>, temperature: Option, max_tokens: Option, mut on_event: impl FnMut(&StreamEvent) -> bool, abort_flag: Option<&AtomicBool>, ) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> { let tools_for_fallback = tools.clone(); let req = ChatRequest { model: self.model.clone(), messages: messages.to_vec(), max_tokens: Some(max_tokens.unwrap_or(4096)), temperature: Some(temperature.unwrap_or(0.7)), tools, stream: Some(true), stop: None, stream_options: Some(StreamOptions { include_usage: true, }), tool_choice: None, top_p: None, }; let url = format!("{}/chat/completions", self.base_url); let max_retries_stream = 5; let mut attempt = 0u32; let mut meaningful_content = false; loop { attempt += 1; let mut captured_content = false; let mut wrapped = |event: &StreamEvent| -> bool { match event { StreamEvent::Token(_) | StreamEvent::Reasoning(_) => { captured_content = true; } _ => { tracing::debug!("unhandled stream event type in wrapped closure"); } } on_event(event) }; match self.try_stream_once(&req, &url, &mut wrapped) { Ok(result) => return Ok(result), Err(e) => { let err_str = e.to_string(); if is_auth_error(&err_str) { return Err(e); } if captured_content || (attempt >= max_retries_stream) { meaningful_content = captured_content || meaningful_content; break; } if attempt >= max_retries_stream { return Err(e); } let delay = backoff_for_error(attempt, &err_str); std::thread::sleep(delay); } } } if meaningful_content { if let Some(ref flag) = abort_flag { if flag.load(std::sync::atomic::Ordering::Relaxed) { return Err(anyhow::anyhow!("aborted")); } } return self.chat_with_tools_non_streaming( messages, tools_for_fallback, max_tokens, temperature, abort_flag, ); } Err(anyhow::anyhow!( "streaming request failed after {max_retries_stream} attempts" )) } fn try_stream_once( &self, req: &ChatRequest, url: &str, on_event: &mut dyn FnMut(&StreamEvent) -> bool, ) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> { use std::io::Read; let mut http_req = self .client .post(url) .header("Content-Type", "application/json"); if !self.api_key.is_empty() { http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key)); } let resp = http_req.json(req).send().map_err(|e| { if e.is_timeout() { anyhow::anyhow!( "API request timed out after {REQUEST_TIMEOUT:?}. \ Check your network or try again." ) } else if e.is_connect() { anyhow::anyhow!( "Could not connect to {}. \ Is the URL correct and is the service reachable?", self.base_url ) } else { anyhow::anyhow!("API request failed: {e}") } })?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); anyhow::bail!("API error {} from {}: {}", status, self.base_url, body); } struct StreamedTurn { content: String, tool_calls: Vec, done_received: bool, } impl StreamedTurn { fn new() -> Self { StreamedTurn { content: String::new(), tool_calls: Vec::new(), done_received: false, } } fn apply_event(&mut self, event: &StreamEvent) { match event { StreamEvent::Token(t) => self.content.push_str(t), StreamEvent::Reasoning(_) => {} StreamEvent::ToolCallDelta { index: _, id, name, arguments_delta, } => { let existing = self.tool_calls.iter_mut().find(|tc| { if let Some(ref id_val) = id { tc.id == *id_val } else { false } }); if let Some(tc) = existing { if let Some(ref n) = name { tc.function.name = n.clone(); } } else { self.tool_calls.push( zesdex_domain::core::ToolCall { id: id.clone().unwrap_or_default(), type_: "function".to_string(), function: zesdex_domain::core::ToolFunction { name: name.clone().unwrap_or_default(), arguments: serde_json::Value::String(arguments_delta.clone()), }, }, ); } } _ => { tracing::debug!("unhandled stream event in apply_event: {event:?}"); } } } fn build_assistant_message(self) -> ChatMessage { ChatMessage { role: zesdex_domain::core::Role::Assistant, content: if self.content.is_empty() { None } else { Some(self.content) }, tool_calls: if self.tool_calls.is_empty() { None } else { Some(self.tool_calls) }, tool_call_id: None, name: None, } } } let mut turn = StreamedTurn::new(); let mut usage: Option<(u64, u64)> = None; let mut parser = SseParser::new(); let mut reader = resp; let mut byte_buf: Vec = Vec::new(); let mut chunk_buf = [0u8; 4096]; loop { let n = reader.read(&mut chunk_buf)?; if n == 0 { break; } byte_buf.extend_from_slice(&chunk_buf[..n]); let valid_len = match std::str::from_utf8(&byte_buf) { Ok(s) => s.len(), Err(e) => e.valid_up_to(), }; if valid_len == 0 { continue; } let text = String::from_utf8_lossy(&byte_buf[..valid_len]).into_owned(); byte_buf.drain(..valid_len); for event in parser.feed(&text) { if !on_event(&event) { anyhow::bail!("aborted"); } match &event { StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => { usage = Some((*prompt_tokens, *completion_tokens)); } StreamEvent::Error(msg) => { anyhow::bail!("stream error: {msg}"); } StreamEvent::Done => { turn.apply_event(&event); turn.done_received = true; return Ok((turn.build_assistant_message(), usage)); } other => { tracing::debug!("unhandled stream event type: {other:?}"); turn.apply_event(other); } } } } Ok((turn.build_assistant_message(), usage)) } } /// Resolve the API key for the currently configured provider, falling back /// through settings -> env var -> provider default. pub fn resolve_api_key( settings: &zesdex_domain::cms::Settings, app_config: &zesdex_domain::cms::AppConfig, ) -> String { let provider = &settings.provider; let mut api_key = settings .api_keys .get(provider) .cloned() .unwrap_or_default(); if api_key.is_empty() { if let Some(provider_cfg) = app_config.providers.get(provider) { api_key = provider_cfg .api_key_env .as_ref() .and_then(|env| std::env::var(env).ok()) .or_else(|| provider_cfg.default_api_key.clone()) .unwrap_or_default(); } } api_key }