返回 DeepSeek-TUI-2026
client.rs
根目录 / crates / tui / src / client.rs
1 //! HTTP client for DeepSeek's OpenAI-compatible Chat Completions API.
2 //!
3 //! DeepSeek documents `/chat/completions` as the primary endpoint, and this
4 //! client now routes all normal traffic through that surface.
5
6 use std::collections::HashMap;
7 use std::sync::{Arc, Mutex as StdMutex, OnceLock};
8 use std::time::{Duration, Instant};
9
10 use anyhow::{Context, Result};
11 use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
12 use serde::{Deserialize, Serialize};
13 use serde_json::{Value, json};
14 use tokio::sync::Mutex as AsyncMutex;
15
16 use crate::config::{ApiProvider, Config, RetryPolicy};
17 use crate::llm_client::{
18 LlmClient, LlmError, RetryConfig as LlmRetryConfig, extract_retry_after, with_retry,
19 };
20 use crate::logging;
21 use crate::models::{MessageRequest, MessageResponse, ServerToolUsage, SystemPrompt, Usage};
22
23 pub(super) fn to_api_tool_name(name: &str) -> String {
24 let mut out = String::new();
25 for ch in name.chars() {
26 if ch.is_ascii_alphanumeric() || ch == '_' {
27 out.push(ch);
28 } else if ch == '-' {
29 out.push_str("--");
30 } else {
31 out.push_str("-x");
32 out.push_str(&format!("{:06X}", ch as u32));
33 out.push('-');
34 }
35 }
36 out
37 }
38
39 pub(super) fn from_api_tool_name(name: &str) -> String {
40 let mut out = String::new();
41 let mut iter = name.chars().peekable();
42 while let Some(ch) = iter.next() {
43 if ch != '-' {
44 out.push(ch);
45 continue;
46 }
47 if let Some('-') = iter.peek().copied() {
48 iter.next();
49 out.push('-');
50 continue;
51 }
52 if iter.peek().copied() == Some('x') {
53 iter.next();
54 let mut hex = String::new();
55 for _ in 0..6 {
56 if let Some(h) = iter.next() {
57 hex.push(h);
58 } else {
59 break;
60 }
61 }
62 if let Ok(code) = u32::from_str_radix(&hex, 16)
63 && let Some(decoded) = std::char::from_u32(code)
64 {
65 if let Some('-') = iter.peek().copied() {
66 iter.next();
67 }
68 out.push(decoded);
69 continue;
70 }
71 out.push('-');
72 out.push('x');
73 out.push_str(&hex);
74 continue;
75 }
76 out.push('-');
77 }
78
79 // Second pass: decode bare hex escapes (e.g. `x00002E`) that the model
80 // may produce when it mangles the `-x00002E-` delimiter form. Only
81 // decode when the resulting character is one that `to_api_tool_name`
82 // would have encoded (not alphanumeric, not `_`, not `-`).
83 decode_bare_hex_escapes(&out)
84 }
85
86 /// Decode bare `x[0-9A-Fa-f]{6}` sequences (optionally followed by `-`)
87 /// that survive the standard delimiter-based pass. This handles cases
88 /// where the model strips or replaces the leading `-` of `-x00002E-`.
89 pub(super) fn decode_bare_hex_escapes(input: &str) -> String {
90 use regex::Regex;
91 use std::sync::OnceLock;
92
93 static RE: OnceLock<Regex> = OnceLock::new();
94 let re = RE.get_or_init(|| Regex::new(r"x([0-9A-Fa-f]{6})-?").unwrap());
95
96 let result = re.replace_all(input, |caps: &regex::Captures| {
97 let hex = &caps[1];
98 if let Ok(code) = u32::from_str_radix(hex, 16)
99 && let Some(decoded) = std::char::from_u32(code)
100 {
101 // Only decode characters that to_api_tool_name would have encoded
102 if !decoded.is_ascii_alphanumeric() && decoded != '_' && decoded != '-' {
103 return decoded.to_string();
104 }
105 }
106 // Not a character we'd encode — leave as-is
107 caps[0].to_string()
108 });
109 result.into_owned()
110 }
111
112 // === Types ===
113
114 /// Model descriptor returned by the provider's `/v1/models` endpoint.
115 #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
116 pub struct AvailableModel {
117 pub id: String,
118 pub owned_by: Option<String>,
119 pub created: Option<u64>,
120 }
121
122 /// Client for DeepSeek's OpenAI-compatible APIs.
123 #[must_use]
124 pub struct DeepSeekClient {
125 pub(super) http_client: reqwest::Client,
126 api_key: String,
127 pub(super) base_url: String,
128 pub(super) api_provider: ApiProvider,
129 retry: RetryPolicy,
130 default_model: String,
131 connection_health: Arc<AsyncMutex<ConnectionHealth>>,
132 rate_limiter: Arc<AsyncMutex<TokenBucket>>,
133 }
134
135 const CONNECTION_FAILURE_THRESHOLD: u32 = 2;
136 const RECOVERY_PROBE_COOLDOWN: Duration = Duration::from_secs(15);
137
138 const DEFAULT_CLIENT_RATE_LIMIT_RPS: f64 = 8.0;
139 const DEFAULT_CLIENT_RATE_LIMIT_BURST: f64 = 16.0;
140 const ALLOW_INSECURE_HTTP_ENV: &str = "DEEPSEEK_ALLOW_INSECURE_HTTP";
141
142 pub(super) const SSE_BACKPRESSURE_HIGH_WATERMARK: usize = 8 * 1024 * 1024; // 8 MB
143 pub(super) const SSE_BACKPRESSURE_SLEEP_MS: u64 = 10;
144 pub(super) const SSE_MAX_LINES_PER_CHUNK: usize = 256;
145
146 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
147 enum ConnectionState {
148 Healthy,
149 Degraded,
150 Recovering,
151 }
152
153 #[derive(Debug)]
154 struct ConnectionHealth {
155 state: ConnectionState,
156 consecutive_failures: u32,
157 last_failure: Option<Instant>,
158 last_success: Option<Instant>,
159 last_probe: Option<Instant>,
160 }
161
162 impl Default for ConnectionHealth {
163 fn default() -> Self {
164 Self {
165 state: ConnectionState::Healthy,
166 consecutive_failures: 0,
167 last_failure: None,
168 last_success: None,
169 last_probe: None,
170 }
171 }
172 }
173
174 #[derive(Debug)]
175 struct TokenBucket {
176 enabled: bool,
177 capacity: f64,
178 tokens: f64,
179 refill_per_sec: f64,
180 last_refill: Instant,
181 }
182
183 impl TokenBucket {
184 fn from_env() -> Self {
185 let rps = std::env::var("DEEPSEEK_RATE_LIMIT_RPS")
186 .ok()
187 .and_then(|v| v.parse::<f64>().ok())
188 .unwrap_or(DEFAULT_CLIENT_RATE_LIMIT_RPS)
189 .max(0.0);
190 let burst = std::env::var("DEEPSEEK_RATE_LIMIT_BURST")
191 .ok()
192 .and_then(|v| v.parse::<f64>().ok())
193 .unwrap_or(DEFAULT_CLIENT_RATE_LIMIT_BURST)
194 .max(1.0);
195 let enabled = rps > 0.0;
196 Self {
197 enabled,
198 capacity: burst,
199 tokens: burst,
200 refill_per_sec: rps,
201 last_refill: Instant::now(),
202 }
203 }
204
205 fn refill(&mut self, now: Instant) {
206 if !self.enabled {
207 return;
208 }
209 let elapsed = now.duration_since(self.last_refill).as_secs_f64();
210 self.last_refill = now;
211 self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity);
212 }
213
214 fn delay_until_available(&mut self, tokens: f64) -> Option<Duration> {
215 if !self.enabled {
216 return None;
217 }
218 let now = Instant::now();
219 self.refill(now);
220 if self.tokens >= tokens {
221 self.tokens -= tokens;
222 return None;
223 }
224 let needed = tokens - self.tokens;
225 self.tokens = 0.0;
226 if self.refill_per_sec <= 0.0 {
227 return Some(Duration::from_secs(1));
228 }
229 Some(Duration::from_secs_f64(needed / self.refill_per_sec))
230 }
231 }
232
233 fn apply_request_success(health: &mut ConnectionHealth, now: Instant) -> bool {
234 let recovered = health.state != ConnectionState::Healthy;
235 health.state = ConnectionState::Healthy;
236 health.consecutive_failures = 0;
237 health.last_success = Some(now);
238 recovered
239 }
240
241 fn apply_request_failure(health: &mut ConnectionHealth, now: Instant) {
242 health.consecutive_failures = health.consecutive_failures.saturating_add(1);
243 health.last_failure = Some(now);
244 if health.consecutive_failures >= CONNECTION_FAILURE_THRESHOLD {
245 health.state = ConnectionState::Degraded;
246 }
247 }
248
249 fn mark_recovery_probe_if_due(health: &mut ConnectionHealth, now: Instant) -> bool {
250 if health.state == ConnectionState::Healthy {
251 return false;
252 }
253 if health
254 .last_probe
255 .is_some_and(|last| now.duration_since(last) < RECOVERY_PROBE_COOLDOWN)
256 {
257 return false;
258 }
259 health.last_probe = Some(now);
260 health.state = ConnectionState::Recovering;
261 true
262 }
263
264 fn buffer_pool() -> &'static StdMutex<Vec<Vec<u8>>> {
265 static POOL: OnceLock<StdMutex<Vec<Vec<u8>>>> = OnceLock::new();
266 POOL.get_or_init(|| StdMutex::new(Vec::new()))
267 }
268
269 fn acquire_stream_buffer() -> Vec<u8> {
270 if let Ok(mut pool) = buffer_pool().lock() {
271 pool.pop().unwrap_or_else(|| Vec::with_capacity(8192))
272 } else {
273 Vec::with_capacity(8192)
274 }
275 }
276
277 fn release_stream_buffer(mut buf: Vec<u8>) {
278 buf.clear();
279 if buf.capacity() > 256 * 1024 {
280 buf.shrink_to(256 * 1024);
281 }
282 if let Ok(mut pool) = buffer_pool().lock()
283 && pool.len() < 8
284 {
285 pool.push(buf);
286 }
287 }
288
289 impl Clone for DeepSeekClient {
290 fn clone(&self) -> Self {
291 Self {
292 http_client: self.http_client.clone(),
293 api_key: self.api_key.clone(),
294 base_url: self.base_url.clone(),
295 api_provider: self.api_provider,
296 retry: self.retry.clone(),
297 default_model: self.default_model.clone(),
298 connection_health: self.connection_health.clone(),
299 rate_limiter: self.rate_limiter.clone(),
300 }
301 }
302 }
303
304 // === Helpers ===
305
306 /// Maximum bytes to read from an error response body (64 KB).
307 pub(super) const ERROR_BODY_MAX_BYTES: usize = 64 * 1024;
308
309 /// Read an error response body with a size limit to prevent unbounded allocation.
310 pub(super) async fn bounded_error_text(response: reqwest::Response, max_bytes: usize) -> String {
311 use futures_util::StreamExt;
312 let mut stream = response.bytes_stream();
313 let mut buf = Vec::with_capacity(max_bytes.min(8192));
314 while let Some(chunk) = stream.next().await {
315 let Ok(chunk) = chunk else { break };
316 let remaining = max_bytes.saturating_sub(buf.len());
317 if remaining == 0 {
318 break;
319 }
320 buf.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
321 }
322 String::from_utf8_lossy(&buf).into_owned()
323 }
324
325 fn validate_base_url_security(base_url: &str) -> Result<()> {
326 if base_url.starts_with("https://")
327 || base_url.starts_with("http://localhost")
328 || base_url.starts_with("http://127.0.0.1")
329 || base_url.starts_with("http://[::1]")
330 {
331 return Ok(());
332 }
333
334 if base_url.starts_with("http://")
335 && std::env::var(ALLOW_INSECURE_HTTP_ENV)
336 .ok()
337 .as_deref()
338 .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
339 {
340 logging::warn(format!(
341 "Using insecure HTTP base URL because {} is set",
342 ALLOW_INSECURE_HTTP_ENV
343 ));
344 return Ok(());
345 }
346
347 if base_url.starts_with("http://") {
348 anyhow::bail!(
349 "Refusing insecure base URL '{}'. Use HTTPS or set {}=1 to override for trusted environments.",
350 base_url,
351 ALLOW_INSECURE_HTTP_ENV
352 );
353 }
354
355 anyhow::bail!(
356 "Refusing base URL '{}': only HTTPS (or explicitly allowed HTTP) URLs are supported.",
357 base_url,
358 )
359 }
360
361 pub(super) fn versioned_base_url(base_url: &str) -> String {
362 let trimmed = base_url.trim_end_matches('/');
363 if trimmed.ends_with("/v1") || trimmed.ends_with("/beta") {
364 trimmed.to_string()
365 } else {
366 format!("{trimmed}/v1")
367 }
368 }
369
370 pub(super) fn api_url(base_url: &str, path: &str) -> String {
371 format!(
372 "{}/{}",
373 versioned_base_url(base_url).trim_end_matches('/'),
374 path.trim_start_matches('/')
375 )
376 }
377
378 // === DeepSeekClient ===
379
380 /// Returns true when DEEPSEEK_FORCE_HTTP1 is set to a truthy value
381 /// (`1`, `true`, `yes`, `on`, case-insensitive). Used by `build_http_client`
382 /// to opt out of HTTP/2 entirely when DeepSeek's edge mishandles long-lived H2
383 /// streams (#103). Anything else (unset, `0`, `false`, ...) leaves HTTP/2 on.
384 fn force_http1_from_env() -> bool {
385 std::env::var("DEEPSEEK_FORCE_HTTP1")
386 .ok()
387 .map(|v| v.trim().to_ascii_lowercase())
388 .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
389 }
390
391 /// Read `SSL_CERT_FILE` and add its contents as extra root
392 /// certificates on the reqwest builder (#418). Tries the PEM-bundle
393 /// parser first (covers single-cert files too), then falls back to
394 /// DER. All failures log a warning and return the builder unchanged
395 /// so a malformed env var degrades gracefully.
396 fn add_extra_root_certs(
397 mut builder: reqwest::ClientBuilder,
398 cert_path: &str,
399 ) -> reqwest::ClientBuilder {
400 let bytes = match std::fs::read(cert_path) {
401 Ok(b) => b,
402 Err(err) => {
403 logging::warn(format!(
404 "SSL_CERT_FILE={cert_path} could not be read: {err}"
405 ));
406 return builder;
407 }
408 };
409
410 if let Ok(certs) = reqwest::Certificate::from_pem_bundle(&bytes) {
411 let added = certs.len();
412 for cert in certs {
413 builder = builder.add_root_certificate(cert);
414 }
415 logging::info(format!(
416 "SSL_CERT_FILE={cert_path} loaded ({added} cert(s))"
417 ));
418 return builder;
419 }
420
421 match reqwest::Certificate::from_der(&bytes) {
422 Ok(cert) => {
423 builder = builder.add_root_certificate(cert);
424 logging::info(format!("SSL_CERT_FILE={cert_path} loaded (1 DER cert)"));
425 }
426 Err(err) => {
427 logging::warn(format!(
428 "SSL_CERT_FILE={cert_path} could not be parsed as PEM bundle or DER: {err}"
429 ));
430 }
431 }
432 builder
433 }
434
435 impl DeepSeekClient {
436 /// Create a DeepSeek client from CLI configuration.
437 pub fn new(config: &Config) -> Result<Self> {
438 let api_key = config.deepseek_api_key()?;
439 let base_url = config.deepseek_base_url();
440 let api_provider = config.api_provider();
441 validate_base_url_security(&base_url)?;
442 let retry = config.retry_policy();
443 let default_model = config.default_model();
444 let http_headers = config.http_headers();
445
446 logging::info(format!("API provider: {}", api_provider.as_str()));
447 logging::info(format!("API base URL: {base_url}"));
448 if !http_headers.is_empty() {
449 logging::info(format!(
450 "{} custom HTTP header(s) configured",
451 http_headers.len()
452 ));
453 }
454 logging::info(format!(
455 "Retry policy: enabled={}, max_retries={}, initial_delay={}s, max_delay={}s",
456 retry.enabled, retry.max_retries, retry.initial_delay, retry.max_delay
457 ));
458
459 let http_client = Self::build_http_client(&api_key, &http_headers)?;
460
461 Ok(Self {
462 http_client,
463 api_key,
464 base_url,
465 api_provider,
466 retry,
467 default_model,
468 connection_health: Arc::new(AsyncMutex::new(ConnectionHealth::default())),
469 rate_limiter: Arc::new(AsyncMutex::new(TokenBucket::from_env())),
470 })
471 }
472
473 fn build_http_client(
474 api_key: &str,
475 extra_headers: &HashMap<String, String>,
476 ) -> Result<reqwest::Client> {
477 let headers = build_default_headers(api_key, extra_headers)?;
478 let mut builder = reqwest::Client::builder()
479 .default_headers(headers)
480 .connect_timeout(Duration::from_secs(30))
481 .tcp_keepalive(Some(Duration::from_secs(30)))
482 .http2_keep_alive_interval(Some(Duration::from_secs(15)))
483 .http2_keep_alive_timeout(Duration::from_secs(20))
484 .min_tls_version(reqwest::tls::Version::TLS_1_2);
485 if force_http1_from_env() {
486 logging::info("DEEPSEEK_FORCE_HTTP1=1 — pinning HTTP client to HTTP/1.1");
487 builder = builder.http1_only();
488 }
489 if let Ok(cert_path) = std::env::var("SSL_CERT_FILE")
490 && !cert_path.is_empty()
491 {
492 builder = add_extra_root_certs(builder, &cert_path);
493 }
494 builder.build().map_err(Into::into)
495 }
496
497 #[cfg(test)]
498 fn default_headers(
499 api_key: &str,
500 extra_headers: &HashMap<String, String>,
501 ) -> Result<HeaderMap> {
502 build_default_headers(api_key, extra_headers)
503 }
504 }
505
506 fn build_default_headers(
507 api_key: &str,
508 extra_headers: &HashMap<String, String>,
509 ) -> Result<HeaderMap> {
510 let mut headers = HeaderMap::new();
511 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
512 if !api_key.trim().is_empty() {
513 headers.insert(
514 AUTHORIZATION,
515 HeaderValue::from_str(&format!("Bearer {api_key}"))?,
516 );
517 }
518 for (name, value) in extra_headers {
519 let name = name.trim();
520 let value = value.trim();
521 if name.is_empty() || value.is_empty() {
522 continue;
523 }
524 let header_name = HeaderName::from_bytes(name.as_bytes())?;
525 if header_name == AUTHORIZATION || header_name == CONTENT_TYPE {
526 continue;
527 }
528 headers.insert(header_name, HeaderValue::from_str(value)?);
529 }
530 Ok(headers)
531 }
532
533 impl DeepSeekClient {
534 /// List available models from the provider.
535 pub async fn list_models(&self) -> Result<Vec<AvailableModel>> {
536 let url = api_url(&self.base_url, "models");
537 let response = self.send_with_retry(|| self.http_client.get(&url)).await?;
538
539 let status = response.status();
540 if !status.is_success() {
541 let error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
542 anyhow::bail!("Failed to list models: HTTP {status}: {error_text}");
543 }
544 let response_text = response.text().await.unwrap_or_default();
545
546 parse_models_response(&response_text)
547 }
548
549 async fn wait_for_rate_limit(&self) {
550 let maybe_delay = {
551 let mut limiter = self.rate_limiter.lock().await;
552 limiter.delay_until_available(1.0)
553 };
554 if let Some(delay) = maybe_delay {
555 tokio::time::sleep(delay).await;
556 }
557 }
558
559 async fn mark_request_success(&self) {
560 let mut health = self.connection_health.lock().await;
561 if apply_request_success(&mut health, Instant::now()) {
562 logging::info("Connection recovered");
563 }
564 }
565
566 async fn mark_request_failure(&self, reason: &str) {
567 let mut health = self.connection_health.lock().await;
568 apply_request_failure(&mut health, Instant::now());
569 logging::warn(format!(
570 "Connection degraded (failures={}): {}",
571 health.consecutive_failures, reason
572 ));
573 }
574
575 async fn maybe_probe_recovery(&self) {
576 let should_probe = {
577 let mut health = self.connection_health.lock().await;
578 mark_recovery_probe_if_due(&mut health, Instant::now())
579 };
580 if !should_probe {
581 return;
582 }
583 let health_url = api_url(&self.base_url, "models");
584 let probe = self.http_client.get(health_url).send().await;
585 match probe {
586 Ok(resp) if resp.status().is_success() => {
587 self.mark_request_success().await;
588 logging::info("Recovery probe succeeded");
589 }
590 Ok(resp) => {
591 self.mark_request_failure(&format!("probe status={}", resp.status()))
592 .await;
593 }
594 Err(err) => {
595 self.mark_request_failure(&format!("probe error={err}"))
596 .await;
597 }
598 }
599 }
600
601 pub(super) async fn send_with_retry<F>(&self, mut build: F) -> Result<reqwest::Response>
602 where
603 F: FnMut() -> reqwest::RequestBuilder,
604 {
605 let retry_cfg: LlmRetryConfig = self.retry.clone().into();
606 let request_result = with_retry(
607 &retry_cfg,
608 || {
609 let request = build();
610 async move {
611 self.wait_for_rate_limit().await;
612 let response = request
613 .send()
614 .await
615 .map_err(|err| LlmError::from_reqwest(&err))?;
616 let status = response.status();
617 if status.is_success() {
618 return Ok(response);
619 }
620 let retryable = status.as_u16() == 429 || status.is_server_error();
621 if !retryable {
622 return Ok(response);
623 }
624 let retry_after = extract_retry_after(response.headers());
625 let body = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
626 Err(LlmError::from_http_response_with_retry_after(
627 status.as_u16(),
628 &body,
629 retry_after,
630 ))
631 }
632 },
633 Some(Box::new(|err, attempt, delay| {
634 let (reason_label, human_reason) = retry_reason_label_and_human(err);
635 logging::warn(format!(
636 "HTTP retry reason={} attempt={} delay={:.2}s",
637 reason_label,
638 attempt + 1,
639 delay.as_secs_f64(),
640 ));
641 crate::retry_status::start(attempt + 1, delay, human_reason);
642 })),
643 )
644 .await;
645
646 match request_result {
647 Ok(response) => {
648 crate::retry_status::succeeded();
649 self.mark_request_success().await;
650 Ok(response)
651 }
652 Err(err) => {
653 let last = err.last_error.to_string();
654 if err.attempts > 1 {
655 crate::retry_status::failed(last.clone());
656 } else {
657 crate::retry_status::clear();
658 }
659 self.mark_request_failure(&last).await;
660 self.maybe_probe_recovery().await;
661 Err(anyhow::anyhow!(last))
662 }
663 }
664 }
665 }
666
667 /// Translate the structured `LlmError` into both a categorical label
668 /// (for structured logs / metrics) and a short human reason string
669 /// (for the retry banner). Returning both from one match avoids the
670 /// double-classification we had before.
671 fn retry_reason_label_and_human(err: &LlmError) -> (&'static str, String) {
672 match err {
673 LlmError::RateLimited { retry_after, .. } => {
674 let human = if let Some(after) = retry_after {
675 format!("rate limited (Retry-After {}s)", after.as_secs())
676 } else {
677 "rate limited".to_string()
678 };
679 ("rate_limited", human)
680 }
681 LlmError::ServerError { status, .. } => ("server_error", format!("upstream {status}")),
682 LlmError::NetworkError(_) => ("network_error", "network error".to_string()),
683 LlmError::Timeout(_) => ("timeout", "timeout".to_string()),
684 _ => ("other", "other".to_string()),
685 }
686 }
687
688 impl LlmClient for DeepSeekClient {
689 fn provider_name(&self) -> &'static str {
690 self.api_provider.as_str()
691 }
692
693 fn model(&self) -> &str {
694 &self.default_model
695 }
696
697 async fn health_check(&self) -> Result<bool> {
698 let health_url = api_url(&self.base_url, "models");
699 self.wait_for_rate_limit().await;
700 let response = self.http_client.get(health_url).send().await;
701 match response {
702 Ok(resp) if resp.status().is_success() => {
703 self.mark_request_success().await;
704 Ok(true)
705 }
706 Ok(resp) => {
707 self.mark_request_failure(&format!("health status={}", resp.status()))
708 .await;
709 Ok(false)
710 }
711 Err(err) => {
712 self.mark_request_failure(&format!("health error={err}"))
713 .await;
714 Ok(false)
715 }
716 }
717 }
718
719 async fn create_message(&self, request: MessageRequest) -> Result<MessageResponse> {
720 self.create_message_chat(&request).await
721 }
722
723 async fn create_message_stream(
724 &self,
725 request: MessageRequest,
726 ) -> Result<crate::llm_client::StreamEventBox> {
727 self.handle_chat_completion_stream(request).await
728 }
729 }
730
731 #[derive(Debug, Deserialize)]
732 struct ModelsListResponse {
733 data: Vec<ModelListItem>,
734 }
735
736 #[derive(Debug, Deserialize)]
737 struct ModelListItem {
738 id: String,
739 #[serde(default)]
740 owned_by: Option<String>,
741 #[serde(default)]
742 created: Option<u64>,
743 }
744
745 pub(super) fn parse_models_response(payload: &str) -> Result<Vec<AvailableModel>> {
746 let parsed: ModelsListResponse =
747 serde_json::from_str(payload).context("Failed to parse model list JSON")?;
748
749 let mut models = parsed
750 .data
751 .into_iter()
752 .map(|item| AvailableModel {
753 id: item.id,
754 owned_by: item.owned_by,
755 created: item.created,
756 })
757 .collect::<Vec<_>>();
758 models.sort_by(|a, b| a.id.cmp(&b.id));
759 models.dedup_by(|a, b| a.id == b.id);
760 Ok(models)
761 }
762
763 pub(super) fn system_to_instructions(system: Option<SystemPrompt>) -> Option<String> {
764 match system {
765 Some(SystemPrompt::Text(text)) => Some(text),
766 Some(SystemPrompt::Blocks(blocks)) => {
767 let joined = blocks
768 .into_iter()
769 .map(|b| b.text)
770 .collect::<Vec<_>>()
771 .join("\n\n---\n\n");
772 if joined.trim().is_empty() {
773 None
774 } else {
775 Some(joined)
776 }
777 }
778 None => None,
779 }
780 }
781
782 pub(super) fn apply_reasoning_effort(
783 body: &mut Value,
784 effort: Option<&str>,
785 provider: ApiProvider,
786 ) {
787 let Some(effort) = effort else {
788 return;
789 };
790 let normalized = effort.trim().to_ascii_lowercase();
791 match normalized.as_str() {
792 "off" | "disabled" | "none" | "false" => match provider {
793 ApiProvider::Deepseek
794 | ApiProvider::DeepseekCN
795 | ApiProvider::Openrouter
796 | ApiProvider::Novita
797 | ApiProvider::Fireworks
798 | ApiProvider::Sglang
799 | ApiProvider::Vllm => {
800 body["thinking"] = json!({ "type": "disabled" });
801 }
802 ApiProvider::NvidiaNim => {
803 body["chat_template_kwargs"] = json!({
804 "thinking": false,
805 });
806 }
807 },
808 "low" | "minimal" | "medium" | "mid" | "high" | "" => match provider {
809 ApiProvider::Deepseek
810 | ApiProvider::DeepseekCN
811 | ApiProvider::Openrouter
812 | ApiProvider::Novita
813 | ApiProvider::Fireworks
814 | ApiProvider::Sglang
815 | ApiProvider::Vllm => {
816 body["reasoning_effort"] = json!("high");
817 body["thinking"] = json!({ "type": "enabled" });
818 }
819 ApiProvider::NvidiaNim => {
820 body["chat_template_kwargs"] = json!({
821 "thinking": true,
822 "reasoning_effort": "high",
823 });
824 }
825 },
826 "xhigh" | "max" | "highest" => match provider {
827 ApiProvider::Deepseek
828 | ApiProvider::DeepseekCN
829 | ApiProvider::Openrouter
830 | ApiProvider::Novita
831 | ApiProvider::Fireworks
832 | ApiProvider::Sglang
833 | ApiProvider::Vllm => {
834 body["reasoning_effort"] = json!("max");
835 body["thinking"] = json!({ "type": "enabled" });
836 }
837 ApiProvider::NvidiaNim => {
838 body["chat_template_kwargs"] = json!({
839 "thinking": true,
840 "reasoning_effort": "max",
841 });
842 }
843 },
844 _ => {}
845 }
846 }
847
848 pub(super) fn parse_usage(usage: Option<&Value>) -> Usage {
849 let input_tokens = usage
850 .and_then(|u| u.get("input_tokens").or_else(|| u.get("prompt_tokens")))
851 .and_then(Value::as_u64)
852 .unwrap_or(0);
853 let mut output_tokens = usage
854 .and_then(|u| {
855 u.get("output_tokens")
856 .or_else(|| u.get("completion_tokens"))
857 })
858 .and_then(Value::as_u64)
859 .unwrap_or(0);
860 let reasoning_tokens_raw = usage
861 .and_then(|u| u.get("completion_tokens_details"))
862 .and_then(|details| details.get("reasoning_tokens"))
863 .and_then(Value::as_u64);
864 if output_tokens == 0
865 && let Some(reasoning_tokens) = reasoning_tokens_raw
866 {
867 output_tokens = reasoning_tokens;
868 }
869 let cached_tokens = usage
870 .and_then(|u| u.get("prompt_tokens_details"))
871 .and_then(|details| details.get("cached_tokens"))
872 .and_then(Value::as_u64);
873 let prompt_cache_hit_tokens = usage
874 .and_then(|u| u.get("prompt_cache_hit_tokens"))
875 .and_then(Value::as_u64)
876 .or(cached_tokens)
877 .map(|v| v as u32);
878 let prompt_cache_miss_tokens = usage
879 .and_then(|u| u.get("prompt_cache_miss_tokens"))
880 .and_then(Value::as_u64)
881 .or_else(|| cached_tokens.map(|cached| input_tokens.saturating_sub(cached)))
882 .map(|v| v as u32);
883 let reasoning_tokens = reasoning_tokens_raw.map(|v| v as u32);
884
885 let server_tool_use = usage.and_then(|u| u.get("server_tool_use")).map(|server| {
886 let code_execution_requests = server
887 .get("code_execution_requests")
888 .and_then(Value::as_u64)
889 .map(|v| v as u32);
890 let tool_search_requests = server
891 .get("tool_search_requests")
892 .and_then(Value::as_u64)
893 .map(|v| v as u32);
894 ServerToolUsage {
895 code_execution_requests,
896 tool_search_requests,
897 }
898 });
899
900 Usage {
901 input_tokens: input_tokens as u32,
902 output_tokens: output_tokens as u32,
903 prompt_cache_hit_tokens,
904 prompt_cache_miss_tokens,
905 reasoning_tokens,
906 reasoning_replay_tokens: None,
907 server_tool_use,
908 }
909 }
910
911 impl DeepSeekClient {
912 /// Call the DeepSeek `/beta/completions` FIM endpoint.
913 pub async fn fim_completion(
914 &self,
915 model: &str,
916 prompt: &str,
917 suffix: &str,
918 max_tokens: u32,
919 ) -> anyhow::Result<String> {
920 let url = api_url(&self.base_url, "beta/completions");
921 let body = json!({
922 "model": model,
923 "prompt": prompt,
924 "suffix": suffix,
925 "max_tokens": max_tokens,
926 });
927 let response = self
928 .send_with_retry(|| self.http_client.post(&url).json(&body))
929 .await?;
930 let status = response.status();
931 if !status.is_success() {
932 let error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
933 anyhow::bail!("FIM API error: HTTP {status}: {error_text}");
934 }
935 let response_text = response.text().await.unwrap_or_default();
936 let value: serde_json::Value =
937 serde_json::from_str(&response_text).context("Failed to parse FIM API response")?;
938 let text = value
939 .pointer("/choices/0/text")
940 .and_then(serde_json::Value::as_str)
941 .ok_or_else(|| anyhow::anyhow!("FIM response missing choices[0].text"))?;
942 Ok(text.to_string())
943 }
944 }
945
946 mod chat;
947
948 #[cfg(test)]
949 mod tests {
950 use super::*;
951 use crate::client::chat::{
952 build_chat_messages, build_chat_messages_for_request, count_reasoning_replay_chars,
953 parse_chat_message, parse_sse_chunk, sanitize_thinking_mode_messages, tool_to_chat,
954 };
955 use crate::models::{
956 ContentBlock, ContentBlockStart, Delta, Message, MessageRequest, StreamEvent, Tool,
957 };
958 use serde_json::json;
959
960 #[test]
961 fn tool_name_roundtrip_dot() {
962 let original = "multi_tool_use.parallel";
963 let encoded = to_api_tool_name(original);
964 assert_eq!(encoded, "multi_tool_use-x00002E-parallel");
965 let decoded = from_api_tool_name(&encoded);
966 assert_eq!(decoded, original);
967 }
968
969 #[test]
970 fn tool_name_decode_mangled_dot_prefix() {
971 let mangled = "multi_tool_use.x00002E-parallel";
972 let decoded = from_api_tool_name(mangled);
973 assert_eq!(decoded, "multi_tool_use..parallel");
974 }
975
976 #[test]
977 fn tool_name_decode_bare_hex_no_trailing_dash() {
978 let mangled = "foo_x00002Ebar";
979 let decoded = from_api_tool_name(mangled);
980 assert_eq!(decoded, "foo_.bar");
981 }
982
983 #[test]
984 fn tool_name_bare_hex_preserves_alnum() {
985 let input = "foox000041bar";
986 let decoded = from_api_tool_name(input);
987 assert_eq!(decoded, input);
988 }
989
990 #[test]
991 fn tool_name_bare_hex_preserves_underscore() {
992 let input = "foox00005Fbar";
993 let decoded = from_api_tool_name(input);
994 assert_eq!(decoded, input);
995 }
996
997 #[test]
998 fn tool_name_roundtrip_colon() {
999 let original = "mcp__server:tool_name";
1000 let encoded = to_api_tool_name(original);
1001 let decoded = from_api_tool_name(&encoded);
1002 assert_eq!(decoded, original);
1003 }
1004
1005 #[test]
1006 fn api_url_handles_default_v1_and_beta_base_urls() {
1007 assert_eq!(
1008 api_url("https://api.deepseek.com", "chat/completions"),
1009 "https://api.deepseek.com/v1/chat/completions"
1010 );
1011 assert_eq!(
1012 api_url("https://api.deepseek.com/v1", "chat/completions"),
1013 "https://api.deepseek.com/v1/chat/completions"
1014 );
1015 assert_eq!(
1016 api_url("https://api.deepseek.com/beta", "chat/completions"),
1017 "https://api.deepseek.com/beta/chat/completions"
1018 );
1019 }
1020
1021 #[test]
1022 fn default_headers_include_custom_headers_when_configured() {
1023 let mut extra = HashMap::new();
1024 extra.insert("X-Model-Provider-Id".to_string(), "tongyi".to_string());
1025 let headers = DeepSeekClient::default_headers("sk-test", &extra).expect("headers");
1026 assert_eq!(
1027 headers
1028 .get("x-model-provider-id")
1029 .and_then(|value| value.to_str().ok()),
1030 Some("tongyi")
1031 );
1032 }
1033
1034 #[test]
1035 fn default_headers_ignore_blank_custom_headers() {
1036 let mut extra = HashMap::new();
1037 extra.insert("X-Blank".to_string(), " ".to_string());
1038 let headers = DeepSeekClient::default_headers("sk-test", &extra).expect("headers");
1039 assert!(headers.get("x-blank").is_none());
1040 }
1041
1042 #[test]
1043 fn chat_messages_keep_reasoning_content_on_all_assistant_messages() {
1044 let message = Message {
1045 role: "assistant".to_string(),
1046 content: vec![
1047 ContentBlock::Thinking {
1048 thinking: "plan".to_string(),
1049 },
1050 ContentBlock::Text {
1051 text: "done".to_string(),
1052 cache_control: None,
1053 },
1054 ],
1055 };
1056 let out = build_chat_messages(None, &[message], "deepseek-v4-pro");
1057 let assistant = out
1058 .iter()
1059 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
1060 .expect("assistant message");
1061 assert_eq!(
1062 assistant.get("content").and_then(Value::as_str),
1063 Some("done")
1064 );
1065 assert_eq!(
1066 assistant.get("reasoning_content").and_then(Value::as_str),
1067 Some("plan"),
1068 "thinking-mode models must keep reasoning_content on all assistant messages"
1069 );
1070 }
1071
1072 #[test]
1073 fn chat_messages_replay_prior_tool_round_reasoning_after_new_user_turn() {
1074 let messages = vec![
1075 Message {
1076 role: "user".to_string(),
1077 content: vec![ContentBlock::Text {
1078 text: "Need the date".to_string(),
1079 cache_control: None,
1080 }],
1081 },
1082 Message {
1083 role: "assistant".to_string(),
1084 content: vec![
1085 ContentBlock::Thinking {
1086 thinking: "Need to call a tool".to_string(),
1087 },
1088 ContentBlock::ToolUse {
1089 id: "tool-1".to_string(),
1090 name: "get_date".to_string(),
1091 input: json!({}),
1092 caller: None,
1093 },
1094 ],
1095 },
1096 Message {
1097 role: "user".to_string(),
1098 content: vec![ContentBlock::ToolResult {
1099 tool_use_id: "tool-1".to_string(),
1100 content: "2026-04-23".to_string(),
1101 is_error: None,
1102 content_blocks: None,
1103 }],
1104 },
1105 Message {
1106 role: "assistant".to_string(),
1107 content: vec![ContentBlock::Text {
1108 text: "It is 2026-04-23.".to_string(),
1109 cache_control: None,
1110 }],
1111 },
1112 Message {
1113 role: "user".to_string(),
1114 content: vec![ContentBlock::Text {
1115 text: "Thanks. Next question.".to_string(),
1116 cache_control: None,
1117 }],
1118 },
1119 ];
1120 let out = build_chat_messages(None, &messages, "deepseek-v4-pro");
1121 let tool_assistant = out
1122 .iter()
1123 .find(|value| {
1124 value.get("role").and_then(Value::as_str) == Some("assistant")
1125 && value.get("tool_calls").is_some()
1126 })
1127 .expect("tool-call assistant message");
1128 assert_eq!(
1129 tool_assistant
1130 .get("reasoning_content")
1131 .and_then(Value::as_str),
1132 Some("Need to call a tool"),
1133 "thinking-mode tool rounds must replay reasoning_content on later requests"
1134 );
1135 }
1136
1137 #[test]
1138 fn chat_messages_allow_tool_round_without_reasoning_when_thinking_disabled() {
1139 let request = MessageRequest {
1140 model: "deepseek-v4-pro".to_string(),
1141 messages: vec![
1142 Message {
1143 role: "assistant".to_string(),
1144 content: vec![ContentBlock::ToolUse {
1145 id: "call-no-thinking".to_string(),
1146 name: "read_file".to_string(),
1147 input: json!({"path": "Cargo.toml"}),
1148 caller: None,
1149 }],
1150 },
1151 Message {
1152 role: "user".to_string(),
1153 content: vec![ContentBlock::ToolResult {
1154 tool_use_id: "call-no-thinking".to_string(),
1155 content: "workspace manifest".to_string(),
1156 is_error: None,
1157 content_blocks: None,
1158 }],
1159 },
1160 ],
1161 max_tokens: 1024,
1162 system: None,
1163 tools: None,
1164 tool_choice: None,
1165 metadata: None,
1166 thinking: None,
1167 reasoning_effort: Some("off".to_string()),
1168 stream: None,
1169 temperature: None,
1170 top_p: None,
1171 };
1172
1173 let out = build_chat_messages_for_request(&request);
1174 assert!(
1175 out.iter().any(
1176 |value| value.get("role").and_then(Value::as_str) == Some("assistant")
1177 && value.get("tool_calls").is_some()
1178 ),
1179 "tool calls remain valid when thinking mode is disabled"
1180 );
1181 assert!(
1182 out.iter()
1183 .any(|value| value.get("role").and_then(Value::as_str) == Some("tool")),
1184 "matching tool result should remain"
1185 );
1186 }
1187
1188 #[test]
1189 fn reasoning_effort_uses_deepseek_top_level_thinking_parameter() {
1190 let mut body = json!({});
1191 apply_reasoning_effort(&mut body, Some("max"), ApiProvider::Deepseek);
1192
1193 assert_eq!(
1194 body.get("reasoning_effort").and_then(Value::as_str),
1195 Some("max")
1196 );
1197 assert_eq!(
1198 body.pointer("/thinking/type").and_then(Value::as_str),
1199 Some("enabled")
1200 );
1201 assert!(body.get("extra_body").is_none());
1202 }
1203
1204 #[test]
1205 fn reasoning_effort_off_disables_top_level_thinking() {
1206 let mut body = json!({});
1207 apply_reasoning_effort(&mut body, Some("off"), ApiProvider::Deepseek);
1208
1209 assert_eq!(
1210 body.pointer("/thinking/type").and_then(Value::as_str),
1211 Some("disabled")
1212 );
1213 assert!(body.get("reasoning_effort").is_none());
1214 assert!(body.get("extra_body").is_none());
1215 }
1216
1217 #[test]
1218 fn reasoning_effort_uses_nvidia_nim_chat_template_kwargs() {
1219 let mut body = json!({});
1220 apply_reasoning_effort(&mut body, Some("max"), ApiProvider::NvidiaNim);
1221
1222 assert_eq!(
1223 body.pointer("/chat_template_kwargs/thinking")
1224 .and_then(Value::as_bool),
1225 Some(true)
1226 );
1227 assert_eq!(
1228 body.pointer("/chat_template_kwargs/reasoning_effort")
1229 .and_then(Value::as_str),
1230 Some("max")
1231 );
1232 assert!(body.get("thinking").is_none());
1233 assert!(body.get("reasoning_effort").is_none());
1234 }
1235
1236 #[test]
1237 fn reasoning_effort_off_disables_nvidia_nim_thinking() {
1238 let mut body = json!({});
1239 apply_reasoning_effort(&mut body, Some("off"), ApiProvider::NvidiaNim);
1240
1241 assert_eq!(
1242 body.pointer("/chat_template_kwargs/thinking")
1243 .and_then(Value::as_bool),
1244 Some(false)
1245 );
1246 assert!(
1247 body.pointer("/chat_template_kwargs/reasoning_effort")
1248 .is_none()
1249 );
1250 }
1251
1252 #[test]
1253 fn chat_parser_accepts_nvidia_nim_reasoning_field() -> Result<()> {
1254 let response = parse_chat_message(&json!({
1255 "id": "chatcmpl-test",
1256 "model": "deepseek-ai/deepseek-v4-pro",
1257 "choices": [{
1258 "message": {
1259 "role": "assistant",
1260 "reasoning": "thinking via NIM",
1261 "content": "final answer"
1262 },
1263 "finish_reason": "stop"
1264 }],
1265 "usage": {
1266 "prompt_tokens": 10,
1267 "completion_tokens": 3
1268 }
1269 }))?;
1270
1271 assert!(matches!(
1272 response.content.first(),
1273 Some(ContentBlock::Thinking { thinking }) if thinking == "thinking via NIM"
1274 ));
1275 assert!(matches!(
1276 response.content.get(1),
1277 Some(ContentBlock::Text { text, .. }) if text == "final answer"
1278 ));
1279 Ok(())
1280 }
1281
1282 #[test]
1283 fn sse_parser_accepts_nvidia_nim_reasoning_delta() {
1284 let mut content_index = 0;
1285 let mut text_started = false;
1286 let mut thinking_started = false;
1287 let mut tool_indices = std::collections::HashMap::new();
1288 let events = parse_sse_chunk(
1289 &json!({
1290 "choices": [{
1291 "delta": {
1292 "reasoning": "nim thought"
1293 }
1294 }]
1295 }),
1296 &mut content_index,
1297 &mut text_started,
1298 &mut thinking_started,
1299 &mut tool_indices,
1300 true,
1301 );
1302
1303 assert!(events.iter().any(|event| matches!(
1304 event,
1305 StreamEvent::ContentBlockDelta {
1306 delta: Delta::ThinkingDelta { thinking },
1307 ..
1308 } if thinking == "nim thought"
1309 )));
1310 }
1311
1312 #[test]
1313 fn chat_tool_strict_flag_is_nested_under_function() {
1314 let tool = Tool {
1315 tool_type: Some("function".to_string()),
1316 name: "emit_json".to_string(),
1317 description: "Emit JSON".to_string(),
1318 input_schema: json!({"type": "object", "properties": {}}),
1319 allowed_callers: None,
1320 defer_loading: None,
1321 input_examples: None,
1322 strict: Some(true),
1323 cache_control: None,
1324 };
1325 let encoded = tool_to_chat(&tool);
1326 assert_eq!(
1327 encoded
1328 .get("function")
1329 .and_then(|function| function.get("strict"))
1330 .and_then(Value::as_bool),
1331 Some(true)
1332 );
1333 assert!(encoded.get("strict").is_none());
1334 }
1335
1336 #[test]
1337 fn chat_messages_drop_thinking_only_assistant_for_non_reasoning_model() {
1338 let message = Message {
1339 role: "assistant".to_string(),
1340 content: vec![ContentBlock::Thinking {
1341 thinking: "plan".to_string(),
1342 }],
1343 };
1344 let out = build_chat_messages(None, &[message], "some-non-deepseek-model");
1345 assert!(
1346 !out.iter()
1347 .any(|value| value.get("role").and_then(Value::as_str) == Some("assistant")),
1348 "non-reasoning model should drop thinking-only assistant"
1349 );
1350 }
1351
1352 #[test]
1353 fn parse_sse_chunk_closes_each_tool_block_with_matching_index() {
1354 let chunk = json!({
1355 "choices": [{
1356 "delta": {
1357 "tool_calls": [
1358 {
1359 "index": 0,
1360 "id": "call_0",
1361 "function": {"name": "read_file", "arguments": "{\"path\":\"a\"}"}
1362 },
1363 {
1364 "index": 1,
1365 "id": "call_1",
1366 "function": {"name": "read_file", "arguments": "{\"path\":\"b\"}"}
1367 }
1368 ]
1369 },
1370 "finish_reason": "tool_calls"
1371 }]
1372 });
1373
1374 let mut content_index = 0;
1375 let mut text_started = false;
1376 let mut thinking_started = false;
1377 let mut tool_indices: std::collections::HashMap<u32, u32> =
1378 std::collections::HashMap::new();
1379 let events = parse_sse_chunk(
1380 &chunk,
1381 &mut content_index,
1382 &mut text_started,
1383 &mut thinking_started,
1384 &mut tool_indices,
1385 false,
1386 );
1387
1388 let starts: Vec<u32> = events
1389 .iter()
1390 .filter_map(|event| match event {
1391 StreamEvent::ContentBlockStart {
1392 index,
1393 content_block: ContentBlockStart::ToolUse { .. },
1394 } => Some(*index),
1395 _ => None,
1396 })
1397 .collect();
1398 let stops: Vec<u32> = events
1399 .iter()
1400 .filter_map(|event| match event {
1401 StreamEvent::ContentBlockStop { index } => Some(*index),
1402 _ => None,
1403 })
1404 .collect();
1405 let deltas: Vec<u32> = events
1406 .iter()
1407 .filter_map(|event| match event {
1408 StreamEvent::ContentBlockDelta {
1409 index,
1410 delta: Delta::InputJsonDelta { .. },
1411 } => Some(*index),
1412 _ => None,
1413 })
1414 .collect();
1415
1416 assert_eq!(starts, vec![0, 1]);
1417 assert_eq!(stops, vec![0, 1]);
1418 assert_eq!(deltas, vec![0, 1]);
1419 }
1420
1421 #[test]
1422 fn parse_sse_chunk_handles_empty_choices_usage_chunk() {
1423 let chunk = json!({
1424 "choices": [],
1425 "usage": {
1426 "prompt_tokens": 100,
1427 "completion_tokens": 20,
1428 "prompt_cache_hit_tokens": 70,
1429 "prompt_cache_miss_tokens": 30
1430 }
1431 });
1432
1433 let mut content_index = 0;
1434 let mut text_started = false;
1435 let mut thinking_started = false;
1436 let mut tool_indices: std::collections::HashMap<u32, u32> =
1437 std::collections::HashMap::new();
1438 let events = parse_sse_chunk(
1439 &chunk,
1440 &mut content_index,
1441 &mut text_started,
1442 &mut thinking_started,
1443 &mut tool_indices,
1444 false,
1445 );
1446
1447 let StreamEvent::MessageDelta {
1448 usage: Some(usage), ..
1449 } = &events[0]
1450 else {
1451 panic!("expected usage delta");
1452 };
1453 assert_eq!(usage.input_tokens, 100);
1454 assert_eq!(usage.prompt_cache_hit_tokens, Some(70));
1455 assert_eq!(usage.prompt_cache_miss_tokens, Some(30));
1456 }
1457
1458 #[test]
1459 fn chat_messages_drop_orphan_tool_results() {
1460 let messages = vec![Message {
1461 role: "user".to_string(),
1462 content: vec![ContentBlock::ToolResult {
1463 tool_use_id: "tool-1".to_string(),
1464 content: "ok".to_string(),
1465 is_error: None,
1466 content_blocks: None,
1467 }],
1468 }];
1469
1470 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
1471 assert!(
1472 !out.iter()
1473 .any(|value| { value.get("role").and_then(Value::as_str) == Some("tool") })
1474 );
1475 }
1476
1477 #[test]
1478 fn chat_messages_include_tool_results_when_call_present() {
1479 let messages = vec![
1480 Message {
1481 role: "assistant".to_string(),
1482 content: vec![
1483 ContentBlock::Thinking {
1484 thinking: "Need to inspect the directory".to_string(),
1485 },
1486 ContentBlock::ToolUse {
1487 id: "tool-1".to_string(),
1488 name: "list_dir".to_string(),
1489 input: json!({}),
1490 caller: None,
1491 },
1492 ],
1493 },
1494 Message {
1495 role: "user".to_string(),
1496 content: vec![ContentBlock::ToolResult {
1497 tool_use_id: "tool-1".to_string(),
1498 content: "ok".to_string(),
1499 is_error: None,
1500 content_blocks: None,
1501 }],
1502 },
1503 ];
1504
1505 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
1506 assert!(
1507 out.iter()
1508 .any(|value| { value.get("role").and_then(Value::as_str) == Some("tool") })
1509 );
1510 let assistant = out
1511 .iter()
1512 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
1513 .expect("assistant message");
1514 assert!(assistant.get("tool_calls").is_some());
1515 }
1516
1517 #[test]
1518 fn chat_messages_encode_tool_call_names() {
1519 let messages = vec![
1520 Message {
1521 role: "assistant".to_string(),
1522 content: vec![
1523 ContentBlock::Thinking {
1524 thinking: "Need to search".to_string(),
1525 },
1526 ContentBlock::ToolUse {
1527 id: "tool-1".to_string(),
1528 name: "web.run".to_string(),
1529 input: json!({}),
1530 caller: None,
1531 },
1532 ],
1533 },
1534 Message {
1535 role: "user".to_string(),
1536 content: vec![ContentBlock::ToolResult {
1537 tool_use_id: "tool-1".to_string(),
1538 content: "ok".to_string(),
1539 is_error: None,
1540 content_blocks: None,
1541 }],
1542 },
1543 ];
1544
1545 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
1546 let assistant = out
1547 .iter()
1548 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
1549 .expect("assistant message");
1550 let tool_calls = assistant
1551 .get("tool_calls")
1552 .and_then(Value::as_array)
1553 .expect("tool_calls array");
1554 let function_name = tool_calls
1555 .first()
1556 .and_then(|call| call.get("function"))
1557 .and_then(|func| func.get("name"))
1558 .and_then(Value::as_str)
1559 .expect("tool call function name");
1560
1561 assert_eq!(function_name, to_api_tool_name("web.run"));
1562 }
1563
1564 #[test]
1565 fn chat_messages_strips_orphaned_tool_calls_after_compaction() {
1566 // Simulates post-compaction state: assistant has tool_calls but the
1567 // tool result messages were summarized away.
1568 let messages = vec![
1569 Message {
1570 role: "assistant".to_string(),
1571 content: vec![ContentBlock::ToolUse {
1572 id: "tool-orphan".to_string(),
1573 name: "read_file".to_string(),
1574 input: json!({"path": "src/main.rs"}),
1575 caller: None,
1576 }],
1577 },
1578 // No tool result follows — it was removed by compaction.
1579 Message {
1580 role: "user".to_string(),
1581 content: vec![ContentBlock::Text {
1582 text: "continue".to_string(),
1583 cache_control: None,
1584 }],
1585 },
1586 ];
1587
1588 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
1589 let assistant = out
1590 .iter()
1591 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"));
1592 // The safety net may drop the assistant message entirely if it only
1593 // contained orphaned tool_calls and no text content.
1594 assert!(
1595 assistant.is_none(),
1596 "assistant without content/tool_calls should be removed"
1597 );
1598 assert!(
1599 !out.iter()
1600 .any(|v| v.get("role").and_then(Value::as_str) == Some("tool")),
1601 "orphaned tool results should also be removed"
1602 );
1603 }
1604
1605 #[test]
1606 fn chat_messages_keeps_valid_tool_calls_intact() {
1607 // Complete call+result pair should NOT be stripped.
1608 let messages = vec![
1609 Message {
1610 role: "assistant".to_string(),
1611 content: vec![
1612 ContentBlock::Thinking {
1613 thinking: "Need to list files".to_string(),
1614 },
1615 ContentBlock::ToolUse {
1616 id: "tool-ok".to_string(),
1617 name: "list_dir".to_string(),
1618 input: json!({}),
1619 caller: None,
1620 },
1621 ],
1622 },
1623 Message {
1624 role: "user".to_string(),
1625 content: vec![ContentBlock::ToolResult {
1626 tool_use_id: "tool-ok".to_string(),
1627 content: "files".to_string(),
1628 is_error: None,
1629 content_blocks: None,
1630 }],
1631 },
1632 ];
1633
1634 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
1635 let assistant = out
1636 .iter()
1637 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
1638 .expect("assistant message");
1639 assert!(
1640 assistant.get("tool_calls").is_some(),
1641 "valid tool_calls should remain intact"
1642 );
1643 assert!(
1644 out.iter()
1645 .any(|value| value.get("role").and_then(Value::as_str) == Some("tool")),
1646 "tool result should remain"
1647 );
1648 }
1649
1650 #[test]
1651 fn chat_messages_strips_partial_tool_results() {
1652 let messages = vec![
1653 Message {
1654 role: "assistant".to_string(),
1655 content: vec![
1656 ContentBlock::ToolUse {
1657 id: "t1".to_string(),
1658 name: "read_file".to_string(),
1659 input: json!({"path": "a.rs"}),
1660 caller: None,
1661 },
1662 ContentBlock::ToolUse {
1663 id: "t2".to_string(),
1664 name: "read_file".to_string(),
1665 input: json!({"path": "b.rs"}),
1666 caller: None,
1667 },
1668 ContentBlock::ToolUse {
1669 id: "t3".to_string(),
1670 name: "shell".to_string(),
1671 input: json!({"cmd": "ls"}),
1672 caller: None,
1673 },
1674 ],
1675 },
1676 Message {
1677 role: "user".to_string(),
1678 content: vec![ContentBlock::ToolResult {
1679 tool_use_id: "t1".to_string(),
1680 content: "content a".to_string(),
1681 is_error: None,
1682 content_blocks: None,
1683 }],
1684 },
1685 Message {
1686 role: "user".to_string(),
1687 content: vec![ContentBlock::ToolResult {
1688 tool_use_id: "t2".to_string(),
1689 content: "content b".to_string(),
1690 is_error: None,
1691 content_blocks: None,
1692 }],
1693 },
1694 // No result for t3
1695 Message {
1696 role: "user".to_string(),
1697 content: vec![ContentBlock::Text {
1698 text: "continue".to_string(),
1699 cache_control: None,
1700 }],
1701 },
1702 ];
1703
1704 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
1705 let assistant = out
1706 .iter()
1707 .find(|v| v.get("role").and_then(Value::as_str) == Some("assistant"));
1708 assert!(
1709 assistant.is_none(),
1710 "assistant with only partial tool_calls should be removed"
1711 );
1712 assert!(
1713 !out.iter()
1714 .any(|v| v.get("role").and_then(Value::as_str) == Some("tool")),
1715 "all orphaned tool results should be removed"
1716 );
1717 }
1718
1719 #[test]
1720 fn parse_models_response_parses_and_deduplicates() {
1721 let payload = r#"{
1722 "object": "list",
1723 "data": [
1724 {"id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek", "created": 1},
1725 {"id": "deepseek-v4-flash", "object": "model"},
1726 {"id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek", "created": 1}
1727 ]
1728 }"#;
1729
1730 let models = parse_models_response(payload).expect("parse models");
1731 assert_eq!(
1732 models,
1733 vec![
1734 AvailableModel {
1735 id: "deepseek-v4-flash".to_string(),
1736 owned_by: None,
1737 created: None
1738 },
1739 AvailableModel {
1740 id: "deepseek-v4-pro".to_string(),
1741 owned_by: Some("deepseek".to_string()),
1742 created: Some(1)
1743 }
1744 ]
1745 );
1746 }
1747
1748 #[test]
1749 fn parse_usage_reads_deepseek_cache_and_reasoning_tokens() {
1750 let usage = parse_usage(Some(&json!({
1751 "prompt_tokens": 100,
1752 "completion_tokens": 20,
1753 "prompt_cache_hit_tokens": 70,
1754 "prompt_cache_miss_tokens": 30,
1755 "completion_tokens_details": {
1756 "reasoning_tokens": 12
1757 }
1758 })));
1759
1760 assert_eq!(usage.input_tokens, 100);
1761 assert_eq!(usage.output_tokens, 20);
1762 assert_eq!(usage.prompt_cache_hit_tokens, Some(70));
1763 assert_eq!(usage.prompt_cache_miss_tokens, Some(30));
1764 assert_eq!(usage.reasoning_tokens, Some(12));
1765 }
1766
1767 #[test]
1768 fn parse_usage_counts_reasoning_tokens_when_completion_tokens_are_zero() {
1769 let usage = parse_usage(Some(&json!({
1770 "prompt_tokens": 100,
1771 "completion_tokens": 0,
1772 "completion_tokens_details": {
1773 "reasoning_tokens": 12
1774 }
1775 })));
1776
1777 assert_eq!(usage.input_tokens, 100);
1778 assert_eq!(usage.output_tokens, 12);
1779 assert_eq!(usage.reasoning_tokens, Some(12));
1780 assert!(
1781 crate::pricing::calculate_turn_cost_from_usage("deepseek-v4-pro", &usage)
1782 .expect("DeepSeek V4 Pro pricing should apply")
1783 > 0.0
1784 );
1785 }
1786
1787 #[test]
1788 fn parse_usage_reads_v4_prompt_tokens_details_cached_tokens() {
1789 let usage = parse_usage(Some(&json!({
1790 "prompt_tokens": 4000,
1791 "completion_tokens": 20,
1792 "prompt_tokens_details": {
1793 "cached_tokens": 3000
1794 }
1795 })));
1796
1797 assert_eq!(usage.input_tokens, 4000);
1798 assert_eq!(usage.output_tokens, 20);
1799 assert_eq!(usage.prompt_cache_hit_tokens, Some(3000));
1800 assert_eq!(usage.prompt_cache_miss_tokens, Some(1000));
1801 }
1802
1803 #[test]
1804 fn sanitize_thinking_mode_counts_reasoning_replay_across_assistant_turns() {
1805 // Multi-turn body that mimics two prior tool-calling rounds: each
1806 // assistant message carries its `reasoning_content`. The sanitizer
1807 // should keep all of them and the count helper should tally bytes
1808 // across every assistant message.
1809 let mut body = json!({
1810 "model": "deepseek-v4-pro",
1811 "messages": [
1812 { "role": "system", "content": "you are helpful" },
1813 { "role": "user", "content": "step 1" },
1814 {
1815 "role": "assistant",
1816 "content": "",
1817 "reasoning_content": "I need to call tool A first.",
1818 "tool_calls": [{ "id": "1", "type": "function" }]
1819 },
1820 { "role": "tool", "tool_call_id": "1", "content": "ok" },
1821 {
1822 "role": "assistant",
1823 "content": "",
1824 "reasoning_content": "Now I call tool B.",
1825 "tool_calls": [{ "id": "2", "type": "function" }]
1826 },
1827 { "role": "tool", "tool_call_id": "2", "content": "ok" },
1828 { "role": "user", "content": "step 2" }
1829 ]
1830 });
1831
1832 let approx_tokens =
1833 sanitize_thinking_mode_messages(&mut body, "deepseek-v4-pro", Some("max"))
1834 .expect("multi-turn thinking-mode conversation should report replay tokens");
1835 // ~4 chars/token; 46 bytes of reasoning -> 11 tokens.
1836 assert_eq!(approx_tokens, 11);
1837
1838 let chars = count_reasoning_replay_chars(&body);
1839 // "I need to call tool A first." (28) + "Now I call tool B." (18) = 46
1840 assert_eq!(chars, 46);
1841
1842 // No assistant messages should have lost or had their reasoning_content blanked.
1843 let messages = body["messages"].as_array().unwrap();
1844 let assistant_with_reasoning: usize = messages
1845 .iter()
1846 .filter(|m| m["role"] == "assistant")
1847 .filter(|m| {
1848 m["reasoning_content"]
1849 .as_str()
1850 .is_some_and(|s| !s.is_empty())
1851 })
1852 .count();
1853 assert_eq!(assistant_with_reasoning, 2);
1854 }
1855
1856 /// Issue #30: when no thinking-mode replay applies (non-thinking model or
1857 /// empty conversation), the sanitizer returns `None` so the footer chip
1858 /// stays hidden.
1859 #[test]
1860 fn sanitize_thinking_mode_returns_none_for_non_thinking_model() {
1861 let mut body = json!({
1862 "model": "deepseek-v4-flash",
1863 "messages": [
1864 { "role": "user", "content": "hi" }
1865 ]
1866 });
1867 let result = sanitize_thinking_mode_messages(&mut body, "deepseek-v4-flash", None);
1868 // reasoning_effort is None → no thinking injection, result is None
1869 assert!(result.is_none());
1870 }
1871
1872 #[test]
1873 fn sanitize_thinking_mode_counts_substituted_placeholder() {
1874 // An assistant tool-call message is missing reasoning_content; the
1875 // sanitizer must inject the placeholder, and the count helper must
1876 // include the placeholder in the total (since it's in the wire
1877 // payload that ships to DeepSeek).
1878 let mut body = json!({
1879 "model": "deepseek-v4-pro",
1880 "messages": [
1881 { "role": "user", "content": "hi" },
1882 {
1883 "role": "assistant",
1884 "content": "",
1885 "tool_calls": [{ "id": "1", "type": "function" }]
1886 }
1887 ]
1888 });
1889
1890 sanitize_thinking_mode_messages(&mut body, "deepseek-v4-pro", Some("max"));
1891
1892 let chars = count_reasoning_replay_chars(&body);
1893 // "(reasoning omitted)" is 19 bytes.
1894 assert_eq!(chars, 19);
1895 }
1896
1897 #[test]
1898 fn token_bucket_enforces_delay_when_empty() {
1899 let now = Instant::now();
1900 let mut bucket = TokenBucket {
1901 enabled: true,
1902 capacity: 1.0,
1903 tokens: 1.0,
1904 refill_per_sec: 2.0,
1905 last_refill: now,
1906 };
1907
1908 assert!(bucket.delay_until_available(1.0).is_none());
1909 let delay = bucket
1910 .delay_until_available(1.0)
1911 .expect("bucket should require refill delay");
1912 assert!(
1913 delay >= Duration::from_millis(400) && delay <= Duration::from_millis(600),
1914 "unexpected refill delay: {delay:?}"
1915 );
1916 }
1917
1918 #[test]
1919 fn stream_buffer_pool_reuses_released_buffers() {
1920 let mut first = acquire_stream_buffer();
1921 first.extend_from_slice(b"hello");
1922 let released_capacity = first.capacity();
1923 release_stream_buffer(first);
1924
1925 let second = acquire_stream_buffer();
1926 assert!(second.is_empty());
1927 assert!(
1928 second.capacity() >= released_capacity,
1929 "pooled buffer capacity should be reused"
1930 );
1931 }
1932
1933 #[test]
1934 fn base_url_security_rejects_insecure_non_local_http() {
1935 let err = validate_base_url_security("http://api.deepseek.com")
1936 .expect_err("non-local insecure HTTP should be rejected");
1937 assert!(err.to_string().contains("Refusing insecure base URL"));
1938 }
1939
1940 #[test]
1941 fn base_url_security_allows_localhost_http() {
1942 assert!(validate_base_url_security("http://localhost:8080").is_ok());
1943 assert!(validate_base_url_security("http://127.0.0.1:8080").is_ok());
1944 }
1945
1946 #[test]
1947 fn connection_health_degrades_and_recovers() {
1948 let now = Instant::now();
1949 let mut health = ConnectionHealth::default();
1950 assert_eq!(health.state, ConnectionState::Healthy);
1951
1952 apply_request_failure(&mut health, now);
1953 assert_eq!(health.state, ConnectionState::Healthy);
1954
1955 apply_request_failure(&mut health, now + Duration::from_millis(1));
1956 assert_eq!(health.state, ConnectionState::Degraded);
1957 assert_eq!(health.consecutive_failures, 2);
1958
1959 let recovered = apply_request_success(&mut health, now + Duration::from_secs(1));
1960 assert!(recovered);
1961 assert_eq!(health.state, ConnectionState::Healthy);
1962 assert_eq!(health.consecutive_failures, 0);
1963 }
1964
1965 #[test]
1966 fn recovery_probe_respects_cooldown() {
1967 let now = Instant::now();
1968 let mut health = ConnectionHealth {
1969 state: ConnectionState::Degraded,
1970 ..ConnectionHealth::default()
1971 };
1972
1973 assert!(mark_recovery_probe_if_due(&mut health, now));
1974 assert_eq!(health.state, ConnectionState::Recovering);
1975 assert!(!mark_recovery_probe_if_due(
1976 &mut health,
1977 now + Duration::from_secs(1)
1978 ));
1979 assert!(mark_recovery_probe_if_due(
1980 &mut health,
1981 now + RECOVERY_PROBE_COOLDOWN + Duration::from_millis(1)
1982 ));
1983 }
1984
1985 // === #103 Phase 2: HTTP/1 escape hatch ===================================
1986
1987 /// Serialize tests that mutate `DEEPSEEK_FORCE_HTTP1` so they don't race
1988 /// against each other — env vars are process-global.
1989 static FORCE_HTTP1_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1990
1991 struct ForceHttp1EnvGuard {
1992 prior: Option<std::ffi::OsString>,
1993 }
1994 impl ForceHttp1EnvGuard {
1995 fn capture() -> Self {
1996 Self {
1997 prior: std::env::var_os("DEEPSEEK_FORCE_HTTP1"),
1998 }
1999 }
2000 }
2001 impl Drop for ForceHttp1EnvGuard {
2002 fn drop(&mut self) {
2003 // Safety: scoped to test process; reverts to the captured value.
2004 match &self.prior {
2005 Some(v) => unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", v) },
2006 None => unsafe { std::env::remove_var("DEEPSEEK_FORCE_HTTP1") },
2007 }
2008 }
2009 }
2010
2011 #[test]
2012 fn force_http1_unset_is_false() {
2013 let _lock = FORCE_HTTP1_ENV_LOCK.lock().unwrap();
2014 let _guard = ForceHttp1EnvGuard::capture();
2015 unsafe { std::env::remove_var("DEEPSEEK_FORCE_HTTP1") };
2016 assert!(!force_http1_from_env());
2017 }
2018
2019 #[test]
2020 fn force_http1_truthy_values() {
2021 let _lock = FORCE_HTTP1_ENV_LOCK.lock().unwrap();
2022 let _guard = ForceHttp1EnvGuard::capture();
2023 for value in ["1", "true", "True", "YES", "on", " 1 "] {
2024 // Safety: serialized by FORCE_HTTP1_ENV_LOCK; reverted by guard.
2025 unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", value) };
2026 assert!(
2027 force_http1_from_env(),
2028 "{value:?} should be parsed as truthy",
2029 );
2030 }
2031 }
2032
2033 #[test]
2034 fn force_http1_falsy_values() {
2035 let _lock = FORCE_HTTP1_ENV_LOCK.lock().unwrap();
2036 let _guard = ForceHttp1EnvGuard::capture();
2037 for value in ["0", "false", "no", "off", "", "garbage", "2"] {
2038 unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", value) };
2039 assert!(
2040 !force_http1_from_env(),
2041 "{value:?} should NOT be parsed as truthy",
2042 );
2043 }
2044 }
2045 }
2046
2046 lines RUST