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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(arr[1]["file"]["file_id"], "file-api-xxxxxxxxxxxxxxxx");
408
409 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}