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::{
18    self, BearerAuth, Capabilities, Capable, DebugExt, ModelLister, Nothing, Provider,
19    ProviderBuilder, ProviderClient,
20};
21use crate::completion::GetTokenUsage;
22use crate::http_client::{self, HttpClientExt};
23use crate::model::{Model, ModelList, ModelListingError};
24use crate::providers::openai;
25use crate::telemetry::ProviderResponseExt;
26use crate::{
27    OneOrMany,
28    completion::{self, CompletionError},
29    json_utils,
30    wasm_compat::{WasmCompatSend, WasmCompatSync},
31};
32use serde::{Deserialize, Serialize};
33
34// ================================================================
35// Main DeepSeek Client
36// ================================================================
37const DEEPSEEK_API_BASE_URL: &str = "https://api.deepseek.com";
38
39#[derive(Debug, Default, Clone, Copy)]
40pub struct DeepSeekExt;
41#[derive(Debug, Default, Clone, Copy)]
42pub struct DeepSeekExtBuilder;
43
44type DeepSeekApiKey = BearerAuth;
45
46impl Provider for DeepSeekExt {
47    type Builder = DeepSeekExtBuilder;
48    const VERIFY_PATH: &'static str = "/user/balance";
49}
50
51impl openai::completion::OpenAICompatibleProvider for DeepSeekExt {
52    const PROVIDER_NAME: &'static str = "deepseek";
53
54    type StreamingUsage = Usage;
55
56    const EMITS_COMPLETE_SINGLE_CHUNK_TOOL_CALLS: bool = true;
57
58    // DeepSeek's API only supports `json_object` response formats (passed via
59    // `additional_params`), not the `json_schema` mapping of `output_schema`.
60    const SUPPORTS_RESPONSE_FORMAT: bool = false;
61
62    type Response = CompletionResponse;
63
64    fn finalize_request_body(&self, body: &mut Value) -> Result<(), CompletionError> {
65        let Some(map) = body.as_object_mut() else {
66            return Ok(());
67        };
68
69        // DeepSeek takes message `content` as a plain string, not an array of
70        // content parts, and echoes tool calls back with an `index` field.
71        if let Some(messages) = map.get_mut("messages").and_then(Value::as_array_mut) {
72            for message in messages {
73                let Some(message) = message.as_object_mut() else {
74                    continue;
75                };
76                let is_assistant = message.get("role").and_then(Value::as_str) == Some("assistant");
77
78                if let Some(content) = message.get_mut("content") {
79                    let separator = if is_assistant { "" } else { "\n" };
80                    openai::completion::flatten_text_content_parts(content, separator, false);
81                } else if is_assistant && !message.contains_key("content") {
82                    // Tool-call-only assistant turns must still carry an
83                    // (empty) string content field.
84                    message.insert("content".to_string(), Value::String(String::new()));
85                }
86
87                if is_assistant
88                    && let Some(tool_calls) =
89                        message.get_mut("tool_calls").and_then(Value::as_array_mut)
90                {
91                    for tool_call in tool_calls {
92                        if let Some(tool_call) = tool_call.as_object_mut() {
93                            tool_call
94                                .entry("index")
95                                .or_insert_with(|| serde_json::json!(0));
96                        }
97                    }
98                }
99            }
100        }
101
102        // DeepSeek rejects forced tool choices (`required` or a specific
103        // function) unless thinking is explicitly disabled; suppress them to
104        // an explicit `null` otherwise.
105        let thinking_disabled = map
106            .get("thinking")
107            .and_then(|thinking| thinking.get("type"))
108            .and_then(Value::as_str)
109            .is_some_and(|mode| mode.eq_ignore_ascii_case("disabled"));
110        if !thinking_disabled && let Some(tool_choice) = map.get_mut("tool_choice") {
111            let forced = tool_choice.is_object() || tool_choice.as_str() == Some("required");
112            if forced {
113                *tool_choice = Value::Null;
114            }
115        }
116
117        Ok(())
118    }
119}
120
121impl<H> Capabilities<H> for DeepSeekExt {
122    type Completion = Capable<CompletionModel<H>>;
123    type Embeddings = Nothing;
124    type Transcription = Nothing;
125    type ModelListing = Capable<DeepSeekModelLister<H>>;
126    #[cfg(feature = "image")]
127    type ImageGeneration = Nothing;
128    #[cfg(feature = "audio")]
129    type AudioGeneration = Nothing;
130    type Rerank = Nothing;
131}
132
133impl DebugExt for DeepSeekExt {}
134
135impl ProviderBuilder for DeepSeekExtBuilder {
136    type Extension<H>
137        = DeepSeekExt
138    where
139        H: HttpClientExt;
140    type ApiKey = DeepSeekApiKey;
141
142    const BASE_URL: &'static str = DEEPSEEK_API_BASE_URL;
143
144    fn build<H>(
145        _builder: &client::ClientBuilder<Self, Self::ApiKey, H>,
146    ) -> http_client::Result<Self::Extension<H>>
147    where
148        H: HttpClientExt,
149    {
150        Ok(DeepSeekExt)
151    }
152}
153
154pub type Client<H = reqwest::Client> = client::Client<DeepSeekExt, H>;
155pub type ClientBuilder<H = crate::markers::Missing> =
156    client::ClientBuilder<DeepSeekExtBuilder, DeepSeekApiKey, H>;
157
158/// DeepSeek completion model, driven by the shared OpenAI Chat Completions path.
159pub type CompletionModel<H = reqwest::Client> =
160    openai::completion::GenericCompletionModel<DeepSeekExt, H>;
161
162/// Final streaming response, shared with the OpenAI Chat Completions path but
163/// carrying DeepSeek's own usage payload (cache hit/miss counters).
164pub type StreamingCompletionResponse = openai::StreamingCompletionResponse<Usage>;
165
166impl ProviderClient for Client {
167    type Input = DeepSeekApiKey;
168    type Error = crate::client::ProviderClientError;
169
170    // If you prefer the environment variable approach:
171    fn from_env() -> Result<Self, Self::Error> {
172        let api_key = crate::client::required_env_var("DEEPSEEK_API_KEY")?;
173        let mut client_builder = Self::builder();
174        client_builder.headers_mut().insert(
175            http::header::CONTENT_TYPE,
176            http::HeaderValue::from_static("application/json"),
177        );
178        let client_builder = client_builder.api_key(&api_key);
179        client_builder.build().map_err(Into::into)
180    }
181
182    fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
183        Self::new(input).map_err(Into::into)
184    }
185}
186
187/// The response shape from the DeepSeek API
188#[derive(Clone, Debug, Serialize, Deserialize)]
189pub struct CompletionResponse {
190    #[serde(default)]
191    pub id: Option<String>,
192    #[serde(default)]
193    pub model: Option<String>,
194    #[serde(default)]
195    pub object: Option<String>,
196    #[serde(default)]
197    pub system_fingerprint: Option<String>,
198    pub choices: Vec<Choice>,
199    pub usage: Usage,
200}
201
202impl ProviderResponseExt for CompletionResponse {
203    type OutputMessage = Message;
204    type Usage = Usage;
205
206    fn get_response_id(&self) -> Option<String> {
207        self.id.clone()
208    }
209
210    fn get_response_model_name(&self) -> Option<String> {
211        self.model.clone()
212    }
213
214    fn get_output_messages(&self) -> Vec<Self::OutputMessage> {
215        self.choices
216            .iter()
217            .map(|choice| choice.message.clone())
218            .collect()
219    }
220
221    fn get_text_response(&self) -> Option<String> {
222        self.choices
223            .iter()
224            .find_map(|choice| match &choice.message {
225                Message::Assistant { content, .. } if !content.is_empty() => Some(content.clone()),
226                _ => None,
227            })
228    }
229
230    fn get_usage(&self) -> Option<Self::Usage> {
231        Some(self.usage.clone())
232    }
233}
234
235#[derive(Clone, Debug, Serialize, Deserialize, Default)]
236pub struct Usage {
237    pub completion_tokens: u32,
238    pub prompt_tokens: u32,
239    pub prompt_cache_hit_tokens: u32,
240    pub prompt_cache_miss_tokens: u32,
241    pub total_tokens: u32,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub completion_tokens_details: Option<CompletionTokensDetails>,
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub prompt_tokens_details: Option<PromptTokensDetails>,
246}
247
248impl GetTokenUsage for Usage {
249    fn token_usage(&self) -> crate::completion::Usage {
250        crate::completion::Usage {
251            input_tokens: self.prompt_tokens as u64,
252            output_tokens: self.completion_tokens as u64,
253            total_tokens: self.total_tokens as u64,
254            cached_input_tokens: self
255                .prompt_tokens_details
256                .as_ref()
257                .and_then(|details| details.cached_tokens)
258                .map(u64::from)
259                .unwrap_or(0),
260            cache_creation_input_tokens: 0,
261            tool_use_prompt_tokens: 0,
262            reasoning_tokens: self
263                .completion_tokens_details
264                .as_ref()
265                .and_then(|details| details.reasoning_tokens)
266                .map(u64::from)
267                .unwrap_or(0),
268        }
269    }
270}
271
272#[derive(Clone, Debug, Serialize, Deserialize, Default)]
273pub struct CompletionTokensDetails {
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub reasoning_tokens: Option<u32>,
276}
277
278#[derive(Clone, Debug, Serialize, Deserialize, Default)]
279pub struct PromptTokensDetails {
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub cached_tokens: Option<u32>,
282}
283
284#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
285pub struct Choice {
286    pub index: usize,
287    pub message: Message,
288    pub logprobs: Option<serde_json::Value>,
289    pub finish_reason: String,
290}
291
292/// DeepSeek's provider-native message shape, as it appears in responses.
293#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
294#[serde(tag = "role", rename_all = "lowercase")]
295pub enum Message {
296    System {
297        content: String,
298        #[serde(skip_serializing_if = "Option::is_none")]
299        name: Option<String>,
300    },
301    User {
302        content: String,
303        #[serde(skip_serializing_if = "Option::is_none")]
304        name: Option<String>,
305    },
306    Assistant {
307        content: String,
308        #[serde(skip_serializing_if = "Option::is_none")]
309        name: Option<String>,
310        #[serde(
311            default,
312            deserialize_with = "json_utils::null_or_vec",
313            skip_serializing_if = "Vec::is_empty"
314        )]
315        tool_calls: Vec<ToolCall>,
316        /// only exists on `deepseek-reasoner` model at time of addition
317        #[serde(skip_serializing_if = "Option::is_none")]
318        reasoning_content: Option<String>,
319    },
320    #[serde(rename = "tool")]
321    ToolResult {
322        tool_call_id: String,
323        content: String,
324    },
325}
326
327#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
328pub struct ToolCall {
329    pub id: String,
330    pub index: usize,
331    #[serde(default)]
332    pub r#type: ToolType,
333    pub function: Function,
334}
335
336#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
337pub struct Function {
338    pub name: String,
339    #[serde(with = "json_utils::stringified_json")]
340    pub arguments: serde_json::Value,
341}
342
343#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
344#[serde(rename_all = "lowercase")]
345pub enum ToolType {
346    #[default]
347    Function,
348}
349
350impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionResponse> {
351    type Error = CompletionError;
352
353    fn try_from(response: CompletionResponse) -> Result<Self, Self::Error> {
354        let choice = response.choices.first().ok_or_else(|| {
355            CompletionError::ResponseError("Response contained no choices".to_owned())
356        })?;
357        let content = match &choice.message {
358            Message::Assistant {
359                content,
360                tool_calls,
361                reasoning_content,
362                ..
363            } => {
364                let mut content = if content.trim().is_empty() {
365                    vec![]
366                } else {
367                    vec![completion::AssistantContent::text(content)]
368                };
369
370                content.extend(
371                    tool_calls
372                        .iter()
373                        .map(|call| {
374                            completion::AssistantContent::tool_call(
375                                &call.id,
376                                &call.function.name,
377                                call.function.arguments.clone(),
378                            )
379                        })
380                        .collect::<Vec<_>>(),
381                );
382
383                if let Some(reasoning_content) = reasoning_content {
384                    content.push(completion::AssistantContent::reasoning(reasoning_content));
385                }
386
387                Ok(content)
388            }
389            _ => Err(CompletionError::ResponseError(
390                "Response did not contain a valid message or tool call".into(),
391            )),
392        }?;
393
394        let choice = OneOrMany::many(content).map_err(|_| {
395            CompletionError::ResponseError(
396                "Response contained no message or tool call (empty)".to_owned(),
397            )
398        })?;
399
400        let usage = response.usage.token_usage();
401
402        Ok(completion::CompletionResponse {
403            choice,
404            usage,
405            raw_response: response,
406            message_id: None,
407        })
408    }
409}
410
411#[derive(Debug, Deserialize)]
412struct ListModelsResponse {
413    data: Vec<ListModelEntry>,
414}
415
416#[derive(Debug, Deserialize)]
417struct ListModelEntry {
418    id: String,
419    owned_by: String,
420}
421
422impl From<ListModelEntry> for Model {
423    fn from(value: ListModelEntry) -> Self {
424        let mut model = Model::from_id(value.id);
425        model.owned_by = Some(value.owned_by);
426        model
427    }
428}
429
430/// [`ModelLister`] implementation for the DeepSeek API (`GET /models`).
431#[derive(Clone)]
432pub struct DeepSeekModelLister<H = reqwest::Client> {
433    client: Client<H>,
434}
435
436impl<H> ModelLister<H> for DeepSeekModelLister<H>
437where
438    H: HttpClientExt + WasmCompatSend + WasmCompatSync + 'static,
439{
440    type Client = Client<H>;
441
442    fn new(client: Self::Client) -> Self {
443        Self { client }
444    }
445
446    async fn list_all(&self) -> Result<ModelList, ModelListingError> {
447        let path = "/models";
448        let req = self.client.get(path)?.body(http_client::NoBody)?;
449        let response = self
450            .client
451            .send::<_, Vec<u8>>(req)
452            .await
453            .map_err(|error| match error {
454                http_client::Error::InvalidStatusCodeWithMessage(status, message) => {
455                    ModelListingError::api_error_with_context(
456                        "DeepSeek",
457                        path,
458                        status.as_u16(),
459                        message.as_bytes(),
460                    )
461                }
462                other => ModelListingError::from(other),
463            })?;
464
465        if !response.status().is_success() {
466            let status_code = response.status().as_u16();
467            let body = response.into_body().await?;
468            return Err(ModelListingError::api_error_with_context(
469                "DeepSeek",
470                path,
471                status_code,
472                &body,
473            ));
474        }
475
476        let body = response.into_body().await?;
477        let api_resp: ListModelsResponse = serde_json::from_slice(&body).map_err(|error| {
478            ModelListingError::parse_error_with_context("DeepSeek", path, &error, &body)
479        })?;
480
481        let models = api_resp.data.into_iter().map(Model::from).collect();
482
483        Ok(ModelList::new(models))
484    }
485}
486
487// ================================================================
488// DeepSeek Completion API
489// ================================================================
490#[deprecated(
491    note = "The model names `deepseek-chat` and `deepseek-reasoner` will be deprecated on 2026/07/24. \
492    For compatibility, they correspond to the non-thinking mode and thinking mode of `deepseek-v4-flash`, \
493    respectively."
494)]
495pub const DEEPSEEK_CHAT: &str = "deepseek-chat";
496#[deprecated(
497    note = "The model names `deepseek-chat` and `deepseek-reasoner` will be deprecated on 2026/07/24. \
498    For compatibility, they correspond to the non-thinking mode and thinking mode of `deepseek-v4-flash`, \
499    respectively."
500)]
501pub const DEEPSEEK_REASONER: &str = "deepseek-reasoner";
502pub const DEEPSEEK_V4_FLASH: &str = "deepseek-v4-flash";
503pub const DEEPSEEK_V4_PRO: &str = "deepseek-v4-pro";
504
505// Tests
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::client::ModelListingClient;
510    use crate::completion::{CompletionRequestBuilder, ToolDefinition as RigToolDefinition};
511    use crate::message::ToolChoice as RigToolChoice;
512    use crate::providers::openai::completion::{
513        CompletionRequest as OpenAICompletionRequest, OpenAICompatibleProvider, OpenAIRequestParams,
514    };
515    use crate::test_utils::{MockCompletionModel, RecordingHttpClient};
516
517    fn finalized_body(request: crate::completion::CompletionRequest) -> serde_json::Value {
518        let request = OpenAICompletionRequest::try_from(OpenAIRequestParams {
519            model: "deepseek-v4-flash".to_string(),
520            request,
521            strict_tools: false,
522            tool_result_array_content: false,
523            supports_response_format: DeepSeekExt::SUPPORTS_RESPONSE_FORMAT,
524            supports_tools: true,
525        })
526        .expect("request should convert");
527        let mut body = serde_json::to_value(request).expect("request should serialize");
528        DeepSeekExt
529            .finalize_request_body(&mut body)
530            .expect("finalize should succeed");
531        body
532    }
533
534    #[test]
535    fn test_deserialize_vec_choice() {
536        let data = r#"[{
537            "finish_reason": "stop",
538            "index": 0,
539            "logprobs": null,
540            "message":{"role":"assistant","content":"Hello, world!"}
541            }]"#;
542
543        let choices: Vec<Choice> = serde_json::from_str(data).unwrap();
544        assert_eq!(choices.len(), 1);
545        match &choices.first().unwrap().message {
546            Message::Assistant { content, .. } => assert_eq!(content, "Hello, world!"),
547            _ => panic!("Expected assistant message"),
548        }
549    }
550
551    #[test]
552    fn test_deserialize_deepseek_response() {
553        let data = r#"{
554            "choices":[{
555                "finish_reason": "stop",
556                "index": 0,
557                "logprobs": null,
558                "message":{"role":"assistant","content":"Hello, world!"}
559            }],
560            "usage": {
561                "completion_tokens": 0,
562                "prompt_tokens": 0,
563                "prompt_cache_hit_tokens": 0,
564                "prompt_cache_miss_tokens": 0,
565                "total_tokens": 0
566            }
567        }"#;
568
569        let jd = &mut serde_json::Deserializer::from_str(data);
570        let result: Result<CompletionResponse, _> = serde_path_to_error::deserialize(jd);
571        match result {
572            Ok(response) => match &response.choices.first().unwrap().message {
573                Message::Assistant { content, .. } => assert_eq!(content, "Hello, world!"),
574                _ => panic!("Expected assistant message"),
575            },
576            Err(err) => {
577                panic!("Deserialization error at {}: {}", err.path(), err);
578            }
579        }
580    }
581
582    #[test]
583    fn deepseek_request_serializes_specific_tool_choice_as_chat_completions_object() {
584        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Use a tool.")
585            .tool(RigToolDefinition {
586                name: "alpha".to_string(),
587                description: "Alpha tool".to_string(),
588                parameters: serde_json::json!({
589                    "type": "object",
590                    "properties": {},
591                    "required": []
592                }),
593            })
594            .tool(RigToolDefinition {
595                name: "beta".to_string(),
596                description: "Beta tool".to_string(),
597                parameters: serde_json::json!({
598                    "type": "object",
599                    "properties": {},
600                    "required": []
601                }),
602            })
603            .tool_choice(RigToolChoice::Specific {
604                function_names: vec!["beta".to_string()],
605            })
606            .additional_params(serde_json::json!({"thinking": {"type": "disabled"}}))
607            .build();
608
609        let body = finalized_body(request);
610
611        assert_eq!(
612            body["tool_choice"],
613            serde_json::json!({"type": "function", "function": {"name": "beta"}})
614        );
615    }
616
617    #[test]
618    fn deepseek_request_suppresses_required_tool_choice_when_thinking_is_not_disabled() {
619        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Use a tool.")
620            .tool(RigToolDefinition {
621                name: "alpha".to_string(),
622                description: "Alpha tool".to_string(),
623                parameters: serde_json::json!({
624                    "type": "object",
625                    "properties": {},
626                    "required": []
627                }),
628            })
629            .tool_choice(RigToolChoice::Required)
630            .build();
631
632        let body = finalized_body(request);
633
634        assert!(
635            body.as_object()
636                .expect("body is object")
637                .contains_key("tool_choice"),
638            "suppressed tool_choice should stay present as an explicit null"
639        );
640        assert_eq!(body["tool_choice"], serde_json::Value::Null);
641    }
642
643    #[test]
644    fn deepseek_request_flattens_message_content_to_strings() {
645        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Hello!")
646            .preamble("You are helpful.".to_string())
647            .build();
648
649        let body = finalized_body(request);
650
651        assert_eq!(body["messages"][0]["role"], "system");
652        assert_eq!(body["messages"][0]["content"], "You are helpful.");
653        assert_eq!(body["messages"][1]["role"], "user");
654        assert_eq!(body["messages"][1]["content"], "Hello!");
655    }
656
657    #[test]
658    fn deepseek_finalize_joins_user_parts_with_newline_and_concats_assistant_parts() {
659        let mut body = serde_json::json!({
660            "model": "deepseek-v4-flash",
661            "messages": [
662                {"role": "user", "content": [
663                    {"type": "text", "text": "first part"},
664                    {"type": "text", "text": "second part"}
665                ]},
666                {"role": "assistant", "content": [
667                    {"type": "text", "text": "Hello"},
668                    {"type": "text", "text": " world"}
669                ]}
670            ]
671        });
672
673        DeepSeekExt
674            .finalize_request_body(&mut body)
675            .expect("finalize should succeed");
676
677        assert_eq!(body["messages"][0]["content"], "first part\nsecond part");
678        assert_eq!(body["messages"][1]["content"], "Hello world");
679    }
680
681    #[test]
682    fn deepseek_finalize_adds_tool_call_index_to_assistant_history() {
683        let mut body = serde_json::json!({
684            "model": "deepseek-v4-flash",
685            "messages": [{
686                "role": "assistant",
687                "content": "",
688                "tool_calls": [{
689                    "id": "call_1",
690                    "type": "function",
691                    "function": {"name": "subtract", "arguments": "{\"x\":2,\"y\":5}"}
692                }]
693            }]
694        });
695
696        DeepSeekExt
697            .finalize_request_body(&mut body)
698            .expect("finalize should succeed");
699
700        assert_eq!(body["messages"][0]["tool_calls"][0]["index"], 0);
701    }
702
703    #[test]
704    fn deepseek_response_preserves_metadata_and_reasoning_token_usage() {
705        let raw: CompletionResponse = serde_json::from_value(serde_json::json!({
706            "id": "chatcmpl_123",
707            "object": "chat.completion",
708            "model": "deepseek-v4-flash",
709            "system_fingerprint": "fp_123",
710            "choices": [{
711                "finish_reason": "stop",
712                "index": 0,
713                "logprobs": null,
714                "message": {
715                    "role": "assistant",
716                    "content": "done",
717                    "reasoning_content": "thinking"
718                }
719            }],
720            "usage": {
721                "completion_tokens": 8,
722                "completion_tokens_details": { "reasoning_tokens": 5 },
723                "prompt_tokens": 10,
724                "prompt_tokens_details": { "cached_tokens": 3 },
725                "prompt_cache_hit_tokens": 0,
726                "prompt_cache_miss_tokens": 10,
727                "total_tokens": 18
728            }
729        }))
730        .expect("fixture should deserialize");
731
732        let converted = crate::completion::CompletionResponse::try_from(raw.clone())
733            .expect("DeepSeek response should convert");
734
735        assert_eq!(raw.id.as_deref(), Some("chatcmpl_123"));
736        assert_eq!(raw.model.as_deref(), Some("deepseek-v4-flash"));
737        assert_eq!(raw.system_fingerprint.as_deref(), Some("fp_123"));
738        assert_eq!(converted.usage.input_tokens, 10);
739        assert_eq!(converted.usage.cached_input_tokens, 3);
740        assert_eq!(converted.usage.output_tokens, 8);
741        assert_eq!(converted.usage.reasoning_tokens, 5);
742    }
743
744    #[test]
745    fn test_deserialize_example_response() {
746        let data = r#"
747        {
748            "id": "e45f6c68-9d9e-43de-beb4-4f402b850feb",
749            "object": "chat.completion",
750            "created": 0,
751            "model": "deepseek-chat",
752            "choices": [
753                {
754                    "index": 0,
755                    "message": {
756                        "role": "assistant",
757                        "content": "Why don’t skeletons fight each other?  \nBecause they don’t have the guts! 😄"
758                    },
759                    "logprobs": null,
760                    "finish_reason": "stop"
761                }
762            ],
763            "usage": {
764                "prompt_tokens": 13,
765                "completion_tokens": 32,
766                "total_tokens": 45,
767                "prompt_tokens_details": {
768                    "cached_tokens": 0
769                },
770                "prompt_cache_hit_tokens": 0,
771                "prompt_cache_miss_tokens": 13
772            },
773            "system_fingerprint": "fp_4b6881f2c5"
774        }
775        "#;
776        let jd = &mut serde_json::Deserializer::from_str(data);
777        let result: Result<CompletionResponse, _> = serde_path_to_error::deserialize(jd);
778
779        match result {
780            Ok(response) => match &response.choices.first().unwrap().message {
781                Message::Assistant { content, .. } => assert_eq!(
782                    content,
783                    "Why don’t skeletons fight each other?  \nBecause they don’t have the guts! 😄"
784                ),
785                _ => panic!("Expected assistant message"),
786            },
787            Err(err) => {
788                panic!("Deserialization error at {}: {}", err.path(), err);
789            }
790        }
791    }
792
793    #[test]
794    fn test_serialize_deserialize_tool_call_message() {
795        let tool_call_choice_json = r#"
796            {
797              "finish_reason": "tool_calls",
798              "index": 0,
799              "logprobs": null,
800              "message": {
801                "content": "",
802                "role": "assistant",
803                "tool_calls": [
804                  {
805                    "function": {
806                      "arguments": "{\"x\":2,\"y\":5}",
807                      "name": "subtract"
808                    },
809                    "id": "call_0_2b4a85ee-b04a-40ad-a16b-a405caf6e65b",
810                    "index": 0,
811                    "type": "function"
812                  }
813                ]
814              }
815            }
816        "#;
817
818        let choice: Choice =
819            serde_json::from_str(tool_call_choice_json).expect("choice should deserialize");
820        match &choice.message {
821            Message::Assistant { tool_calls, .. } => {
822                assert_eq!(tool_calls.len(), 1);
823                let call = tool_calls.first().expect("one tool call");
824                assert_eq!(call.function.name, "subtract");
825                assert_eq!(call.index, 0);
826            }
827            _ => panic!("Expected assistant message"),
828        }
829
830        let serialized = serde_json::to_value(&choice).expect("choice should serialize");
831        assert_eq!(
832            serialized["message"]["tool_calls"][0]["function"]["name"],
833            "subtract"
834        );
835    }
836
837    #[test]
838    fn test_client_initialization() {
839        let _client =
840            crate::providers::deepseek::Client::new("dummy-key").expect("Client::new() failed");
841        let _client_from_builder = crate::providers::deepseek::Client::builder()
842            .api_key("dummy-key")
843            .build()
844            .expect("Client::builder() failed");
845    }
846
847    #[test]
848    fn test_deserialize_list_models_response() {
849        let data = r#"{
850            "object": "list",
851            "data": [
852                {"id": "deepseek-chat", "object": "model", "owned_by": "deepseek"},
853                {"id": "deepseek-reasoner", "object": "model", "owned_by": "deepseek"}
854            ]
855        }"#;
856
857        let response: ListModelsResponse =
858            serde_json::from_str(data).expect("list models response should deserialize");
859        assert_eq!(response.data.len(), 2);
860        assert_eq!(response.data[0].id, "deepseek-chat");
861        assert_eq!(response.data[0].owned_by, "deepseek");
862    }
863
864    #[tokio::test]
865    async fn test_list_models_uses_models_endpoint() {
866        let response_body = r#"{
867            "object": "list",
868            "data": [
869                {
870                    "id": "deepseek-v4-flash",
871                    "object": "model",
872                    "owned_by": "deepseek"
873                },
874                {
875                    "id": "deepseek-v4-pro",
876                    "object": "model",
877                    "owned_by": "deepseek"
878                }
879            ]
880        }"#;
881
882        let http_client = RecordingHttpClient::new(response_body);
883        let client = Client::builder()
884            .api_key("dummy-key")
885            .http_client(http_client.clone())
886            .build()
887            .expect("client should build");
888
889        let models = client
890            .list_models()
891            .await
892            .expect("list_models should succeed");
893
894        assert_eq!(models.len(), 2);
895        assert_eq!(models.data[0].id, "deepseek-v4-flash");
896        assert_eq!(models.data[0].r#type, None);
897        assert_eq!(models.data[0].owned_by.as_deref(), Some("deepseek"));
898        let requests = http_client.requests();
899        assert_eq!(requests.len(), 1);
900        assert_eq!(requests[0].uri, "https://api.deepseek.com/models");
901    }
902
903    #[tokio::test]
904    async fn test_list_models_preserves_api_error_context() {
905        let http_client = RecordingHttpClient::with_error(
906            http::StatusCode::UNAUTHORIZED,
907            r#"{"error":{"message":"invalid api key"}}"#,
908        );
909        let client = Client::builder()
910            .api_key("dummy-key")
911            .http_client(http_client)
912            .build()
913            .expect("client should build");
914
915        let error = client
916            .list_models()
917            .await
918            .expect_err("list_models should fail");
919
920        match error {
921            ModelListingError::ApiError {
922                status_code,
923                message,
924            } => {
925                assert_eq!(status_code, 401);
926                assert!(message.contains("provider=DeepSeek"));
927                assert!(message.contains("path=/models"));
928                assert!(message.contains("invalid api key"));
929            }
930            other => panic!("expected api error, got {other:?}"),
931        }
932    }
933}