Skip to main content

rig_core/providers/
deepseek.rs

1//! DeepSeek API client and Rig integration
2//!
3//! # Example
4//! ```no_run
5//! use rig_core::{client::CompletionClient, providers::deepseek};
6//!
7//! # fn run() -> Result<(), Box<dyn std::error::Error>> {
8//! let client = deepseek::Client::new("DEEPSEEK_API_KEY")?;
9//!
10//! let deepseek_chat = client.completion_model(deepseek::DEEPSEEK_V4_FLASH);
11//! # Ok(())
12//! # }
13//! ```
14
15use serde_json::Value;
16
17use crate::client::{self, BearerAuth, DebugExt, Provider, ProviderClient};
18use crate::providers::openai;
19use crate::telemetry::ProviderResponseExt;
20use crate::{
21    completion::{self, CompletionError},
22    json_utils,
23};
24use serde::{Deserialize, Serialize};
25
26// ================================================================
27// Main DeepSeek Client
28// ================================================================
29const DEEPSEEK_API_BASE_URL: &str = "https://api.deepseek.com";
30
31#[derive(Debug, Default, Clone, Copy)]
32pub struct DeepSeekExt;
33#[derive(Debug, Default, Clone, Copy)]
34pub struct DeepSeekExtBuilder;
35
36type DeepSeekApiKey = BearerAuth;
37
38impl Provider for DeepSeekExt {
39    type Builder = DeepSeekExtBuilder;
40    const VERIFY_PATH: &'static str = "/user/balance";
41}
42
43impl openai::completion::OpenAICompatibleProvider for DeepSeekExt {
44    const PROVIDER_NAME: &'static str = "deepseek";
45
46    type StreamingUsage = Usage;
47
48    const EMITS_COMPLETE_SINGLE_CHUNK_TOOL_CALLS: bool = true;
49
50    // DeepSeek's API only supports `json_object` response formats (passed via
51    // `additional_params`), not the `json_schema` mapping of `output_schema`.
52    const SUPPORTS_RESPONSE_FORMAT: bool = false;
53
54    type Response = CompletionResponse;
55
56    fn finalize_request_body(&self, body: &mut Value) -> Result<(), CompletionError> {
57        let Some(map) = body.as_object_mut() else {
58            return Ok(());
59        };
60
61        // DeepSeek takes message `content` as a plain string, not an array of
62        // content parts, and echoes tool calls back with an `index` field.
63        if let Some(messages) = map.get_mut("messages").and_then(Value::as_array_mut) {
64            for message in messages {
65                let Some(message) = message.as_object_mut() else {
66                    continue;
67                };
68                let is_assistant = message.get("role").and_then(Value::as_str) == Some("assistant");
69
70                if let Some(content) = message.get_mut("content") {
71                    let separator = if is_assistant { "" } else { "\n" };
72                    // Text-only arrays flatten; an array carrying an image,
73                    // audio, video or file part is left alone so DeepSeek's
74                    // own rejection reaches the caller ("unknown variant
75                    // `image_url`, expected `text`", verified live). Dropping
76                    // those parts here answered the question from the text
77                    // alone and never told anyone the attachment was gone.
78                    openai::completion::flatten_text_content_parts(content, separator, true);
79                } else if is_assistant && !message.contains_key("content") {
80                    // Tool-call-only assistant turns must still carry an
81                    // (empty) string content field.
82                    message.insert("content".to_string(), Value::String(String::new()));
83                }
84
85                if is_assistant
86                    && let Some(tool_calls) =
87                        message.get_mut("tool_calls").and_then(Value::as_array_mut)
88                {
89                    for tool_call in tool_calls {
90                        if let Some(tool_call) = tool_call.as_object_mut() {
91                            tool_call
92                                .entry("index")
93                                .or_insert_with(|| serde_json::json!(0));
94                        }
95                    }
96                }
97            }
98        }
99
100        // DeepSeek rejects forced tool choices (`required` or a specific
101        // function) unless thinking is explicitly disabled; suppress them to
102        // an explicit `null` otherwise.
103        let thinking_disabled = map
104            .get("thinking")
105            .and_then(|thinking| thinking.get("type"))
106            .and_then(Value::as_str)
107            .is_some_and(|mode| mode.eq_ignore_ascii_case("disabled"));
108        if !thinking_disabled && let Some(tool_choice) = map.get_mut("tool_choice") {
109            let forced = tool_choice.is_object() || tool_choice.as_str() == Some("required");
110            if forced {
111                *tool_choice = Value::Null;
112            }
113        }
114
115        Ok(())
116    }
117}
118
119client::impl_capabilities!(
120    DeepSeekExt,
121    completion = CompletionModel<H>,
122    model_listing = DeepSeekModelLister<H>,
123);
124
125impl DebugExt for DeepSeekExt {}
126
127client::impl_default_provider_builder!(
128    DeepSeekExtBuilder => DeepSeekExt,
129    api_key = DeepSeekApiKey,
130    base_url = DEEPSEEK_API_BASE_URL,
131);
132
133pub type Client<H = reqwest::Client> = client::Client<DeepSeekExt, H>;
134pub type ClientBuilder<H = crate::markers::Missing> =
135    client::ClientBuilder<DeepSeekExtBuilder, DeepSeekApiKey, H>;
136
137/// DeepSeek completion model, driven by the shared OpenAI Chat Completions path.
138pub type CompletionModel<H = reqwest::Client> =
139    openai::completion::GenericCompletionModel<DeepSeekExt, H>;
140
141/// DeepSeek's provider-native terminal streaming record: the value carried by
142/// the final item of the stream returned by `CompletionModel::raw_stream`.
143/// Shared with the OpenAI Chat Completions path but carrying DeepSeek's own
144/// usage payload (cache hit/miss counters).
145pub type StreamingCompletionResponse = openai::StreamingCompletionResponse<Usage>;
146
147impl ProviderClient for Client {
148    type Input = DeepSeekApiKey;
149    type Error = crate::client::ProviderClientError;
150
151    // If you prefer the environment variable approach:
152    fn from_env() -> Result<Self, Self::Error> {
153        let api_key = crate::client::required_env_var("DEEPSEEK_API_KEY")?;
154        let mut client_builder = Self::builder();
155        client_builder.headers_mut().insert(
156            http::header::CONTENT_TYPE,
157            http::HeaderValue::from_static("application/json"),
158        );
159        let client_builder = client_builder.api_key(&api_key);
160        client_builder.build().map_err(Into::into)
161    }
162
163    fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
164        Self::new(input).map_err(Into::into)
165    }
166}
167
168/// The response shape from the DeepSeek API
169#[derive(Clone, Debug, Serialize, Deserialize)]
170pub struct CompletionResponse {
171    #[serde(default)]
172    pub id: Option<String>,
173    #[serde(default)]
174    pub model: Option<String>,
175    #[serde(default)]
176    pub object: Option<String>,
177    #[serde(default)]
178    pub system_fingerprint: Option<String>,
179    #[serde(
180        deserialize_with = "crate::providers::internal::openai_chat_completions_compatible::deserialize_choices_dropping_incomplete_tool_calls"
181    )]
182    pub choices: Vec<Choice>,
183    pub usage: Usage,
184}
185
186impl ProviderResponseExt for CompletionResponse {
187    type Usage = Usage;
188
189    fn get_response_id(&self) -> Option<String> {
190        self.id.clone()
191    }
192
193    fn get_response_model_name(&self) -> Option<String> {
194        self.model.clone()
195    }
196
197    fn get_text_response(&self) -> Option<String> {
198        self.choices
199            .iter()
200            .find_map(|choice| match &choice.message {
201                Message::Assistant { content, .. } if !content.is_empty() => Some(content.clone()),
202                _ => None,
203            })
204    }
205
206    fn get_usage(&self) -> Option<Self::Usage> {
207        Some(self.usage.clone())
208    }
209}
210
211#[derive(Clone, Debug, Serialize, Deserialize, Default)]
212#[serde(default)]
213pub struct Usage {
214    pub completion_tokens: u32,
215    pub prompt_tokens: u32,
216    pub prompt_cache_hit_tokens: u32,
217    pub prompt_cache_miss_tokens: u32,
218    pub total_tokens: u32,
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub completion_tokens_details: Option<CompletionTokensDetails>,
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub prompt_tokens_details: Option<PromptTokensDetails>,
223}
224
225impl From<&Usage> for crate::completion::Usage {
226    fn from(usage: &Usage) -> Self {
227        let mut normalized = crate::providers::internal::completion_usage(
228            usage.prompt_tokens as u64,
229            usage.completion_tokens as u64,
230            usage.total_tokens as u64,
231            usage
232                .prompt_tokens_details
233                .as_ref()
234                .and_then(|details| details.cached_tokens)
235                .map(u64::from)
236                // DeepSeek's native usage reports cache hits outside the
237                // OpenAI-style details object.
238                .unwrap_or(u64::from(usage.prompt_cache_hit_tokens)),
239        );
240        normalized.reasoning_tokens = usage
241            .completion_tokens_details
242            .as_ref()
243            .and_then(|details| details.reasoning_tokens)
244            .map(u64::from)
245            .unwrap_or(0);
246        normalized
247    }
248}
249
250impl From<Usage> for crate::completion::Usage {
251    fn from(usage: Usage) -> Self {
252        Self::from(&usage)
253    }
254}
255
256#[derive(Clone, Debug, Serialize, Deserialize, Default)]
257pub struct CompletionTokensDetails {
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub reasoning_tokens: Option<u32>,
260}
261
262#[derive(Clone, Debug, Serialize, Deserialize, Default)]
263pub struct PromptTokensDetails {
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub cached_tokens: Option<u32>,
266}
267
268#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
269pub struct Choice {
270    pub index: usize,
271    pub message: Message,
272    pub logprobs: Option<serde_json::Value>,
273    pub finish_reason: String,
274}
275
276/// DeepSeek's provider-native message shape, as it appears in responses.
277#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
278#[serde(tag = "role", rename_all = "lowercase")]
279pub enum Message {
280    Assistant {
281        content: String,
282        #[serde(skip_serializing_if = "Option::is_none")]
283        name: Option<String>,
284        #[serde(
285            default,
286            deserialize_with = "json_utils::null_or_default",
287            skip_serializing_if = "Vec::is_empty"
288        )]
289        tool_calls: Vec<ToolCall>,
290        /// only exists on `deepseek-reasoner` model at time of addition
291        #[serde(skip_serializing_if = "Option::is_none")]
292        reasoning_content: Option<String>,
293    },
294}
295
296#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
297pub struct ToolCall {
298    pub id: String,
299    pub index: usize,
300    #[serde(default)]
301    pub r#type: ToolType,
302    pub function: Function,
303}
304
305#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
306pub struct Function {
307    pub name: String,
308    #[serde(with = "json_utils::stringified_json")]
309    pub arguments: serde_json::Value,
310}
311
312#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
313#[serde(rename_all = "lowercase")]
314pub enum ToolType {
315    #[default]
316    Function,
317}
318
319/// Normalize a DeepSeek chat completion response.
320///
321/// The provider descriptor name is an *input* rather than a constant so the
322/// shared OpenAI-compatible completion path labels the response with the
323/// descriptor that actually produced it, exactly as it does for the OpenAI
324/// wire type.
325impl crate::completion::NormalizeCompletionResponse for CompletionResponse {
326    fn normalize(self, provider: &str) -> Result<completion::CompletionResponse, CompletionError> {
327        use crate::providers::internal::openai_chat_completions_compatible as compat;
328
329        let usage = crate::completion::Usage::from(&self.usage);
330        compat::normalize_openai_response(
331            provider,
332            &self.choices,
333            self.id.as_deref(),
334            self.model.as_deref(),
335            usage,
336            |choice| choice.finish_reason.as_str(),
337            |choice| {
338                let Message::Assistant {
339                    content: text,
340                    tool_calls,
341                    reasoning_content,
342                    ..
343                } = &choice.message;
344                // Reasoning leads the turn, as it does on the streaming
345                // path: DeepSeek's stream emits every `reasoning_content`
346                // delta before the first `content` delta and before the tool
347                // call, and the shared canonical chunk order is the same
348                // (reasoning, then text, then tool events). Appending it last
349                // made the two transports disagree about identical bytes.
350                let mut content = match reasoning_content {
351                    Some(reasoning_content) => {
352                        vec![completion::AssistantContent::reasoning(reasoning_content)]
353                    }
354                    None => Vec::new(),
355                };
356
357                content.extend(compat::text_then_tool_calls(
358                    text,
359                    text.trim().is_empty(),
360                    tool_calls.iter().map(|call| {
361                        (
362                            call.id.as_str(),
363                            call.function.name.as_str(),
364                            call.function.arguments.clone(),
365                        )
366                    }),
367                ));
368
369                Some(content)
370            },
371        )
372    }
373}
374
375crate::providers::internal::model_listing::impl_model_lister!(
376    /// [`ModelLister`](crate::client::ModelLister) implementation for the
377    /// DeepSeek API (`GET /models`).
378    DeepSeekModelLister,
379    Client<H>,
380    crate::providers::internal::model_listing::ListModelEntry,
381    "DeepSeek",
382    "/models"
383);
384
385// ================================================================
386// DeepSeek Completion API
387// ================================================================
388#[deprecated(
389    note = "The model names `deepseek-chat` and `deepseek-reasoner` will be deprecated on 2026/07/24. \
390    For compatibility, they correspond to the non-thinking mode and thinking mode of `deepseek-v4-flash`, \
391    respectively."
392)]
393pub const DEEPSEEK_CHAT: &str = "deepseek-chat";
394#[deprecated(
395    note = "The model names `deepseek-chat` and `deepseek-reasoner` will be deprecated on 2026/07/24. \
396    For compatibility, they correspond to the non-thinking mode and thinking mode of `deepseek-v4-flash`, \
397    respectively."
398)]
399pub const DEEPSEEK_REASONER: &str = "deepseek-reasoner";
400pub const DEEPSEEK_V4_FLASH: &str = "deepseek-v4-flash";
401pub const DEEPSEEK_V4_PRO: &str = "deepseek-v4-pro";
402
403// Tests
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crate::client::ModelListingClient;
408    use crate::completion::NormalizeCompletionResponse;
409    use crate::completion::{
410        CompletionRequestBuilder, FinishReason, ToolDefinition as RigToolDefinition,
411    };
412    use crate::message::ToolChoice as RigToolChoice;
413    use crate::model::ModelListingError;
414    use crate::providers::openai::completion::{
415        CompletionRequest as OpenAICompletionRequest, OpenAICompatibleProvider, OpenAIRequestParams,
416    };
417    use crate::test_utils::{MockCompletionModel, RecordingHttpClient};
418
419    /// Normalize a DeepSeek wire response the way the shared completion path
420    /// does, threading DeepSeek's own descriptor name through the conversion.
421    fn normalized(response: CompletionResponse) -> crate::completion::CompletionResponse {
422        response
423            .normalize(DeepSeekExt::PROVIDER_NAME)
424            .expect("DeepSeek response should convert")
425    }
426
427    fn finalized_body(request: crate::completion::CompletionRequest) -> serde_json::Value {
428        let request = OpenAICompletionRequest::try_from(OpenAIRequestParams {
429            model: "deepseek-v4-flash".to_string(),
430            request,
431            strict_tools: false,
432            tool_result_array_content: false,
433            supports_response_format: DeepSeekExt::SUPPORTS_RESPONSE_FORMAT,
434            supports_tools: true,
435        })
436        .expect("request should convert");
437        let mut body = serde_json::to_value(request).expect("request should serialize");
438        DeepSeekExt
439            .finalize_request_body(&mut body)
440            .expect("finalize should succeed");
441        body
442    }
443
444    #[test]
445    fn test_deserialize_vec_choice() {
446        let data = r#"[{
447            "finish_reason": "stop",
448            "index": 0,
449            "logprobs": null,
450            "message":{"role":"assistant","content":"Hello, world!"}
451            }]"#;
452
453        let choices: Vec<Choice> = serde_json::from_str(data).unwrap();
454        assert_eq!(choices.len(), 1);
455        match &choices.first().unwrap().message {
456            Message::Assistant { content, .. } => assert_eq!(content, "Hello, world!"),
457        }
458    }
459
460    #[test]
461    fn test_deserialize_deepseek_response() {
462        let data = r#"{
463            "choices":[{
464                "finish_reason": "stop",
465                "index": 0,
466                "logprobs": null,
467                "message":{"role":"assistant","content":"Hello, world!"}
468            }],
469            "usage": {
470                "completion_tokens": 0,
471                "prompt_tokens": 0,
472                "prompt_cache_hit_tokens": 0,
473                "prompt_cache_miss_tokens": 0,
474                "total_tokens": 0
475            }
476        }"#;
477
478        let jd = &mut serde_json::Deserializer::from_str(data);
479        let result: Result<CompletionResponse, _> = serde_path_to_error::deserialize(jd);
480        match result {
481            Ok(response) => match &response.choices.first().unwrap().message {
482                Message::Assistant { content, .. } => assert_eq!(content, "Hello, world!"),
483            },
484            Err(err) => {
485                panic!("Deserialization error at {}: {}", err.path(), err);
486            }
487        }
488    }
489
490    #[test]
491    fn deepseek_request_serializes_specific_tool_choice_as_chat_completions_object() {
492        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Use a tool.")
493            .tool(RigToolDefinition {
494                name: "alpha".to_string(),
495                description: "Alpha tool".to_string(),
496                parameters: serde_json::json!({
497                    "type": "object",
498                    "properties": {},
499                    "required": []
500                }),
501            })
502            .tool(RigToolDefinition {
503                name: "beta".to_string(),
504                description: "Beta tool".to_string(),
505                parameters: serde_json::json!({
506                    "type": "object",
507                    "properties": {},
508                    "required": []
509                }),
510            })
511            .tool_choice(RigToolChoice::Specific {
512                function_names: vec!["beta".to_string()],
513            })
514            .additional_params(serde_json::json!({"thinking": {"type": "disabled"}}))
515            .build();
516
517        let body = finalized_body(request);
518
519        assert_eq!(
520            body["tool_choice"],
521            serde_json::json!({"type": "function", "function": {"name": "beta"}})
522        );
523    }
524
525    #[test]
526    fn deepseek_request_suppresses_required_tool_choice_when_thinking_is_not_disabled() {
527        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Use a tool.")
528            .tool(RigToolDefinition {
529                name: "alpha".to_string(),
530                description: "Alpha tool".to_string(),
531                parameters: serde_json::json!({
532                    "type": "object",
533                    "properties": {},
534                    "required": []
535                }),
536            })
537            .tool_choice(RigToolChoice::Required)
538            .build();
539
540        let body = finalized_body(request);
541
542        assert!(
543            body.as_object()
544                .expect("body is object")
545                .contains_key("tool_choice"),
546            "suppressed tool_choice should stay present as an explicit null"
547        );
548        assert_eq!(body["tool_choice"], serde_json::Value::Null);
549    }
550
551    #[test]
552    fn deepseek_request_flattens_message_content_to_strings() {
553        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Hello!")
554            .preamble("You are helpful.".to_string())
555            .build();
556
557        let body = finalized_body(request);
558
559        assert_eq!(body["messages"][0]["role"], "system");
560        assert_eq!(body["messages"][0]["content"], "You are helpful.");
561        assert_eq!(body["messages"][1]["role"], "user");
562        assert_eq!(body["messages"][1]["content"], "Hello!");
563    }
564
565    #[test]
566    fn deepseek_finalize_joins_user_parts_with_newline_and_concats_assistant_parts() {
567        let mut body = serde_json::json!({
568            "model": "deepseek-v4-flash",
569            "messages": [
570                {"role": "user", "content": [
571                    {"type": "text", "text": "first part"},
572                    {"type": "text", "text": "second part"}
573                ]},
574                {"role": "assistant", "content": [
575                    {"type": "text", "text": "Hello"},
576                    {"type": "text", "text": " world"}
577                ]}
578            ]
579        });
580
581        DeepSeekExt
582            .finalize_request_body(&mut body)
583            .expect("finalize should succeed");
584
585        assert_eq!(body["messages"][0]["content"], "first part\nsecond part");
586        assert_eq!(body["messages"][1]["content"], "Hello world");
587    }
588
589    #[test]
590    fn deepseek_finalize_adds_tool_call_index_to_assistant_history() {
591        let mut body = serde_json::json!({
592            "model": "deepseek-v4-flash",
593            "messages": [{
594                "role": "assistant",
595                "content": "",
596                "tool_calls": [{
597                    "id": "call_1",
598                    "type": "function",
599                    "function": {"name": "subtract", "arguments": "{\"x\":2,\"y\":5}"}
600                }]
601            }]
602        });
603
604        DeepSeekExt
605            .finalize_request_body(&mut body)
606            .expect("finalize should succeed");
607
608        assert_eq!(body["messages"][0]["tool_calls"][0]["index"], 0);
609    }
610
611    #[test]
612    fn deepseek_response_preserves_metadata_and_reasoning_token_usage() {
613        let raw: CompletionResponse = serde_json::from_value(serde_json::json!({
614            "id": "chatcmpl_123",
615            "object": "chat.completion",
616            "model": "deepseek-v4-flash",
617            "system_fingerprint": "fp_123",
618            "choices": [{
619                "finish_reason": "stop",
620                "index": 0,
621                "logprobs": null,
622                "message": {
623                    "role": "assistant",
624                    "content": "done",
625                    "reasoning_content": "thinking"
626                }
627            }],
628            "usage": {
629                "completion_tokens": 8,
630                "completion_tokens_details": { "reasoning_tokens": 5 },
631                "prompt_tokens": 10,
632                "prompt_tokens_details": { "cached_tokens": 3 },
633                "prompt_cache_hit_tokens": 0,
634                "prompt_cache_miss_tokens": 10,
635                "total_tokens": 18
636            }
637        }))
638        .expect("fixture should deserialize");
639
640        let converted = normalized(raw.clone());
641
642        assert_eq!(raw.id.as_deref(), Some("chatcmpl_123"));
643        assert_eq!(raw.model.as_deref(), Some("deepseek-v4-flash"));
644        assert_eq!(raw.system_fingerprint.as_deref(), Some("fp_123"));
645        assert_eq!(converted.provider, "deepseek");
646        assert_eq!(converted.response_id.as_deref(), Some("chatcmpl_123"));
647        assert_eq!(converted.message_id, None);
648        assert_eq!(converted.model.as_deref(), Some("deepseek-v4-flash"));
649        assert_eq!(converted.finish_reason(), Some(FinishReason::Stop));
650        assert_eq!(converted.usage.input_tokens, 10);
651        assert_eq!(converted.usage.cached_input_tokens, 3);
652        assert_eq!(converted.usage.output_tokens, 8);
653        assert_eq!(converted.usage.reasoning_tokens, 5);
654    }
655
656    fn response_with_finish_reason(finish_reason: &str) -> CompletionResponse {
657        serde_json::from_value(serde_json::json!({
658            "id": "chatcmpl_finish",
659            "model": "deepseek-v4-flash",
660            "choices": [{
661                "finish_reason": finish_reason,
662                "index": 0,
663                "logprobs": null,
664                "message": {"role": "assistant", "content": "done"}
665            }],
666            "usage": {
667                "completion_tokens": 1,
668                "prompt_tokens": 1,
669                "prompt_cache_hit_tokens": 0,
670                "prompt_cache_miss_tokens": 1,
671                "total_tokens": 2
672            }
673        }))
674        .expect("fixture should deserialize")
675    }
676
677    #[test]
678    fn deepseek_finish_reasons_normalize_and_preserve_unknowns() {
679        for (wire, expected) in [
680            ("stop", FinishReason::Stop),
681            ("length", FinishReason::Length),
682            ("max_tokens", FinishReason::Length),
683            ("tool_calls", FinishReason::ToolCalls),
684            ("function_call", FinishReason::ToolCalls),
685            ("content_filter", FinishReason::ContentFilter),
686            // Anything DeepSeek invents survives verbatim rather than reading
687            // as a natural stop.
688            (
689                "insufficient_system_resource",
690                FinishReason::Other("insufficient_system_resource".to_owned()),
691            ),
692        ] {
693            let converted = normalized(response_with_finish_reason(wire));
694
695            assert_eq!(converted.finish_reason(), Some(expected), "wire: {wire}");
696        }
697    }
698
699    /// Build a one-choice DeepSeek turn out of its three assistant slots.
700    fn assistant_turn(
701        finish_reason: &str,
702        content: &str,
703        reasoning_content: Option<&str>,
704        tool_arguments: &[&str],
705    ) -> CompletionResponse {
706        let mut message = serde_json::json!({
707            "role": "assistant",
708            "content": content,
709        });
710        if let Some(reasoning_content) = reasoning_content {
711            message["reasoning_content"] = serde_json::Value::String(reasoning_content.to_owned());
712        }
713        if !tool_arguments.is_empty() {
714            message["tool_calls"] = tool_arguments
715                .iter()
716                .enumerate()
717                .map(|(index, arguments)| {
718                    serde_json::json!({
719                        "id": format!("call_{index}"),
720                        "index": index,
721                        "type": "function",
722                        "function": {"name": format!("tool_{index}"), "arguments": arguments},
723                    })
724                })
725                .collect();
726        }
727
728        serde_json::from_value(serde_json::json!({
729            "id": "chatcmpl_truncated",
730            "model": "deepseek-v4-flash",
731            "choices": [{
732                "finish_reason": finish_reason,
733                "index": 0,
734                "logprobs": null,
735                "message": message,
736            }],
737            "usage": {
738                "completion_tokens": 24,
739                "prompt_tokens": 372,
740                "prompt_cache_hit_tokens": 256,
741                "prompt_cache_miss_tokens": 116,
742                "total_tokens": 396
743            }
744        }))
745        .expect("fixture should deserialize")
746    }
747
748    fn block_kinds(choice: &[crate::completion::AssistantContent]) -> Vec<&'static str> {
749        choice
750            .iter()
751            .map(|content| match content {
752                crate::completion::AssistantContent::Text(_) => "text",
753                crate::completion::AssistantContent::ToolCall(_) => "tool_call",
754                crate::completion::AssistantContent::Reasoning(_) => "reasoning",
755                crate::completion::AssistantContent::Image(_) => "image",
756            })
757            .collect()
758    }
759
760    /// DeepSeek emits the tool call anyway when `max_tokens` runs out mid
761    /// arguments -- live turns capped at 24/32/48/64 tokens returned
762    /// `finish_reason: "length"` with `arguments` cut off partway through the
763    /// object. Parsing strictly took the whole response down with it: text,
764    /// usage, id, model and finish reason all went with the unusable call.
765    #[test]
766    fn deepseek_truncated_tool_arguments_do_not_destroy_the_response() {
767        // The 24-token budget's recorded `arguments`, verbatim; the text is
768        // added on top so the assertions below can show it survives too (the
769        // recorded 24-token turn itself came back with `content: ""`).
770        let raw = assistant_turn("length", "Acknowledged.", None, &[r#"{"summary": "#]);
771
772        assert!(
773            match &raw.choices[0].message {
774                Message::Assistant { tool_calls, .. } => tool_calls.is_empty(),
775            },
776            "the unusable call is dropped at decode, not surfaced as a sentinel"
777        );
778
779        let converted = normalized(raw);
780
781        assert_eq!(converted.finish_reason(), Some(FinishReason::Length));
782        assert_eq!(converted.response_id.as_deref(), Some("chatcmpl_truncated"));
783        assert_eq!(converted.model.as_deref(), Some("deepseek-v4-flash"));
784        assert_eq!(converted.usage.total_tokens, 396);
785        assert_eq!(converted.usage.cached_input_tokens, 256);
786        assert_eq!(block_kinds(&converted.choice), vec!["text"]);
787    }
788
789    /// The unusable call is dropped, exactly as the streaming path drops it,
790    /// while a complete sibling in the same turn survives.
791    #[test]
792    fn deepseek_parallel_calls_drop_only_the_truncated_one() {
793        let converted = normalized(assistant_turn(
794            "length",
795            "",
796            None,
797            &[r#"{"team": "platform"}"#, r#"{"summary": "Log this"#],
798        ));
799
800        assert_eq!(block_kinds(&converted.choice), vec!["tool_call"]);
801        let crate::completion::AssistantContent::ToolCall(call) = &converted.choice[0] else {
802            panic!("expected the complete call to survive");
803        };
804        assert_eq!(call.function.name, "tool_0");
805        assert_eq!(
806            call.function.arguments,
807            serde_json::json!({"team": "platform"})
808        );
809    }
810
811    /// The tolerant parse must not weaken a complete payload, and must keep
812    /// reading an empty one as a parameterless invocation.
813    #[test]
814    fn deepseek_complete_and_empty_tool_arguments_are_unaffected() {
815        let complete = normalized(assistant_turn(
816            "tool_calls",
817            "",
818            None,
819            &[r#"{"summary": "done"}"#],
820        ));
821        let crate::completion::AssistantContent::ToolCall(call) = &complete.choice[0] else {
822            panic!("expected a tool call");
823        };
824        assert_eq!(
825            call.function.arguments,
826            serde_json::json!({"summary": "done"})
827        );
828
829        let empty = normalized(assistant_turn("tool_calls", "", None, &[""]));
830        let crate::completion::AssistantContent::ToolCall(call) = &empty.choice[0] else {
831            panic!("expected a parameterless tool call");
832        };
833        assert_eq!(call.function.arguments, serde_json::json!({}));
834
835        let truncated_empty = normalized(assistant_turn("length", "", None, &[""]));
836        assert!(
837            truncated_empty.choice.is_empty(),
838            "an output-length turn with no argument tokens must not dispatch a tool"
839        );
840    }
841
842    /// DeepSeek documents that an ordinary function call may contain invalid
843    /// JSON. Without an outer `length` signal that is a provider response
844    /// defect and must not disappear from the native response.
845    #[test]
846    fn deepseek_malformed_completed_tool_call_is_loud() {
847        let response = serde_json::json!({
848            "id": "chatcmpl-malformed",
849            "model": "deepseek-v4-flash",
850            "choices": [{
851                "finish_reason": "tool_calls",
852                "index": 0,
853                "logprobs": null,
854                "message": {
855                    "role": "assistant",
856                    "content": "",
857                    "tool_calls": [{
858                        "id": "call_0",
859                        "index": 0,
860                        "type": "function",
861                        "function": {"name": "page", "arguments": "{\"team\":"}
862                    }]
863                }
864            }],
865            "usage": {
866                "completion_tokens": 1,
867                "prompt_tokens": 1,
868                "total_tokens": 2
869            }
870        });
871
872        assert!(
873            serde_json::from_value::<CompletionResponse>(response).is_err(),
874            "a completed malformed call must not be rewritten away"
875        );
876    }
877
878    /// The full blocking block-order enumeration: reasoning present/absent x
879    /// text present/absent x zero/one/two tool calls. Reasoning leads the
880    /// choice on every shape, which is the order DeepSeek's own stream emits
881    /// (`reasoning_content` deltas before the first `content` delta and before
882    /// the tool call) and the order the shared canonical chunk lifecycle
883    /// imposes. Appending it last made the two transports disagree about
884    /// identical wire bytes.
885    #[test]
886    fn deepseek_reasoning_leads_the_choice_on_every_turn_shape() {
887        for reasoning in [None, Some("thinking")] {
888            for text in ["", "spoken"] {
889                for calls in [
890                    &[][..],
891                    &[r#"{"x":1}"#][..],
892                    &[r#"{"x":1}"#, r#"{"y":2}"#][..],
893                ] {
894                    let finish_reason = if calls.is_empty() {
895                        "stop"
896                    } else {
897                        "tool_calls"
898                    };
899                    let raw = assistant_turn(finish_reason, text, reasoning, calls);
900                    // A turn with nothing in it at all is a provider defect the
901                    // shared skeleton rejects; it is not an ordering shape.
902                    if reasoning.is_none() && text.is_empty() && calls.is_empty() {
903                        continue;
904                    }
905                    let kinds = block_kinds(&normalized(raw).choice);
906
907                    let mut expected = Vec::new();
908                    if reasoning.is_some() {
909                        expected.push("reasoning");
910                    }
911                    if !text.is_empty() {
912                        expected.push("text");
913                    }
914                    expected.extend(std::iter::repeat_n("tool_call", calls.len()));
915
916                    assert_eq!(
917                        kinds,
918                        expected,
919                        "reasoning={reasoning:?} text={text:?} calls={}",
920                        calls.len()
921                    );
922                }
923            }
924        }
925    }
926
927    #[test]
928    fn deepseek_stop_finish_reason_upgrades_when_the_turn_called_a_tool() {
929        let raw: CompletionResponse = serde_json::from_value(serde_json::json!({
930            "id": "chatcmpl_tool",
931            "model": "deepseek-v4-flash",
932            "choices": [{
933                "finish_reason": "stop",
934                "index": 0,
935                "logprobs": null,
936                "message": {
937                    "role": "assistant",
938                    "content": "",
939                    "tool_calls": [{
940                        "id": "call_1",
941                        "index": 0,
942                        "type": "function",
943                        "function": {"name": "subtract", "arguments": "{\"x\":2,\"y\":5}"}
944                    }]
945                }
946            }],
947            "usage": {
948                "completion_tokens": 1,
949                "prompt_tokens": 1,
950                "prompt_cache_hit_tokens": 0,
951                "prompt_cache_miss_tokens": 1,
952                "total_tokens": 2
953            }
954        }))
955        .expect("fixture should deserialize");
956
957        assert_eq!(
958            normalized(raw).finish_reason(),
959            Some(FinishReason::ToolCalls)
960        );
961    }
962
963    #[test]
964    fn test_deserialize_example_response() {
965        let data = r#"
966        {
967            "id": "e45f6c68-9d9e-43de-beb4-4f402b850feb",
968            "object": "chat.completion",
969            "created": 0,
970            "model": "deepseek-chat",
971            "choices": [
972                {
973                    "index": 0,
974                    "message": {
975                        "role": "assistant",
976                        "content": "Why don’t skeletons fight each other?  \nBecause they don’t have the guts! 😄"
977                    },
978                    "logprobs": null,
979                    "finish_reason": "stop"
980                }
981            ],
982            "usage": {
983                "prompt_tokens": 13,
984                "completion_tokens": 32,
985                "total_tokens": 45,
986                "prompt_tokens_details": {
987                    "cached_tokens": 0
988                },
989                "prompt_cache_hit_tokens": 0,
990                "prompt_cache_miss_tokens": 13
991            },
992            "system_fingerprint": "fp_4b6881f2c5"
993        }
994        "#;
995        let jd = &mut serde_json::Deserializer::from_str(data);
996        let result: Result<CompletionResponse, _> = serde_path_to_error::deserialize(jd);
997
998        match result {
999            Ok(response) => match &response.choices.first().unwrap().message {
1000                Message::Assistant { content, .. } => assert_eq!(
1001                    content,
1002                    "Why don’t skeletons fight each other?  \nBecause they don’t have the guts! 😄"
1003                ),
1004            },
1005            Err(err) => {
1006                panic!("Deserialization error at {}: {}", err.path(), err);
1007            }
1008        }
1009    }
1010
1011    #[test]
1012    fn test_serialize_deserialize_tool_call_message() {
1013        let tool_call_choice_json = r#"
1014            {
1015              "finish_reason": "tool_calls",
1016              "index": 0,
1017              "logprobs": null,
1018              "message": {
1019                "content": "",
1020                "role": "assistant",
1021                "tool_calls": [
1022                  {
1023                    "function": {
1024                      "arguments": "{\"x\":2,\"y\":5}",
1025                      "name": "subtract"
1026                    },
1027                    "id": "call_0_2b4a85ee-b04a-40ad-a16b-a405caf6e65b",
1028                    "index": 0,
1029                    "type": "function"
1030                  }
1031                ]
1032              }
1033            }
1034        "#;
1035
1036        let choice: Choice =
1037            serde_json::from_str(tool_call_choice_json).expect("choice should deserialize");
1038        match &choice.message {
1039            Message::Assistant { tool_calls, .. } => {
1040                assert_eq!(tool_calls.len(), 1);
1041                let call = tool_calls.first().expect("one tool call");
1042                assert_eq!(call.function.name, "subtract");
1043                assert_eq!(call.index, 0);
1044            }
1045        }
1046
1047        let serialized = serde_json::to_value(&choice).expect("choice should serialize");
1048        assert_eq!(
1049            serialized["message"]["tool_calls"][0]["function"]["name"],
1050            "subtract"
1051        );
1052    }
1053
1054    #[test]
1055    fn test_client_initialization() {
1056        let _client =
1057            crate::providers::deepseek::Client::new("dummy-key").expect("Client::new() failed");
1058        let _client_from_builder = crate::providers::deepseek::Client::builder()
1059            .api_key("dummy-key")
1060            .build()
1061            .expect("Client::builder() failed");
1062    }
1063
1064    #[test]
1065    fn test_deserialize_list_models_response() {
1066        let data = r#"{
1067            "object": "list",
1068            "data": [
1069                {"id": "deepseek-chat", "object": "model", "owned_by": "deepseek"},
1070                {"id": "deepseek-reasoner", "object": "model", "owned_by": "deepseek"}
1071            ]
1072        }"#;
1073
1074        let response: crate::providers::internal::model_listing::DataEnvelope<
1075            crate::providers::internal::model_listing::ListModelEntry,
1076        > = serde_json::from_str(data).expect("list models response should deserialize");
1077        assert_eq!(response.data.len(), 2);
1078        assert_eq!(response.data[0].id, "deepseek-chat");
1079        assert_eq!(response.data[0].owned_by.as_deref(), Some("deepseek"));
1080    }
1081
1082    #[tokio::test]
1083    async fn test_list_models_uses_models_endpoint() {
1084        let response_body = r#"{
1085            "object": "list",
1086            "data": [
1087                {
1088                    "id": "deepseek-v4-flash",
1089                    "object": "model",
1090                    "owned_by": "deepseek"
1091                },
1092                {
1093                    "id": "deepseek-v4-pro",
1094                    "object": "model",
1095                    "owned_by": "deepseek"
1096                }
1097            ]
1098        }"#;
1099
1100        let http_client = RecordingHttpClient::new(response_body);
1101        let client = Client::builder()
1102            .api_key("dummy-key")
1103            .http_client(http_client.clone())
1104            .build()
1105            .expect("client should build");
1106
1107        let models = client
1108            .list_models()
1109            .await
1110            .expect("list_models should succeed");
1111
1112        assert_eq!(models.len(), 2);
1113        assert_eq!(models.data[0].id, "deepseek-v4-flash");
1114        assert_eq!(models.data[0].r#type, None);
1115        assert_eq!(models.data[0].owned_by.as_deref(), Some("deepseek"));
1116        let requests = http_client.requests();
1117        assert_eq!(requests.len(), 1);
1118        assert_eq!(requests[0].uri, "https://api.deepseek.com/models");
1119    }
1120
1121    #[tokio::test]
1122    async fn test_list_models_preserves_api_error_context() {
1123        let http_client = RecordingHttpClient::with_error(
1124            http::StatusCode::UNAUTHORIZED,
1125            r#"{"error":{"message":"invalid api key"}}"#,
1126        );
1127        let client = Client::builder()
1128            .api_key("dummy-key")
1129            .http_client(http_client)
1130            .build()
1131            .expect("client should build");
1132
1133        let error = client
1134            .list_models()
1135            .await
1136            .expect_err("list_models should fail");
1137
1138        match error {
1139            ModelListingError::ApiError {
1140                status_code,
1141                message,
1142            } => {
1143                assert_eq!(status_code, 401);
1144                assert!(message.contains("provider=DeepSeek"));
1145                assert!(message.contains("path=/models"));
1146                assert!(message.contains("invalid api key"));
1147            }
1148            other => panic!("expected api error, got {other:?}"),
1149        }
1150    }
1151}