Skip to main content

vtcode_llm/providers/
deepseek.rs

1use serde_json::{Map, Value};
2use vtcode_config::constants::{env_vars, models, urls};
3use vtcode_config::core::PromptCachingConfig;
4
5use super::extract_reasoning_trace;
6use super::openai_compat::{OpenAiCompatCore, OpenAiCompatSpec, SystemPromptPlacement, impl_openai_compat_provider};
7use crate::provider::{LLMError, LLMRequest};
8
9pub struct DeepSeekSpec;
10
11fn deepseek_reasoning(message: &Value, choice: &Value) -> Option<String> {
12    message
13        .get("reasoning_content")
14        .and_then(extract_reasoning_trace)
15        .or_else(|| message.get("reasoning").and_then(extract_reasoning_trace))
16        .or_else(|| choice.get("reasoning_content").and_then(extract_reasoning_trace))
17}
18
19impl OpenAiCompatSpec for DeepSeekSpec {
20    const NAME: &'static str = "DeepSeek";
21    const KEY: &'static str = "deepseek";
22    const API_KEY_ENV: &'static str = "DEEPSEEK_API_KEY";
23    const DEFAULT_MODEL: &'static str = models::deepseek::DEFAULT_MODEL;
24    const DEFAULT_BASE_URL: &'static str = urls::DEEPSEEK_API_BASE;
25    const BASE_URL_ENV: Option<&'static str> = Some(env_vars::DEEPSEEK_BASE_URL);
26    const LISTED_MODELS: &'static [&'static str] = models::deepseek::SUPPORTED_MODELS;
27    const VALIDATION_ALLOWLIST: Option<&'static [&'static str]> = Some(models::deepseek::SUPPORTED_MODELS);
28
29    const SYSTEM_PROMPT: SystemPromptPlacement = SystemPromptPlacement::FirstMessage;
30    const STREAM_OPTIONS_INCLUDE_USAGE: bool = true;
31    const INCLUDE_USER_ID: bool = true;
32    const RESPONSE_REASONING_EXTRACTOR: Option<super::openai_compat::ReasoningExtractor> = Some(deepseek_reasoning);
33
34    fn prompt_cache_enabled(prompt_cache: Option<&PromptCachingConfig>) -> bool {
35        prompt_cache.is_some_and(|cfg| {
36            let settings = &cfg.providers.deepseek;
37            cfg.enabled && settings.enabled && settings.surface_metrics
38        })
39    }
40
41    fn response_cache_metrics(core: &OpenAiCompatCore<Self>) -> bool {
42        core.prompt_cache_enabled
43    }
44
45    fn stream_cache_metrics(_core: &OpenAiCompatCore<Self>) -> bool {
46        true
47    }
48
49    fn insert_reasoning(
50        core: &OpenAiCompatCore<Self>,
51        request: &LLMRequest,
52        payload: &mut Map<String, Value>,
53    ) -> Result<(), LLMError> {
54        if let Some(effort) = request.reasoning_effort {
55            if effort == vtcode_config::types::ReasoningEffortLevel::None {
56                payload.insert("thinking".to_owned(), serde_json::json!({"type": "disabled"}));
57            } else {
58                use crate::rig_adapter::RigProviderCapabilities;
59                use vtcode_config::models::Provider;
60                let supported = crate::provider::catalog_or_explicit_reasoning_efforts(
61                    Self::KEY,
62                    &request.model,
63                    core.model_behavior
64                        .as_ref()
65                        .and_then(|behavior| behavior.model_supports_reasoning_effort)
66                        .unwrap_or(false),
67                );
68                if let Some(params) = RigProviderCapabilities::new(Provider::DeepSeek, &request.model)
69                    .reasoning_parameters_for_supported_efforts(effort, supported)?
70                    && let Some(obj) = params.as_object()
71                {
72                    for (k, v) in obj {
73                        payload.insert(k.clone(), v.clone());
74                    }
75                }
76            }
77        }
78        Ok(())
79    }
80
81    fn finish_payload(
82        _core: &OpenAiCompatCore<Self>,
83        _request: &LLMRequest,
84        payload: &mut Map<String, Value>,
85    ) -> Result<(), LLMError> {
86        // DeepSeek vision Files API requires flat `file_id`/`file_data` at top level
87        // of the `file` content part, not nested under `file`. `common.rs` emits
88        // nested `{file:{...}}` for provider-agnostic compat; promote to flat here.
89        let Some(messages) = payload.get_mut("messages").and_then(|m| m.as_array_mut()) else {
90            return Ok(());
91        };
92        for msg in messages {
93            let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) else {
94                continue;
95            };
96            for part in content {
97                if part.get("type").and_then(|t| t.as_str()) != Some("file") {
98                    continue;
99                }
100                // Clone nested file object if present and promote keys to top level.
101                if let Some(nested) = part.get("file").and_then(|f| f.as_object()).cloned() {
102                    let Some(obj) = part.as_object_mut() else { continue };
103                    for (k, v) in nested {
104                        obj.entry(k).or_insert(v);
105                    }
106                }
107            }
108        }
109        Ok(())
110    }
111}
112
113impl_openai_compat_provider!(DeepSeekProvider, DeepSeekSpec, {
114    fn supports_vision(&self, _model: &str) -> bool {
115        false
116    }
117
118    fn supports_reasoning(&self, model: &str) -> bool {
119        let raw = if model.trim().is_empty() {
120            &self.core.model
121        } else {
122            model
123        };
124        let normalized = raw.trim().rsplit('/').next().unwrap_or(raw).trim().to_ascii_lowercase();
125        // Codex-inspired robustness: Setting model_supports_reasoning to false
126        // does NOT disable it for known reasoning models.
127        self.core
128            .model_behavior
129            .as_ref()
130            .and_then(|b| b.model_supports_reasoning)
131            .unwrap_or(false)
132            || vtcode_config::models::model_catalog_entry("deepseek", &normalized).is_some_and(|entry| entry.reasoning)
133    }
134
135    fn supports_reasoning_effort(&self, model: &str) -> bool {
136        let raw = if model.trim().is_empty() {
137            &self.core.model
138        } else {
139            model
140        };
141        let normalized = raw.trim().rsplit('/').next().unwrap_or(raw).trim().to_ascii_lowercase();
142        // Curated catalog effort levels are authoritative for known routes;
143        // retain the legacy flags for explicitly configured or dynamic models.
144        self.core
145            .model_behavior
146            .as_ref()
147            .and_then(|b| b.model_supports_reasoning_effort)
148            .unwrap_or(false)
149            || vtcode_config::models::model_catalog_entry("deepseek", &normalized)
150                .is_some_and(|entry| !entry.reasoning_efforts.is_empty())
151            || normalized == "deepseek-reasoner"
152    }
153
154    async fn get_balance(&self) -> Result<Option<vtcode_commons::llm::BalanceInfo>, LLMError> {
155        // Strip /v1 suffix to get the root API URL for the balance endpoint.
156        let base = self.core.base_url.trim_end_matches('/');
157        let root = base.strip_suffix("/v1").unwrap_or(base);
158        let url = format!("{root}/user/balance");
159
160        let response = self
161            .core
162            .http_client
163            .get(&url)
164            .bearer_auth(&self.core.api_key)
165            .send()
166            .await
167            .map_err(|e| LLMError::Network {
168                message: crate::error_display::format_llm_error(
169                    <DeepSeekSpec as OpenAiCompatSpec>::NAME,
170                    &format!("balance request failed: {e}"),
171                ),
172                metadata: None,
173            })?;
174
175        if !response.status().is_success() {
176            let status = response.status();
177            let body = crate::providers::common::read_provider_error_body(response).await;
178            return Err(LLMError::Provider {
179                message: crate::error_display::format_llm_error(
180                    <DeepSeekSpec as OpenAiCompatSpec>::NAME,
181                    &format!("balance API returned {status}: {body}"),
182                ),
183                metadata: None,
184            });
185        }
186
187        let balance_resp: vtcode_commons::llm::DeepSeekBalanceResponse =
188            response.json().await.map_err(|e| LLMError::Provider {
189                message: crate::error_display::format_llm_error(
190                    <DeepSeekSpec as OpenAiCompatSpec>::NAME,
191                    &format!("failed to parse balance response: {e}"),
192                ),
193                metadata: None,
194            })?;
195
196        Ok(Some(balance_resp.into()))
197    }
198});
199
200#[cfg(test)]
201mod tests {
202    use super::DeepSeekProvider;
203    use crate::provider::{ImageDetail, LLMProvider, LLMRequest, Message, ToolChoice};
204    use crate::reasoning_effort::ReasoningEffortMapper;
205    use std::sync::Arc;
206    use vtcode_config::constants::models;
207    use vtcode_config::types::ReasoningEffortLevel;
208
209    #[test]
210    fn catalog_efforts_are_exposed_to_resolution_and_request_building() {
211        let provider = DeepSeekProvider::new("test-key".to_string());
212        let model = models::deepseek::DEEPSEEK_FLASH;
213
214        assert_eq!(provider.supported_reasoning_efforts(model), &["low", "high", "max"]);
215        let mapping = ReasoningEffortMapper::resolve(&provider, model, ReasoningEffortLevel::High, false)
216            .expect("catalog-supported DeepSeek effort should resolve");
217        assert_eq!(mapping.effective, ReasoningEffortLevel::High);
218    }
219
220    #[test]
221    fn macro_emits_non_streaming_capability_for_stream_timeout_fallback() {
222        // The `impl_openai_compat_provider!` emission is shared by every
223        // compat provider; losing it would silently disable the runloop's
224        // stream-timeout fallback to non-streaming for all of them.
225        let provider = DeepSeekProvider::new("test-key".to_string());
226        assert!(LLMProvider::supports_non_streaming(&provider, models::deepseek::DEEPSEEK_FLASH));
227    }
228
229    fn base_request() -> LLMRequest {
230        LLMRequest {
231            messages: vec![Message::user("hello".to_string())].into(),
232            system_prompt: Some(Arc::from("system guidance")),
233            model: models::deepseek::DEFAULT_MODEL.to_string(),
234            max_tokens: Some(512),
235            temperature: Some(0.5),
236            top_p: Some(0.25),
237            stream: true,
238            tool_choice: Some(ToolChoice::Auto),
239            metadata: Some(serde_json::json!({"user_id": "user-42"})),
240            ..Default::default()
241        }
242    }
243
244    #[test]
245    fn golden_payload_basic_shape() {
246        let provider = DeepSeekProvider::new("test-key".to_string());
247        let payload = provider.core.convert_request(&base_request()).unwrap();
248
249        assert_eq!(payload["model"], models::deepseek::DEFAULT_MODEL);
250        let messages = payload["messages"].as_array().unwrap();
251        assert_eq!(messages.len(), 2);
252        // DeepSeek is OpenAI-compatible chat completions: the system prompt
253        // must lead `messages` so the 7k-token stable prefix participates in
254        // disk prefix caching from token 0. A top-level `system` field is not
255        // part of the DeepSeek API and would drop the cached prefix.
256        assert_eq!(messages[0]["role"], "system");
257        assert_eq!(messages[0]["content"], "system guidance");
258        assert_eq!(messages[1]["role"], "user");
259        assert!(payload.get("system").is_none());
260        assert_eq!(payload["max_tokens"], 512);
261        assert_eq!(payload["temperature"], 0.5);
262        assert_eq!(payload["top_p"], 0.25);
263        assert_eq!(payload["stream"], true);
264        assert_eq!(payload["stream_options"]["include_usage"], true);
265        assert_eq!(payload["tool_choice"], "auto");
266        assert_eq!(payload["user_id"], "user-42");
267        assert!(payload.get("thinking").is_none());
268    }
269
270    #[test]
271    fn golden_payload_thinking_disabled_and_sampling_suppression() {
272        let provider = DeepSeekProvider::new("test-key".to_string());
273
274        let mut request = base_request();
275        request.reasoning_effort = Some(ReasoningEffortLevel::None);
276        let payload = provider.core.convert_request(&request).unwrap();
277        assert_eq!(payload["thinking"]["type"], "disabled");
278        assert_eq!(payload["temperature"], 0.5);
279        assert_eq!(payload["top_p"], 0.25);
280
281        let mut request = base_request();
282        request.reasoning_effort = Some(ReasoningEffortLevel::High);
283        let payload = provider.core.convert_request(&request).unwrap();
284        assert_eq!(payload["thinking"]["type"], "enabled");
285        assert_eq!(payload["reasoning_effort"], "high");
286        assert!(payload.get("temperature").is_none());
287        assert!(payload.get("top_p").is_none());
288
289        let mut request = base_request();
290        request.reasoning_effort = Some(ReasoningEffortLevel::Max);
291        let payload = provider.core.convert_request(&request).unwrap();
292        assert_eq!(payload["reasoning_effort"], "max");
293    }
294
295    #[test]
296    fn supports_vision_always_returns_false() {
297        let provider = DeepSeekProvider::new("test-key".to_string());
298        assert!(!provider.supports_vision(models::deepseek::DEEPSEEK_FLASH));
299        assert!(!provider.supports_vision(""));
300    }
301
302    #[test]
303    fn golden_payload_vision_base64_inline() {
304        let provider = DeepSeekProvider::new("test-key".to_string());
305        let msg = Message::user_with_parts(vec![
306            crate::provider::ContentPart::text("What is in this image?".to_string()),
307            crate::provider::ContentPart::image("abc123b64".to_string(), "image/jpeg".to_string()),
308        ]);
309        let req = LLMRequest {
310            messages: vec![msg].into(),
311            model: models::deepseek::DEEPSEEK_FLASH.to_string(),
312            ..Default::default()
313        };
314        let payload = provider.core.convert_request(&req).unwrap();
315        assert_eq!(payload["model"], models::deepseek::DEEPSEEK_FLASH);
316        let content = &payload["messages"][0]["content"];
317        let arr = content.as_array().expect("vision content should be array");
318        assert_eq!(arr.len(), 2);
319        assert_eq!(arr[0]["type"], "text");
320        assert_eq!(arr[1]["type"], "image_url");
321        assert_eq!(arr[1]["image_url"]["url"], "data:image/jpeg;base64,abc123b64");
322        assert!(arr[1]["image_url"].get("detail").is_none());
323    }
324
325    #[test]
326    fn golden_payload_vision_detail_and_external_url() {
327        let provider = DeepSeekProvider::new("test-key".to_string());
328        // detail low for base64
329        let msg_low = Message::user_with_parts(vec![crate::provider::ContentPart::image_with_detail(
330            "b64".to_string(),
331            "image/png".to_string(),
332            ImageDetail::Low,
333        )]);
334        let payload_low = provider
335            .core
336            .convert_request(&LLMRequest {
337                messages: vec![msg_low].into(),
338                model: models::deepseek::DEEPSEEK_FLASH.to_string(),
339                ..Default::default()
340            })
341            .unwrap();
342        assert_eq!(payload_low["messages"][0]["content"][0]["image_url"]["detail"], "low");
343
344        // external URL with high detail
345        let msg_url = Message::user_with_parts(vec![
346            crate::provider::ContentPart::image_from_url(
347                "https://example.com/image.jpg".to_string(),
348                Some(ImageDetail::High),
349            )
350            .unwrap(),
351        ]);
352        let payload_url = provider
353            .core
354            .convert_request(&LLMRequest {
355                messages: vec![msg_url].into(),
356                model: models::deepseek::DEEPSEEK_FLASH.to_string(),
357                ..Default::default()
358            })
359            .unwrap();
360        assert_eq!(payload_url["messages"][0]["content"][0]["image_url"]["url"], "https://example.com/image.jpg");
361        assert_eq!(payload_url["messages"][0]["content"][0]["image_url"]["detail"], "high");
362
363        // original and auto also accepted
364        for (detail, expected) in [(ImageDetail::Original, "original"), (ImageDetail::Auto, "auto")] {
365            let m = Message::user_with_parts(vec![
366                crate::provider::ContentPart::image_from_url("https://example.com/a.png".to_string(), Some(detail))
367                    .unwrap(),
368            ]);
369            let p = provider
370                .core
371                .convert_request(&LLMRequest {
372                    messages: vec![m].into(),
373                    model: models::deepseek::DEEPSEEK_FLASH.to_string(),
374                    ..Default::default()
375                })
376                .unwrap();
377            assert_eq!(p["messages"][0]["content"][0]["image_url"]["detail"], expected);
378        }
379    }
380
381    #[test]
382    fn golden_payload_vision_files_api() {
383        let provider = DeepSeekProvider::new("test-key".to_string());
384        // file_id reference (preferred for >32MiB or reused images)
385        let msg_file_id = Message::user_with_parts(vec![
386            crate::provider::ContentPart::text("What is in this image?".to_string()),
387            crate::provider::ContentPart::File {
388                content_type: "file".to_string(),
389                filename: None,
390                file_id: Some("file-api-xxxxxxxxxxxxxxxx".to_string()),
391                file_data: None,
392                file_url: None,
393            },
394        ]);
395        let payload = provider
396            .core
397            .convert_request(&LLMRequest {
398                messages: vec![msg_file_id].into(),
399                model: models::deepseek::DEEPSEEK_FLASH.to_string(),
400                ..Default::default()
401            })
402            .unwrap();
403        let arr = payload["messages"][0]["content"].as_array().unwrap();
404        assert_eq!(arr[1]["type"], "file");
405        assert_eq!(arr[1]["file_id"], "file-api-xxxxxxxxxxxxxxxx");
406        // legacy nested still present
407        assert_eq!(arr[1]["file"]["file_id"], "file-api-xxxxxxxxxxxxxxxx");
408
409        // inline file_data with filename
410        let msg_file_data = Message::user_with_parts(vec![crate::provider::ContentPart::File {
411            content_type: "file".to_string(),
412            filename: Some("image.jpg".to_string()),
413            file_id: None,
414            file_data: Some("data:image/jpeg;base64,abc".to_string()),
415            file_url: None,
416        }]);
417        let payload2 = provider
418            .core
419            .convert_request(&LLMRequest {
420                messages: vec![msg_file_data].into(),
421                model: models::deepseek::DEEPSEEK_FLASH.to_string(),
422                ..Default::default()
423            })
424            .unwrap();
425        let arr2 = payload2["messages"][0]["content"].as_array().unwrap();
426        assert_eq!(arr2[0]["type"], "file");
427        assert_eq!(arr2[0]["filename"], "image.jpg");
428        assert!(arr2[0].get("file_data").is_some());
429    }
430
431    #[test]
432    fn supported_models_includes_vision() {
433        let provider = DeepSeekProvider::new("k".to_string());
434        let models = provider.supported_models();
435        assert!(models.contains(&models::deepseek::DEEPSEEK_FLASH.to_string()));
436    }
437}