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