use async_trait::async_trait;
use anyhow::{Context, Result};
use tracing::{debug, warn};
use crate::client::{HttpClient, DeepSeekClient, DEFAULT_BASE_URL};
use crate::error::DeepSeekError;
use crate::types::{ChatRequest, ChatResponse, ReasonerOutput};
#[derive(Clone)]
pub struct ReqwestClient {
client: reqwest::Client,
}
impl ReqwestClient {
pub fn new() -> Self {
Self { client: reqwest::Client::new() }
}
pub fn with_client(client: reqwest::Client) -> Self {
Self { client }
}
}
impl Default for ReqwestClient {
fn default() -> Self { Self::new() }
}
#[async_trait]
impl HttpClient for ReqwestClient {
async fn post_json(&self, url: &str, bearer_token: &str, body: &ChatRequest) -> crate::error::Result<ChatResponse> {
let resp = self
.client
.post(url)
.bearer_auth(bearer_token)
.json(body)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(DeepSeekError::Api { status: status.as_u16(), body: text });
}
let chat_resp: ChatResponse = resp.json().await?;
Ok(chat_resp)
}
}
const REASONER_MODEL: &str = "deepseek-reasoner";
pub fn client_from_env() -> Result<DeepSeekClient<ReqwestClient>> {
let api_key = std::env::var("DEEPSEEK_API_KEY")
.context("DEEPSEEK_API_KEY environment variable not set")?;
let base_url = std::env::var("DEEPSEEK_BASE_URL")
.unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
Ok(DeepSeekClient::new(ReqwestClient::new(), api_key).with_base_url(base_url))
}
pub async fn reason(
client: &DeepSeekClient<ReqwestClient>,
system: &str,
user: &str,
) -> Result<ReasonerOutput> {
debug!("deepseek-reasoner call: system={:.80}…", system);
let request = ChatRequest {
model: REASONER_MODEL.to_string(),
messages: vec![
crate::types::system_msg(system),
crate::types::user_msg(user),
],
tools: None,
tool_choice: None,
temperature: Some(0.6),
max_tokens: Some(8192),
stream: Some(false),
reasoning_effort: Some("high".to_string()),
thinking: Some(serde_json::json!({"type": "enabled"})),
};
let resp = client
.chat(&request)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let choice = resp
.choices
.into_iter()
.next()
.context("No choices in DeepSeek response")?;
Ok(ReasonerOutput {
reasoning: choice.message.reasoning_content.unwrap_or_default(),
content: choice.message.content.as_str().to_string(),
})
}
fn is_retryable(err: &anyhow::Error) -> bool {
let msg = err.to_string();
if let Some(rest) = msg.strip_prefix("API error (") {
if let Some(code_str) = rest.split(')').next() {
if let Ok(code) = code_str.parse::<u16>() {
return code >= 500;
}
}
}
msg.contains("HTTP error:") || msg.contains("connection") || msg.contains("timed out")
}
pub async fn reason_with_retry(
client: &DeepSeekClient<ReqwestClient>,
system: &str,
user: &str,
) -> Result<ReasonerOutput> {
let delays = [1, 2, 4]; let mut last_err = None;
for (attempt, &delay_secs) in std::iter::once(&0).chain(delays.iter()).enumerate() {
if attempt > 0 {
warn!("Retry attempt {attempt}/3 after {delay_secs}s backoff");
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
}
match reason(client, system, user).await {
Ok(output) => return Ok(output),
Err(e) => {
if !is_retryable(&e) || attempt == 3 {
return Err(e);
}
warn!("Retryable error: {e}");
last_err = Some(e);
}
}
}
Err(last_err.unwrap())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_from_env_missing_key() {
let original = std::env::var("DEEPSEEK_API_KEY").ok();
std::env::remove_var("DEEPSEEK_API_KEY");
let result = client_from_env();
let err = result.err().expect("should error when DEEPSEEK_API_KEY is unset");
assert!(
err.to_string().contains("DEEPSEEK_API_KEY"),
"error should mention the missing env var"
);
if let Some(val) = original {
std::env::set_var("DEEPSEEK_API_KEY", val);
}
}
#[test]
fn test_is_retryable_5xx() {
let err = anyhow::anyhow!("API error (500): Internal Server Error");
assert!(is_retryable(&err));
}
#[test]
fn test_is_not_retryable_4xx() {
let err = anyhow::anyhow!("API error (400): Bad Request");
assert!(!is_retryable(&err));
}
#[test]
fn test_is_retryable_network() {
let err = anyhow::anyhow!("HTTP error: connection refused");
assert!(is_retryable(&err));
}
}