1use 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
26const 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 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 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 openai::completion::flatten_text_content_parts(content, separator, true);
79 } else if is_assistant && !message.contains_key("content") {
80 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 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
137pub type CompletionModel<H = reqwest::Client> =
139 openai::completion::GenericCompletionModel<DeepSeekExt, H>;
140
141pub type StreamingCompletionResponse = openai::StreamingCompletionResponse<Usage>;
146
147impl ProviderClient for Client {
148 type Input = DeepSeekApiKey;
149 type Error = crate::client::ProviderClientError;
150
151 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#[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 .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#[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 #[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
319impl 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 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 DeepSeekModelLister,
379 Client<H>,
380 crate::providers::internal::model_listing::ListModelEntry,
381 "DeepSeek",
382 "/models"
383);
384
385#[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#[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 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 (
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 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 #[test]
766 fn deepseek_truncated_tool_arguments_do_not_destroy_the_response() {
767 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 #[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 #[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 #[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 #[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 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}