Skip to main content

deepseek_rust_cli/api/
client.rs

1use std::{sync::Arc, time::Duration};
2
3use anyhow::Result;
4use reqwest::{Client, Response};
5
6use crate::api::types::{ChatRequest, Message, ResponseFormat, ThinkingConfig, Tool};
7
8/// High-performance HTTP client with connection pooling, HTTP/2, and retry logic.
9pub struct DeepSeekClient {
10    client: Client,
11    api_key: Arc<str>,
12    base_url: Arc<str>,
13}
14
15impl DeepSeekClient {
16    pub fn new(
17        api_key: String,
18        base_url: String,
19        timeout_secs: u64,
20        proxy_url: Option<String>,
21        proxy_username: Option<String>,
22        proxy_password: Option<String>,
23        danger_accept_invalid_certs: bool,
24    ) -> Self {
25        let mut builder = Client::builder()
26            .timeout(Duration::from_secs(timeout_secs))
27            // Connection pooling — reuse connections for multiple requests
28            .pool_idle_timeout(Some(Duration::from_secs(90)))
29            .pool_max_idle_per_host(4)
30            // TCP keep-alive for persistent connections
31            .tcp_keepalive(Some(Duration::from_secs(60)))
32            // Use HTTP/2 via ALPN negotiation (works behind proxies/firewalls)
33            .http2_adaptive_window(true)
34            // Auto-decompress responses
35            .gzip(true)
36            .brotli(true)
37            .zstd(true)
38            // Connection timeout
39            .connect_timeout(Duration::from_secs(10))
40            // User agent
41            .user_agent(concat!(
42                "deepseek-rust-cli/",
43                env!("CARGO_PKG_VERSION")
44            ));
45
46        // Accept invalid TLS certs (for corporate proxies / MITM appliances)
47        if danger_accept_invalid_certs {
48            tracing::warn!(
49                "DANGER: Accepting invalid TLS certificates (--danger-accept-invalid-certs)"
50            );
51            builder = builder.danger_accept_invalid_certs(true);
52        }
53
54        // Apply proxy configuration if provided
55        if let Some(ref proxy_url) = proxy_url {
56            let mut proxy = match reqwest::Proxy::all(proxy_url) {
57                Ok(p) => p,
58                Err(e) => {
59                    tracing::warn!("Invalid proxy URL '{}': {}", proxy_url, e);
60                    reqwest::Proxy::custom(|_url| None::<reqwest::Url>)
61                }
62            };
63
64            if let (Some(user), Some(pass)) = (&proxy_username, &proxy_password) {
65                proxy = proxy.basic_auth(user, pass);
66            }
67
68            tracing::info!("Using proxy: {}", proxy_url);
69            builder = builder.proxy(proxy);
70        }
71
72        let client = builder.build().unwrap_or_else(|_| Client::new());
73
74        Self {
75            client,
76            api_key: Arc::from(api_key),
77            base_url: Arc::from(base_url),
78        }
79    }
80
81    pub async fn chat_completions(
82        &self,
83        model: &str,
84        messages: Vec<Message>,
85        tools: Option<Vec<Tool>>,
86        options: crate::api::types::ChatOptions,
87    ) -> Result<Response> {
88        let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
89        tracing::info!("API request to: {}", url);
90        tracing::info!("Model: {}, Messages count: {}", model, messages.len());
91
92        let thinking_cfg = ThinkingConfig {
93            r#type: if options.thinking_enabled {
94                "enabled"
95            } else {
96                "disabled"
97            }
98            .to_string(),
99        };
100        let request = ChatRequest {
101            model: model.to_string(),
102            messages,
103            stream: true,
104            tools,
105            tool_choice: Some("auto".to_string()),
106            temperature: options.temperature,
107            top_p: options.top_p,
108            presence_penalty: options.presence_penalty,
109            frequency_penalty: options.frequency_penalty,
110            max_tokens: options.max_tokens,
111            thinking: Some(thinking_cfg),
112            reasoning_effort: options.reasoning_effort.clone(),
113            response_format: if options.json_mode {
114                Some(ResponseFormat {
115                    r#type: "json_object".to_string(),
116                })
117            } else {
118                None
119            },
120            stop: None,
121        };
122
123        let mut last_err = None;
124        // Exponential backoff: 500ms, 1s, 2s
125        for attempt in 0..3 {
126            if attempt > 0 {
127                tracing::info!("Retry attempt {}...", attempt + 1);
128                tokio::time::sleep(Duration::from_millis(500 * (1 << attempt))).await;
129            }
130
131            let response_res = self
132                .client
133                .post(&url)
134                .bearer_auth(self.api_key.as_ref())
135                .json(&request)
136                .send()
137                .await;
138
139            match response_res {
140                Ok(response) => {
141                    let status = response.status();
142                    tracing::info!("API response status: {}", status);
143                    if response.status().is_success() {
144                        return Ok(response);
145                    }
146                    let err_text = response.text().await.unwrap_or_default();
147                    tracing::error!("API error response: {}", err_text);
148
149                    if status.is_server_error() || status.as_u16() == 429 {
150                        last_err = Some(anyhow::anyhow!("API Error ({}): {}", status, err_text));
151                        continue;
152                    } else {
153                        anyhow::bail!("API Error ({}): {}", status, err_text);
154                    }
155                }
156                Err(e) => {
157                    tracing::error!("Network error on attempt {}: {:#}", attempt + 1, e);
158                    if e.is_timeout() {
159                        tracing::error!("Connection timed out");
160                    }
161                    if e.is_connect() {
162                        tracing::error!("Connection failed (DNS/TLS/refused)");
163                    }
164                    last_err = Some(anyhow::anyhow!("Network Error: {}", e));
165                    continue;
166                }
167            }
168        }
169
170        Err(last_err.unwrap_or_else(|| anyhow::anyhow!("API Request failed after retries")))
171    }
172}