1use anyhow::{Context, Result};
12use serde_json::Value as JsonValue;
13
14use super::common::{
15 NormalizeNonText, RESPONSE_FORMAT_TEMPLATE, TOOL_CALL_TEMPLATE, TOOL_OUTPUT_TEMPLATE,
16 TOOLS_SYSTEM_TEMPLATE, encode_arguments_to_dsml, find_last_user_index,
17 normalize_message_contents, render_tools, to_json,
18};
19
20pub use super::common::{ThinkingMode, tokens};
21
22fn render_message(
24 index: usize,
25 messages: &[JsonValue],
26 thinking_mode: ThinkingMode,
27 last_user_idx: Option<usize>,
28) -> Result<String> {
29 let msg = &messages[index];
30 let role = msg
31 .get("role")
32 .and_then(|r| r.as_str())
33 .context("Missing 'role' field")?;
34
35 let mut prompt = String::new();
36
37 match role {
38 "system" => {
39 let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
40 prompt.push_str(content);
41
42 if let Some(tools) = msg.get("tools").and_then(|t| t.as_array()) {
43 prompt.push_str("\n\n");
44 prompt.push_str(&render_tools(TOOLS_SYSTEM_TEMPLATE, tools));
45 }
46
47 if let Some(response_format) = msg.get("response_format") {
48 prompt.push_str("\n\n");
49 prompt.push_str(
50 &RESPONSE_FORMAT_TEMPLATE.replace("{schema}", &to_json(response_format)),
51 );
52 }
53 }
54
55 "user" => {
56 let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
57 prompt.push_str(tokens::USER_START);
58 prompt.push_str(content);
59 prompt.push_str(tokens::ASSISTANT_START);
60
61 if Some(index) == last_user_idx && thinking_mode == ThinkingMode::Thinking {
62 prompt.push_str(tokens::THINKING_START);
63 } else {
64 prompt.push_str(tokens::THINKING_END);
65 }
66 }
67
68 "developer" => {
69 let content = msg
70 .get("content")
71 .and_then(|c| c.as_str())
72 .context("Developer role requires content")?;
73
74 let mut content_developer = String::new();
75
76 if let Some(tools) = msg.get("tools").and_then(|t| t.as_array()) {
77 content_developer.push_str("\n\n");
78 content_developer.push_str(&render_tools(TOOLS_SYSTEM_TEMPLATE, tools));
79 }
80
81 if let Some(response_format) = msg.get("response_format") {
82 content_developer.push_str("\n\n");
83 content_developer.push_str(
84 &RESPONSE_FORMAT_TEMPLATE.replace("{schema}", &to_json(response_format)),
85 );
86 }
87
88 content_developer.push_str(&format!("\n\n# The user's message is: {}", content));
89
90 prompt.push_str(tokens::USER_START);
91 prompt.push_str(&content_developer);
92 prompt.push_str(tokens::ASSISTANT_START);
93
94 if Some(index) == last_user_idx && thinking_mode == ThinkingMode::Thinking {
95 prompt.push_str(tokens::THINKING_START);
96 } else {
97 prompt.push_str(tokens::THINKING_END);
98 }
99 }
100
101 "assistant" => {
102 if thinking_mode == ThinkingMode::Thinking
110 && last_user_idx.is_some_and(|idx| index > idx)
111 {
112 let reasoning = msg.get("reasoning_content").and_then(|v| match v {
113 serde_json::Value::String(s) => {
114 if s.is_empty() {
115 None
116 } else {
117 Some(s.clone())
118 }
119 }
120 serde_json::Value::Array(arr) => {
121 let joined = arr
122 .iter()
123 .filter_map(|v| v.as_str())
124 .filter(|s| !s.is_empty())
125 .collect::<Vec<_>>()
126 .join("\n");
127 if joined.is_empty() {
128 None
129 } else {
130 Some(joined)
131 }
132 }
133 _ => None,
134 });
135
136 if let Some(reasoning) = reasoning {
137 prompt.push_str(&reasoning);
139 prompt.push_str(tokens::THINKING_END);
140 }
141 }
142
143 if let Some(content) = msg.get("content").and_then(|c| c.as_str()) {
145 prompt.push_str(content);
146 }
147
148 if let Some(tool_calls) = msg.get("tool_calls").and_then(|t| t.as_array())
150 && !tool_calls.is_empty()
151 {
152 prompt.push_str("\n\n");
153 prompt.push_str(&format!("<{}function_calls>\n", tokens::DSML_TOKEN));
154
155 for tool_call in tool_calls {
156 let name = tool_call
157 .get("function")
158 .and_then(|f| f.get("name"))
159 .and_then(|n| n.as_str())
160 .context("Missing tool call name")?;
161
162 let arguments = encode_arguments_to_dsml(
163 tool_call.get("function").context("Missing function")?,
164 )?;
165
166 let invoke = TOOL_CALL_TEMPLATE
167 .replace("{dsml_token}", tokens::DSML_TOKEN)
168 .replace("{name}", name)
169 .replace("{arguments}", &arguments);
170
171 prompt.push_str(&invoke);
172 prompt.push('\n');
173 }
174
175 prompt.push_str(&format!("</{}function_calls>", tokens::DSML_TOKEN));
176 }
177
178 prompt.push_str(tokens::EOS);
179 }
180
181 "tool" => {
182 let mut prev_assistant_idx = None;
184 let mut tool_count = 0;
185
186 for i in (0..index).rev() {
187 let prev_role = messages[i].get("role").and_then(|r| r.as_str());
188 if prev_role == Some("tool") {
189 tool_count += 1;
190 } else if prev_role == Some("assistant") {
191 prev_assistant_idx = Some(i);
192 break;
193 }
194 }
195
196 let tool_call_order = tool_count + 1;
197
198 if tool_call_order == 1 {
200 prompt.push_str("\n\n<function_results>");
201 }
202
203 let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
205 prompt.push_str(&TOOL_OUTPUT_TEMPLATE.replace("{content}", content));
206
207 if let Some(prev_idx) = prev_assistant_idx {
209 let tool_calls_count = messages[prev_idx]
210 .get("tool_calls")
211 .and_then(|t| t.as_array())
212 .map(|a| a.len())
213 .unwrap_or(0);
214
215 if tool_call_order == tool_calls_count {
216 prompt.push_str("\n</function_results>");
217
218 if last_user_idx.is_some_and(|idx| index >= idx)
219 && thinking_mode == ThinkingMode::Thinking
220 {
221 prompt.push_str("\n\n");
222 prompt.push_str(tokens::THINKING_START);
223 } else {
224 prompt.push_str("\n\n");
225 prompt.push_str(tokens::THINKING_END);
226 }
227 }
228 }
229 }
230
231 _ => anyhow::bail!("Unknown role: {}", role),
232 }
233
234 Ok(prompt)
235}
236
237pub fn encode_messages(
247 messages: &[JsonValue],
248 thinking_mode: ThinkingMode,
249 add_bos_token: bool,
250) -> Result<String> {
251 let mut prompt = String::new();
252
253 if add_bos_token {
254 prompt.push_str(tokens::BOS);
255 }
256
257 let last_user_idx = find_last_user_index(messages);
258
259 for (index, _) in messages.iter().enumerate() {
260 let msg_prompt = render_message(index, messages, thinking_mode, last_user_idx)?;
261 prompt.push_str(&msg_prompt);
262 }
263
264 Ok(prompt)
265}
266
267#[derive(Debug)]
271pub struct DeepSeekV32Formatter {
272 thinking_mode: ThinkingMode,
273}
274
275impl DeepSeekV32Formatter {
276 pub fn new(thinking_mode: ThinkingMode) -> Self {
277 Self { thinking_mode }
278 }
279
280 pub fn new_thinking() -> Self {
282 Self::new(ThinkingMode::Thinking)
283 }
284
285 pub fn new_chat() -> Self {
287 Self::new(ThinkingMode::Chat)
288 }
289}
290
291impl crate::OAIPromptFormatter for DeepSeekV32Formatter {
292 fn supports_add_generation_prompt(&self) -> bool {
293 true
294 }
295
296 fn render(&self, req: &dyn crate::OAIChatLikeRequest) -> Result<String> {
297 let thinking_mode =
298 super::common::resolve_thinking_mode(req.chat_template_args(), self.thinking_mode);
299
300 let messages_value = req.messages();
302
303 let messages_json =
305 serde_json::to_value(&messages_value).context("Failed to convert messages to JSON")?;
306 crate::reject_unsupported_partial_assistant(&messages_json)?;
307 crate::reject_unsupported_message_tools(&messages_json, &["developer"])?;
308
309 let mut messages_array = messages_json
310 .as_array()
311 .context("Messages is not an array")?
312 .clone();
313
314 normalize_message_contents(&mut messages_array, NormalizeNonText::SerializeJson);
317
318 super::common::inject_tools_and_response_format(&mut messages_array, req)?;
321
322 encode_messages(
324 &messages_array,
325 thinking_mode,
326 true, )
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use serde_json::json;
335
336 #[test]
337 fn test_simple_conversation() {
338 let messages = json!([
339 {"role": "system", "content": "You are a helpful assistant."},
340 {"role": "user", "content": "Hello!"}
341 ]);
342
343 let result =
344 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
345
346 assert!(result.starts_with(tokens::BOS));
347 assert!(result.contains("You are a helpful assistant."));
348 assert!(result.contains(tokens::USER_START));
349 assert!(result.contains("Hello!"));
350 assert!(result.contains(tokens::ASSISTANT_START));
351 assert!(result.contains(tokens::THINKING_START));
352 }
353
354 #[test]
355 fn test_formatter_handles_user_content_array() {
356 use crate::OAIPromptFormatter;
357
358 let request = MockRequest::new(json!([
359 {"role": "user", "content": [
360 {"type": "text", "text": "who are you?"}
361 ]}
362 ]));
363
364 let formatter = DeepSeekV32Formatter::new_thinking();
365 let result = formatter.render(&request).unwrap();
366
367 assert!(result.contains("who are you?"));
368 assert!(result.contains(tokens::USER_START));
369 assert!(result.contains(tokens::ASSISTANT_START));
370 }
371
372 #[test]
373 fn test_formatter_serializes_non_text_content() {
374 use crate::OAIPromptFormatter;
375
376 let request = MockRequest::new(json!([
377 {"role": "user", "content": {"foo": "bar"}}
378 ]));
379
380 let formatter = DeepSeekV32Formatter::new_thinking();
381 let result = formatter.render(&request).unwrap();
382
383 assert!(result.contains(r#"{"foo": "bar"}"#));
384 }
385
386 #[test]
387 fn test_formatter_rejects_unsupported_partial_assistant() {
388 use crate::OAIPromptFormatter;
389
390 let request = MockRequest::new(json!([
391 {"role": "user", "content": "Continue"},
392 {"role": "assistant", "content": "prefix", "partial": true}
393 ]));
394 let error = DeepSeekV32Formatter::new_thinking()
395 .render(&request)
396 .unwrap_err();
397
398 assert!(matches!(
399 error.downcast_ref::<crate::PromptRenderError>(),
400 Some(crate::PromptRenderError::InvalidRequest(message))
401 if message.contains("`partial: true` is not supported")
402 ));
403 }
404
405 #[test]
406 fn test_formatter_rejects_system_tools_before_injection() {
407 use crate::OAIPromptFormatter;
408
409 let request = MockRequest::new(json!([
410 {"role": "system", "tools": [
411 {"type": "function", "function": {"name": "dynamic_tool"}}
412 ]},
413 {"role": "user", "content": "Use a tool"}
414 ]))
415 .with_tools(json!([{"type": "function", "function": {"name": "top_level_tool"}}]));
416 let error = DeepSeekV32Formatter::new_thinking()
417 .render(&request)
418 .unwrap_err();
419
420 assert!(matches!(
421 error.downcast_ref::<crate::PromptRenderError>(),
422 Some(crate::PromptRenderError::InvalidRequest(message))
423 if message.contains("message-level `tools`") && message.contains("system")
424 ));
425 }
426
427 #[test]
428 fn test_formatter_preserves_developer_tools_with_top_level_tools() {
429 use crate::OAIPromptFormatter;
430
431 let request = MockRequest::new(json!([
432 {"role": "developer", "content": "Use a tool", "tools": [
433 {"type": "function", "function": {"name": "developer_tool"}}
434 ]}
435 ]))
436 .with_tools(json!([{"type": "function", "function": {"name": "top_level_tool"}}]));
437 let rendered = DeepSeekV32Formatter::new_thinking()
438 .render(&request)
439 .unwrap();
440
441 assert!(rendered.contains("developer_tool"));
442 assert!(rendered.contains("top_level_tool"));
443 }
444
445 #[test]
446 fn test_tools_rendering() {
447 let messages = json!([
448 {
449 "role": "system",
450 "content": "You are helpful.",
451 "tools": [{
452 "type": "function",
453 "function": {
454 "name": "get_weather",
455 "description": "Get weather",
456 "parameters": {
457 "type": "object",
458 "properties": {
459 "location": {"type": "string"}
460 }
461 }
462 }
463 }]
464 },
465 {"role": "user", "content": "What's the weather?"}
466 ]);
467
468 let result =
469 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
470
471 assert!(result.contains("## Tools"));
472 assert!(result.contains("get_weather"));
473 assert!(result.contains("<functions>"));
474 }
475
476 struct MockRequest {
478 messages: JsonValue,
479 tools: Option<JsonValue>,
480 response_format: Option<JsonValue>,
481 chat_template_args: Option<std::collections::HashMap<String, JsonValue>>,
482 }
483
484 impl MockRequest {
485 fn new(messages: JsonValue) -> Self {
486 Self {
487 messages,
488 tools: None,
489 response_format: None,
490 chat_template_args: None,
491 }
492 }
493
494 fn with_tools(mut self, tools: JsonValue) -> Self {
495 self.tools = Some(tools);
496 self
497 }
498
499 fn with_response_format(mut self, response_format: JsonValue) -> Self {
500 self.response_format = Some(response_format);
501 self
502 }
503
504 fn with_chat_template_args(
505 mut self,
506 args: std::collections::HashMap<String, JsonValue>,
507 ) -> Self {
508 self.chat_template_args = Some(args);
509 self
510 }
511 }
512
513 impl crate::OAIChatLikeRequest for MockRequest {
514 fn model(&self) -> String {
515 "deepseek-v3.2".to_string()
516 }
517
518 fn messages(&self) -> minijinja::value::Value {
519 minijinja::value::Value::from_serialize(&self.messages)
520 }
521
522 fn tools(&self) -> Option<minijinja::value::Value> {
523 self.tools
524 .as_ref()
525 .map(minijinja::value::Value::from_serialize)
526 }
527
528 fn response_format(&self) -> Option<minijinja::value::Value> {
529 self.response_format
530 .as_ref()
531 .map(minijinja::value::Value::from_serialize)
532 }
533
534 fn should_add_generation_prompt(&self) -> bool {
535 true
536 }
537
538 fn chat_template_args(
539 &self,
540 ) -> Option<&std::collections::HashMap<String, serde_json::Value>> {
541 self.chat_template_args.as_ref()
542 }
543 }
544
545 #[test]
546 fn test_formatter_injects_tools_into_existing_system_message() {
547 use crate::OAIPromptFormatter;
548
549 let tools = json!([{
550 "type": "function",
551 "function": {
552 "name": "get_weather",
553 "description": "Get current weather",
554 "parameters": {
555 "type": "object",
556 "properties": {
557 "location": {"type": "string", "description": "City name"},
558 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
559 },
560 "required": ["location"]
561 }
562 }
563 }]);
564
565 let request = MockRequest::new(json!([
566 {"role": "system", "content": "You are a helpful assistant."},
567 {"role": "user", "content": "What's the weather in Moscow?"}
568 ]))
569 .with_tools(tools);
570
571 let formatter = DeepSeekV32Formatter::new_thinking();
572 let result = formatter.render(&request).unwrap();
573
574 assert!(
576 result.contains("## Tools"),
577 "Should contain Tools section header"
578 );
579 assert!(
580 result.contains("get_weather"),
581 "Should contain function name"
582 );
583 assert!(
584 result.contains("<functions>"),
585 "Should contain functions block"
586 );
587 assert!(
588 result.contains("</functions>"),
589 "Should contain closing functions tag"
590 );
591 assert!(
592 result.contains("You are a helpful assistant."),
593 "Should preserve original system content"
594 );
595 assert!(
596 result.contains(&format!("<{}function_calls>", tokens::DSML_TOKEN)),
597 "Should contain DSML format instructions"
598 );
599 }
600
601 #[test]
602 fn test_formatter_creates_system_message_for_tools_when_missing() {
603 use crate::OAIPromptFormatter;
604
605 let tools = json!([{
606 "type": "function",
607 "function": {
608 "name": "get_current_time",
609 "description": "Get current time in a timezone",
610 "parameters": {
611 "type": "object",
612 "properties": {
613 "timezone": {"type": "string"}
614 },
615 "required": ["timezone"]
616 }
617 }
618 }]);
619
620 let request = MockRequest::new(json!([
622 {"role": "user", "content": "What time is it in Tokyo?"}
623 ]))
624 .with_tools(tools);
625
626 let formatter = DeepSeekV32Formatter::new_thinking();
627 let result = formatter.render(&request).unwrap();
628
629 assert!(
631 result.contains("## Tools"),
632 "Should contain Tools section even without explicit system message"
633 );
634 assert!(
635 result.contains("get_current_time"),
636 "Should contain function name"
637 );
638 assert!(
639 result.contains("<functions>"),
640 "Should contain functions block"
641 );
642 }
643
644 #[test]
645 fn test_formatter_without_tools_does_not_add_tools_section() {
646 use crate::OAIPromptFormatter;
647
648 let request = MockRequest::new(json!([
649 {"role": "system", "content": "You are a helpful assistant."},
650 {"role": "user", "content": "Hello!"}
651 ]));
652
653 let formatter = DeepSeekV32Formatter::new_thinking();
654 let result = formatter.render(&request).unwrap();
655
656 assert!(
658 !result.contains("## Tools"),
659 "Should not contain Tools section when no tools provided"
660 );
661 assert!(
662 !result.contains("<functions>"),
663 "Should not contain functions block when no tools provided"
664 );
665 assert!(
666 result.contains("You are a helpful assistant."),
667 "Should preserve system content"
668 );
669 }
670
671 #[test]
672 fn test_formatter_with_multiple_tools() {
673 use crate::OAIPromptFormatter;
674
675 let tools = json!([
676 {
677 "type": "function",
678 "function": {
679 "name": "get_weather",
680 "description": "Get current weather",
681 "parameters": {
682 "type": "object",
683 "properties": {
684 "location": {"type": "string"}
685 }
686 }
687 }
688 },
689 {
690 "type": "function",
691 "function": {
692 "name": "get_current_time",
693 "description": "Get current time",
694 "parameters": {
695 "type": "object",
696 "properties": {
697 "timezone": {"type": "string"}
698 }
699 }
700 }
701 }
702 ]);
703
704 let request = MockRequest::new(json!([
705 {"role": "system", "content": "You are helpful."},
706 {"role": "user", "content": "Weather and time in Moscow?"}
707 ]))
708 .with_tools(tools);
709
710 let formatter = DeepSeekV32Formatter::new_thinking();
711 let result = formatter.render(&request).unwrap();
712
713 assert!(
715 result.contains("get_weather"),
716 "Should contain first function"
717 );
718 assert!(
719 result.contains("get_current_time"),
720 "Should contain second function"
721 );
722 }
723
724 #[test]
727 fn test_formatter_injects_response_format_into_existing_system_message() {
728 use crate::OAIPromptFormatter;
729
730 let response_format = json!({
731 "type": "json_schema",
732 "json_schema": {
733 "name": "city_info",
734 "strict": true,
735 "schema": {
736 "type": "object",
737 "properties": {
738 "city": {"type": "string"},
739 "country": {"type": "string"},
740 "population": {"type": "number"}
741 },
742 "required": ["city", "country", "population"]
743 }
744 }
745 });
746
747 let request = MockRequest::new(json!([
748 {"role": "system", "content": "You are a helpful assistant."},
749 {"role": "user", "content": "Tell me about Moscow."}
750 ]))
751 .with_response_format(response_format);
752
753 let formatter = DeepSeekV32Formatter::new_thinking();
754 let result = formatter.render(&request).unwrap();
755
756 assert!(
758 result.contains("## Response Format:"),
759 "Should contain Response Format section header"
760 );
761 assert!(
762 result.contains("json_schema"),
763 "Should contain json_schema type"
764 );
765 assert!(result.contains("city_info"), "Should contain schema name");
766 assert!(
767 result.contains("You are a helpful assistant."),
768 "Should preserve original system content"
769 );
770 }
771
772 #[test]
773 fn test_formatter_creates_system_message_for_response_format_when_missing() {
774 use crate::OAIPromptFormatter;
775
776 let response_format = json!({
777 "type": "json_schema",
778 "json_schema": {
779 "name": "weather_response",
780 "schema": {
781 "type": "object",
782 "properties": {
783 "temperature": {"type": "number"},
784 "conditions": {"type": "string"}
785 }
786 }
787 }
788 });
789
790 let request = MockRequest::new(json!([
792 {"role": "user", "content": "What's the weather?"}
793 ]))
794 .with_response_format(response_format);
795
796 let formatter = DeepSeekV32Formatter::new_thinking();
797 let result = formatter.render(&request).unwrap();
798
799 assert!(
801 result.contains("## Response Format:"),
802 "Should contain Response Format section even without explicit system message"
803 );
804 assert!(
805 result.contains("weather_response"),
806 "Should contain schema name"
807 );
808 }
809
810 #[test]
811 fn test_formatter_with_both_tools_and_response_format() {
812 use crate::OAIPromptFormatter;
813
814 let tools = json!([{
815 "type": "function",
816 "function": {
817 "name": "search_database",
818 "description": "Search the database",
819 "parameters": {
820 "type": "object",
821 "properties": {
822 "query": {"type": "string"}
823 }
824 }
825 }
826 }]);
827
828 let response_format = json!({
829 "type": "json_schema",
830 "json_schema": {
831 "name": "search_result",
832 "schema": {
833 "type": "object",
834 "properties": {
835 "results": {"type": "array"},
836 "total_count": {"type": "number"}
837 }
838 }
839 }
840 });
841
842 let request = MockRequest::new(json!([
843 {"role": "system", "content": "You are a search assistant."},
844 {"role": "user", "content": "Find documents about Rust."}
845 ]))
846 .with_tools(tools)
847 .with_response_format(response_format);
848
849 let formatter = DeepSeekV32Formatter::new_thinking();
850 let result = formatter.render(&request).unwrap();
851
852 assert!(result.contains("## Tools"), "Should contain Tools section");
854 assert!(
855 result.contains("search_database"),
856 "Should contain function name"
857 );
858 assert!(
859 result.contains("## Response Format:"),
860 "Should contain Response Format section"
861 );
862 assert!(
863 result.contains("search_result"),
864 "Should contain schema name"
865 );
866 assert!(
867 result.contains("You are a search assistant."),
868 "Should preserve original system content"
869 );
870 }
871
872 #[test]
873 fn test_formatter_without_response_format_does_not_add_response_format_section() {
874 use crate::OAIPromptFormatter;
875
876 let request = MockRequest::new(json!([
877 {"role": "system", "content": "You are a helpful assistant."},
878 {"role": "user", "content": "Hello!"}
879 ]));
880
881 let formatter = DeepSeekV32Formatter::new_thinking();
882 let result = formatter.render(&request).unwrap();
883
884 assert!(
886 !result.contains("## Response Format:"),
887 "Should not contain Response Format section when not provided"
888 );
889 }
890
891 #[test]
894 fn test_chat_mode_via_thinking_false() {
895 use crate::OAIPromptFormatter;
896
897 let args = std::collections::HashMap::from([("thinking".to_string(), json!(false))]);
898
899 let request = MockRequest::new(json!([
900 {"role": "system", "content": "You are a helpful assistant."},
901 {"role": "user", "content": "Hello!"}
902 ]))
903 .with_chat_template_args(args);
904
905 let formatter = DeepSeekV32Formatter::new_thinking();
906 let result = formatter.render(&request).unwrap();
907
908 assert!(
911 result.ends_with(&format!(
912 "{}{}",
913 tokens::ASSISTANT_START,
914 tokens::THINKING_END
915 )),
916 "Chat mode should end with </think> after Assistant token, got: ...{}",
917 &result[result.len().saturating_sub(80)..],
918 );
919 assert!(
920 !result.ends_with(&format!(
921 "{}{}",
922 tokens::ASSISTANT_START,
923 tokens::THINKING_START
924 )),
925 "Chat mode should NOT end with <think>",
926 );
927 }
928
929 #[test]
930 fn test_explicit_thinking_true_via_args() {
931 use crate::OAIPromptFormatter;
932
933 let args = std::collections::HashMap::from([("thinking".to_string(), json!(true))]);
934
935 let request = MockRequest::new(json!([
936 {"role": "system", "content": "You are a helpful assistant."},
937 {"role": "user", "content": "Hello!"}
938 ]))
939 .with_chat_template_args(args);
940
941 let formatter = DeepSeekV32Formatter::new_thinking();
942 let result = formatter.render(&request).unwrap();
943
944 assert!(
945 result.ends_with(&format!(
946 "{}{}",
947 tokens::ASSISTANT_START,
948 tokens::THINKING_START
949 )),
950 "Thinking mode should end with <think> after Assistant token",
951 );
952 }
953
954 #[test]
955 fn test_chat_mode_via_thinking_mode_string() {
956 use crate::OAIPromptFormatter;
957
958 let args = std::collections::HashMap::from([("thinking_mode".to_string(), json!("chat"))]);
959
960 let request = MockRequest::new(json!([
961 {"role": "system", "content": "You are a helpful assistant."},
962 {"role": "user", "content": "Hello!"}
963 ]))
964 .with_chat_template_args(args);
965
966 let formatter = DeepSeekV32Formatter::new_thinking();
967 let result = formatter.render(&request).unwrap();
968
969 assert!(
970 result.ends_with(&format!(
971 "{}{}",
972 tokens::ASSISTANT_START,
973 tokens::THINKING_END
974 )),
975 "thinking_mode='chat' should produce chat mode (ends with </think>)",
976 );
977 }
978
979 #[test]
980 fn test_thinking_mode_string_thinking() {
981 use crate::OAIPromptFormatter;
982
983 let args =
984 std::collections::HashMap::from([("thinking_mode".to_string(), json!("thinking"))]);
985
986 let request = MockRequest::new(json!([
987 {"role": "system", "content": "You are a helpful assistant."},
988 {"role": "user", "content": "Hello!"}
989 ]))
990 .with_chat_template_args(args);
991
992 let formatter = DeepSeekV32Formatter::new_thinking();
993 let result = formatter.render(&request).unwrap();
994
995 assert!(
996 result.ends_with(&format!(
997 "{}{}",
998 tokens::ASSISTANT_START,
999 tokens::THINKING_START
1000 )),
1001 "thinking_mode='thinking' should produce thinking mode (ends with <think>)",
1002 );
1003 }
1004
1005 #[test]
1006 fn test_default_thinking_mode_without_args() {
1007 use crate::OAIPromptFormatter;
1008
1009 let request = MockRequest::new(json!([
1010 {"role": "system", "content": "You are a helpful assistant."},
1011 {"role": "user", "content": "Hello!"}
1012 ]));
1013
1014 let formatter = DeepSeekV32Formatter::new_thinking();
1016 let result = formatter.render(&request).unwrap();
1017
1018 assert!(
1019 result.ends_with(&format!(
1020 "{}{}",
1021 tokens::ASSISTANT_START,
1022 tokens::THINKING_START
1023 )),
1024 "Default (new_thinking) should produce thinking mode",
1025 );
1026
1027 let formatter_chat = DeepSeekV32Formatter::new_chat();
1029 let result_chat = formatter_chat.render(&request).unwrap();
1030
1031 assert!(
1032 result_chat.ends_with(&format!(
1033 "{}{}",
1034 tokens::ASSISTANT_START,
1035 tokens::THINKING_END
1036 )),
1037 "Default (new_chat) should produce chat mode",
1038 );
1039 }
1040
1041 #[test]
1042 fn test_thinking_false_overrides_default_thinking() {
1043 use crate::OAIPromptFormatter;
1044
1045 let args = std::collections::HashMap::from([("thinking".to_string(), json!(false))]);
1046
1047 let request = MockRequest::new(json!([
1048 {"role": "system", "content": "You are a helpful assistant."},
1049 {"role": "user", "content": "Hello!"}
1050 ]))
1051 .with_chat_template_args(args);
1052
1053 let formatter = DeepSeekV32Formatter::new_thinking();
1055 let result = formatter.render(&request).unwrap();
1056
1057 assert!(
1058 result.ends_with(&format!(
1059 "{}{}",
1060 tokens::ASSISTANT_START,
1061 tokens::THINKING_END
1062 )),
1063 "Per-request thinking=false should override new_thinking() default",
1064 );
1065 }
1066
1067 #[test]
1068 fn test_thinking_true_overrides_default_chat() {
1069 use crate::OAIPromptFormatter;
1070
1071 let args = std::collections::HashMap::from([("thinking".to_string(), json!(true))]);
1072
1073 let request = MockRequest::new(json!([
1074 {"role": "system", "content": "You are a helpful assistant."},
1075 {"role": "user", "content": "Hello!"}
1076 ]))
1077 .with_chat_template_args(args);
1078
1079 let formatter = DeepSeekV32Formatter::new_chat();
1081 let result = formatter.render(&request).unwrap();
1082
1083 assert!(
1084 result.ends_with(&format!(
1085 "{}{}",
1086 tokens::ASSISTANT_START,
1087 tokens::THINKING_START
1088 )),
1089 "Per-request thinking=true should override new_chat() default",
1090 );
1091 }
1092
1093 #[test]
1094 fn test_thinking_bool_takes_precedence_over_thinking_mode_string() {
1095 use crate::OAIPromptFormatter;
1096
1097 let args = std::collections::HashMap::from([
1098 ("thinking".to_string(), json!(false)),
1099 ("thinking_mode".to_string(), json!("thinking")),
1100 ]);
1101
1102 let request = MockRequest::new(json!([
1103 {"role": "system", "content": "You are a helpful assistant."},
1104 {"role": "user", "content": "Hello!"}
1105 ]))
1106 .with_chat_template_args(args);
1107
1108 let formatter = DeepSeekV32Formatter::new_thinking();
1109 let result = formatter.render(&request).unwrap();
1110
1111 assert!(
1113 result.ends_with(&format!(
1114 "{}{}",
1115 tokens::ASSISTANT_START,
1116 tokens::THINKING_END
1117 )),
1118 "Boolean 'thinking' key should take precedence over 'thinking_mode' string",
1119 );
1120 }
1121}