1use 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
34const 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 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 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 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 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
158pub type CompletionModel<H = reqwest::Client> =
160 openai::completion::GenericCompletionModel<DeepSeekExt, H>;
161
162pub type StreamingCompletionResponse = openai::StreamingCompletionResponse<Usage>;
165
166impl ProviderClient for Client {
167 type Input = DeepSeekApiKey;
168 type Error = crate::client::ProviderClientError;
169
170 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#[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#[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 #[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#[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#[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#[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}