Skip to main content

dynamo_renderer/deepseek/
v32.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! DeepSeek V3.2 native prompt formatting
5//!
6//! This module provides native Rust implementation of DeepSeek V3.2's chat template,
7//! based on their official Python code: encoding_dsv32.py
8//!
9//! Reference: https://huggingface.co/deepseek-ai/DeepSeek-V3.2/tree/main/encoding
10
11use 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
22/// Render a single message
23fn 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            // Handle reasoning content
103            // NOTE: If this assistant comes after last user message, the opening <think>
104            // was already added in the user message. We only need to add content and closing tag.
105            //
106            // Handle reasoning_content which may be a plain string or an array of segments.
107            // DeepSeek V3.2 always places its <think> block before all tool calls, so
108            // joining segments produces the correct flat form here.
109            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                    // DON'T add THINKING_START - it was already added in user message
138                    prompt.push_str(&reasoning);
139                    prompt.push_str(tokens::THINKING_END);
140                }
141            }
142
143            // Handle content
144            if let Some(content) = msg.get("content").and_then(|c| c.as_str()) {
145                prompt.push_str(content);
146            }
147
148            // Handle tool calls
149            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            // Find the previous assistant message
183            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            // Add opening tag for first tool result
199            if tool_call_order == 1 {
200                prompt.push_str("\n\n<function_results>");
201            }
202
203            // Add result
204            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            // Check if this is the last tool result
208            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
237/// Encode messages to prompt string
238///
239/// # Arguments
240/// * `messages` - Array of messages in OpenAI format
241/// * `thinking_mode` - Whether to use thinking mode
242/// * `add_bos_token` - Whether to add BOS token at start
243///
244/// # Returns
245/// Formatted prompt string ready for tokenization
246pub 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/// DeepSeek V3.2 Prompt Formatter
268///
269/// Implements OAIPromptFormatter for DeepSeek V3.2 models using native Rust implementation
270#[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    /// Create formatter with thinking mode enabled (default for DSV3.2)
281    pub fn new_thinking() -> Self {
282        Self::new(ThinkingMode::Thinking)
283    }
284
285    /// Create formatter with chat mode
286    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        // Get messages from request
301        let messages_value = req.messages();
302
303        // Convert minijinja Value to serde_json Value
304        let messages_json =
305            serde_json::to_value(&messages_value).context("Failed to convert messages to JSON")?;
306
307        let mut messages_array = messages_json
308            .as_array()
309            .context("Messages is not an array")?
310            .clone();
311
312        // DeepSeek V3.2 native formatter expects text content in each message.
313        // Normalize OpenAI content arrays (e.g. [{type: "text", text: "..."}]) to strings.
314        normalize_message_contents(&mut messages_array, NormalizeNonText::SerializeJson);
315
316        // Inject tools and response_format from request into the first system message
317        // DeepSeek V3.2 expects these to be part of the system message for prompt rendering
318        super::common::inject_tools_and_response_format(&mut messages_array, req)?;
319
320        // Encode with native implementation
321        encode_messages(
322            &messages_array,
323            thinking_mode,
324            true, // always add BOS token
325        )
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use serde_json::json;
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_tools_rendering() {
386        let messages = json!([
387            {
388                "role": "system",
389                "content": "You are helpful.",
390                "tools": [{
391                    "type": "function",
392                    "function": {
393                        "name": "get_weather",
394                        "description": "Get weather",
395                        "parameters": {
396                            "type": "object",
397                            "properties": {
398                                "location": {"type": "string"}
399                            }
400                        }
401                    }
402                }]
403            },
404            {"role": "user", "content": "What's the weather?"}
405        ]);
406
407        let result =
408            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
409
410        assert!(result.contains("## Tools"));
411        assert!(result.contains("get_weather"));
412        assert!(result.contains("<functions>"));
413    }
414
415    // Mock request for testing OAIPromptFormatter implementation
416    struct MockRequest {
417        messages: JsonValue,
418        tools: Option<JsonValue>,
419        response_format: Option<JsonValue>,
420        chat_template_args: Option<std::collections::HashMap<String, JsonValue>>,
421    }
422
423    impl MockRequest {
424        fn new(messages: JsonValue) -> Self {
425            Self {
426                messages,
427                tools: None,
428                response_format: None,
429                chat_template_args: None,
430            }
431        }
432
433        fn with_tools(mut self, tools: JsonValue) -> Self {
434            self.tools = Some(tools);
435            self
436        }
437
438        fn with_response_format(mut self, response_format: JsonValue) -> Self {
439            self.response_format = Some(response_format);
440            self
441        }
442
443        fn with_chat_template_args(
444            mut self,
445            args: std::collections::HashMap<String, JsonValue>,
446        ) -> Self {
447            self.chat_template_args = Some(args);
448            self
449        }
450    }
451
452    impl crate::OAIChatLikeRequest for MockRequest {
453        fn model(&self) -> String {
454            "deepseek-v3.2".to_string()
455        }
456
457        fn messages(&self) -> minijinja::value::Value {
458            minijinja::value::Value::from_serialize(&self.messages)
459        }
460
461        fn tools(&self) -> Option<minijinja::value::Value> {
462            self.tools
463                .as_ref()
464                .map(minijinja::value::Value::from_serialize)
465        }
466
467        fn response_format(&self) -> Option<minijinja::value::Value> {
468            self.response_format
469                .as_ref()
470                .map(minijinja::value::Value::from_serialize)
471        }
472
473        fn should_add_generation_prompt(&self) -> bool {
474            true
475        }
476
477        fn chat_template_args(
478            &self,
479        ) -> Option<&std::collections::HashMap<String, serde_json::Value>> {
480            self.chat_template_args.as_ref()
481        }
482    }
483
484    #[test]
485    fn test_formatter_injects_tools_into_existing_system_message() {
486        use crate::OAIPromptFormatter;
487
488        let tools = json!([{
489            "type": "function",
490            "function": {
491                "name": "get_weather",
492                "description": "Get current weather",
493                "parameters": {
494                    "type": "object",
495                    "properties": {
496                        "location": {"type": "string", "description": "City name"},
497                        "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
498                    },
499                    "required": ["location"]
500                }
501            }
502        }]);
503
504        let request = MockRequest::new(json!([
505            {"role": "system", "content": "You are a helpful assistant."},
506            {"role": "user", "content": "What's the weather in Moscow?"}
507        ]))
508        .with_tools(tools);
509
510        let formatter = DeepSeekV32Formatter::new_thinking();
511        let result = formatter.render(&request).unwrap();
512
513        // Verify tools were injected into the prompt
514        assert!(
515            result.contains("## Tools"),
516            "Should contain Tools section header"
517        );
518        assert!(
519            result.contains("get_weather"),
520            "Should contain function name"
521        );
522        assert!(
523            result.contains("<functions>"),
524            "Should contain functions block"
525        );
526        assert!(
527            result.contains("</functions>"),
528            "Should contain closing functions tag"
529        );
530        assert!(
531            result.contains("You are a helpful assistant."),
532            "Should preserve original system content"
533        );
534        assert!(
535            result.contains(&format!("<{}function_calls>", tokens::DSML_TOKEN)),
536            "Should contain DSML format instructions"
537        );
538    }
539
540    #[test]
541    fn test_formatter_creates_system_message_for_tools_when_missing() {
542        use crate::OAIPromptFormatter;
543
544        let tools = json!([{
545            "type": "function",
546            "function": {
547                "name": "get_current_time",
548                "description": "Get current time in a timezone",
549                "parameters": {
550                    "type": "object",
551                    "properties": {
552                        "timezone": {"type": "string"}
553                    },
554                    "required": ["timezone"]
555                }
556            }
557        }]);
558
559        // Request without system message
560        let request = MockRequest::new(json!([
561            {"role": "user", "content": "What time is it in Tokyo?"}
562        ]))
563        .with_tools(tools);
564
565        let formatter = DeepSeekV32Formatter::new_thinking();
566        let result = formatter.render(&request).unwrap();
567
568        // Verify tools were injected via auto-created system message
569        assert!(
570            result.contains("## Tools"),
571            "Should contain Tools section even without explicit system message"
572        );
573        assert!(
574            result.contains("get_current_time"),
575            "Should contain function name"
576        );
577        assert!(
578            result.contains("<functions>"),
579            "Should contain functions block"
580        );
581    }
582
583    #[test]
584    fn test_formatter_without_tools_does_not_add_tools_section() {
585        use crate::OAIPromptFormatter;
586
587        let request = MockRequest::new(json!([
588            {"role": "system", "content": "You are a helpful assistant."},
589            {"role": "user", "content": "Hello!"}
590        ]));
591
592        let formatter = DeepSeekV32Formatter::new_thinking();
593        let result = formatter.render(&request).unwrap();
594
595        // Verify no tools section was added
596        assert!(
597            !result.contains("## Tools"),
598            "Should not contain Tools section when no tools provided"
599        );
600        assert!(
601            !result.contains("<functions>"),
602            "Should not contain functions block when no tools provided"
603        );
604        assert!(
605            result.contains("You are a helpful assistant."),
606            "Should preserve system content"
607        );
608    }
609
610    #[test]
611    fn test_formatter_with_multiple_tools() {
612        use crate::OAIPromptFormatter;
613
614        let tools = json!([
615            {
616                "type": "function",
617                "function": {
618                    "name": "get_weather",
619                    "description": "Get current weather",
620                    "parameters": {
621                        "type": "object",
622                        "properties": {
623                            "location": {"type": "string"}
624                        }
625                    }
626                }
627            },
628            {
629                "type": "function",
630                "function": {
631                    "name": "get_current_time",
632                    "description": "Get current time",
633                    "parameters": {
634                        "type": "object",
635                        "properties": {
636                            "timezone": {"type": "string"}
637                        }
638                    }
639                }
640            }
641        ]);
642
643        let request = MockRequest::new(json!([
644            {"role": "system", "content": "You are helpful."},
645            {"role": "user", "content": "Weather and time in Moscow?"}
646        ]))
647        .with_tools(tools);
648
649        let formatter = DeepSeekV32Formatter::new_thinking();
650        let result = formatter.render(&request).unwrap();
651
652        // Verify both tools are present
653        assert!(
654            result.contains("get_weather"),
655            "Should contain first function"
656        );
657        assert!(
658            result.contains("get_current_time"),
659            "Should contain second function"
660        );
661    }
662
663    // ==================== Structured Output Tests ====================
664
665    #[test]
666    fn test_formatter_injects_response_format_into_existing_system_message() {
667        use crate::OAIPromptFormatter;
668
669        let response_format = json!({
670            "type": "json_schema",
671            "json_schema": {
672                "name": "city_info",
673                "strict": true,
674                "schema": {
675                    "type": "object",
676                    "properties": {
677                        "city": {"type": "string"},
678                        "country": {"type": "string"},
679                        "population": {"type": "number"}
680                    },
681                    "required": ["city", "country", "population"]
682                }
683            }
684        });
685
686        let request = MockRequest::new(json!([
687            {"role": "system", "content": "You are a helpful assistant."},
688            {"role": "user", "content": "Tell me about Moscow."}
689        ]))
690        .with_response_format(response_format);
691
692        let formatter = DeepSeekV32Formatter::new_thinking();
693        let result = formatter.render(&request).unwrap();
694
695        // Verify response format was injected into the prompt
696        assert!(
697            result.contains("## Response Format:"),
698            "Should contain Response Format section header"
699        );
700        assert!(
701            result.contains("json_schema"),
702            "Should contain json_schema type"
703        );
704        assert!(result.contains("city_info"), "Should contain schema name");
705        assert!(
706            result.contains("You are a helpful assistant."),
707            "Should preserve original system content"
708        );
709    }
710
711    #[test]
712    fn test_formatter_creates_system_message_for_response_format_when_missing() {
713        use crate::OAIPromptFormatter;
714
715        let response_format = json!({
716            "type": "json_schema",
717            "json_schema": {
718                "name": "weather_response",
719                "schema": {
720                    "type": "object",
721                    "properties": {
722                        "temperature": {"type": "number"},
723                        "conditions": {"type": "string"}
724                    }
725                }
726            }
727        });
728
729        // Request without system message
730        let request = MockRequest::new(json!([
731            {"role": "user", "content": "What's the weather?"}
732        ]))
733        .with_response_format(response_format);
734
735        let formatter = DeepSeekV32Formatter::new_thinking();
736        let result = formatter.render(&request).unwrap();
737
738        // Verify response format was injected via auto-created system message
739        assert!(
740            result.contains("## Response Format:"),
741            "Should contain Response Format section even without explicit system message"
742        );
743        assert!(
744            result.contains("weather_response"),
745            "Should contain schema name"
746        );
747    }
748
749    #[test]
750    fn test_formatter_with_both_tools_and_response_format() {
751        use crate::OAIPromptFormatter;
752
753        let tools = json!([{
754            "type": "function",
755            "function": {
756                "name": "search_database",
757                "description": "Search the database",
758                "parameters": {
759                    "type": "object",
760                    "properties": {
761                        "query": {"type": "string"}
762                    }
763                }
764            }
765        }]);
766
767        let response_format = json!({
768            "type": "json_schema",
769            "json_schema": {
770                "name": "search_result",
771                "schema": {
772                    "type": "object",
773                    "properties": {
774                        "results": {"type": "array"},
775                        "total_count": {"type": "number"}
776                    }
777                }
778            }
779        });
780
781        let request = MockRequest::new(json!([
782            {"role": "system", "content": "You are a search assistant."},
783            {"role": "user", "content": "Find documents about Rust."}
784        ]))
785        .with_tools(tools)
786        .with_response_format(response_format);
787
788        let formatter = DeepSeekV32Formatter::new_thinking();
789        let result = formatter.render(&request).unwrap();
790
791        // Verify both tools and response format are present
792        assert!(result.contains("## Tools"), "Should contain Tools section");
793        assert!(
794            result.contains("search_database"),
795            "Should contain function name"
796        );
797        assert!(
798            result.contains("## Response Format:"),
799            "Should contain Response Format section"
800        );
801        assert!(
802            result.contains("search_result"),
803            "Should contain schema name"
804        );
805        assert!(
806            result.contains("You are a search assistant."),
807            "Should preserve original system content"
808        );
809    }
810
811    #[test]
812    fn test_formatter_without_response_format_does_not_add_response_format_section() {
813        use crate::OAIPromptFormatter;
814
815        let request = MockRequest::new(json!([
816            {"role": "system", "content": "You are a helpful assistant."},
817            {"role": "user", "content": "Hello!"}
818        ]));
819
820        let formatter = DeepSeekV32Formatter::new_thinking();
821        let result = formatter.render(&request).unwrap();
822
823        // Verify no response format section was added
824        assert!(
825            !result.contains("## Response Format:"),
826            "Should not contain Response Format section when not provided"
827        );
828    }
829
830    // ==================== Thinking Mode Override Tests ====================
831
832    #[test]
833    fn test_chat_mode_via_thinking_false() {
834        use crate::OAIPromptFormatter;
835
836        let args = std::collections::HashMap::from([("thinking".to_string(), json!(false))]);
837
838        let request = MockRequest::new(json!([
839            {"role": "system", "content": "You are a helpful assistant."},
840            {"role": "user", "content": "Hello!"}
841        ]))
842        .with_chat_template_args(args);
843
844        let formatter = DeepSeekV32Formatter::new_thinking();
845        let result = formatter.render(&request).unwrap();
846
847        // In chat mode, the last user message should be followed by </think> (closing tag)
848        // rather than <think> (opening tag)
849        assert!(
850            result.ends_with(&format!(
851                "{}{}",
852                tokens::ASSISTANT_START,
853                tokens::THINKING_END
854            )),
855            "Chat mode should end with </think> after Assistant token, got: ...{}",
856            &result[result.len().saturating_sub(80)..],
857        );
858        assert!(
859            !result.ends_with(&format!(
860                "{}{}",
861                tokens::ASSISTANT_START,
862                tokens::THINKING_START
863            )),
864            "Chat mode should NOT end with <think>",
865        );
866    }
867
868    #[test]
869    fn test_explicit_thinking_true_via_args() {
870        use crate::OAIPromptFormatter;
871
872        let args = std::collections::HashMap::from([("thinking".to_string(), json!(true))]);
873
874        let request = MockRequest::new(json!([
875            {"role": "system", "content": "You are a helpful assistant."},
876            {"role": "user", "content": "Hello!"}
877        ]))
878        .with_chat_template_args(args);
879
880        let formatter = DeepSeekV32Formatter::new_thinking();
881        let result = formatter.render(&request).unwrap();
882
883        assert!(
884            result.ends_with(&format!(
885                "{}{}",
886                tokens::ASSISTANT_START,
887                tokens::THINKING_START
888            )),
889            "Thinking mode should end with <think> after Assistant token",
890        );
891    }
892
893    #[test]
894    fn test_chat_mode_via_thinking_mode_string() {
895        use crate::OAIPromptFormatter;
896
897        let args = std::collections::HashMap::from([("thinking_mode".to_string(), json!("chat"))]);
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!(
909            result.ends_with(&format!(
910                "{}{}",
911                tokens::ASSISTANT_START,
912                tokens::THINKING_END
913            )),
914            "thinking_mode='chat' should produce chat mode (ends with </think>)",
915        );
916    }
917
918    #[test]
919    fn test_thinking_mode_string_thinking() {
920        use crate::OAIPromptFormatter;
921
922        let args =
923            std::collections::HashMap::from([("thinking_mode".to_string(), json!("thinking"))]);
924
925        let request = MockRequest::new(json!([
926            {"role": "system", "content": "You are a helpful assistant."},
927            {"role": "user", "content": "Hello!"}
928        ]))
929        .with_chat_template_args(args);
930
931        let formatter = DeepSeekV32Formatter::new_thinking();
932        let result = formatter.render(&request).unwrap();
933
934        assert!(
935            result.ends_with(&format!(
936                "{}{}",
937                tokens::ASSISTANT_START,
938                tokens::THINKING_START
939            )),
940            "thinking_mode='thinking' should produce thinking mode (ends with <think>)",
941        );
942    }
943
944    #[test]
945    fn test_default_thinking_mode_without_args() {
946        use crate::OAIPromptFormatter;
947
948        let request = MockRequest::new(json!([
949            {"role": "system", "content": "You are a helpful assistant."},
950            {"role": "user", "content": "Hello!"}
951        ]));
952
953        // No chat_template_args — should default to formatter's thinking mode
954        let formatter = DeepSeekV32Formatter::new_thinking();
955        let result = formatter.render(&request).unwrap();
956
957        assert!(
958            result.ends_with(&format!(
959                "{}{}",
960                tokens::ASSISTANT_START,
961                tokens::THINKING_START
962            )),
963            "Default (new_thinking) should produce thinking mode",
964        );
965
966        // Verify new_chat() default also works
967        let formatter_chat = DeepSeekV32Formatter::new_chat();
968        let result_chat = formatter_chat.render(&request).unwrap();
969
970        assert!(
971            result_chat.ends_with(&format!(
972                "{}{}",
973                tokens::ASSISTANT_START,
974                tokens::THINKING_END
975            )),
976            "Default (new_chat) should produce chat mode",
977        );
978    }
979
980    #[test]
981    fn test_thinking_false_overrides_default_thinking() {
982        use crate::OAIPromptFormatter;
983
984        let args = std::collections::HashMap::from([("thinking".to_string(), json!(false))]);
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        // Formatter defaults to thinking, but request overrides to chat
993        let formatter = DeepSeekV32Formatter::new_thinking();
994        let result = formatter.render(&request).unwrap();
995
996        assert!(
997            result.ends_with(&format!(
998                "{}{}",
999                tokens::ASSISTANT_START,
1000                tokens::THINKING_END
1001            )),
1002            "Per-request thinking=false should override new_thinking() default",
1003        );
1004    }
1005
1006    #[test]
1007    fn test_thinking_true_overrides_default_chat() {
1008        use crate::OAIPromptFormatter;
1009
1010        let args = std::collections::HashMap::from([("thinking".to_string(), json!(true))]);
1011
1012        let request = MockRequest::new(json!([
1013            {"role": "system", "content": "You are a helpful assistant."},
1014            {"role": "user", "content": "Hello!"}
1015        ]))
1016        .with_chat_template_args(args);
1017
1018        // Formatter defaults to chat, but request overrides to thinking
1019        let formatter = DeepSeekV32Formatter::new_chat();
1020        let result = formatter.render(&request).unwrap();
1021
1022        assert!(
1023            result.ends_with(&format!(
1024                "{}{}",
1025                tokens::ASSISTANT_START,
1026                tokens::THINKING_START
1027            )),
1028            "Per-request thinking=true should override new_chat() default",
1029        );
1030    }
1031
1032    #[test]
1033    fn test_thinking_bool_takes_precedence_over_thinking_mode_string() {
1034        use crate::OAIPromptFormatter;
1035
1036        let args = std::collections::HashMap::from([
1037            ("thinking".to_string(), json!(false)),
1038            ("thinking_mode".to_string(), json!("thinking")),
1039        ]);
1040
1041        let request = MockRequest::new(json!([
1042            {"role": "system", "content": "You are a helpful assistant."},
1043            {"role": "user", "content": "Hello!"}
1044        ]))
1045        .with_chat_template_args(args);
1046
1047        let formatter = DeepSeekV32Formatter::new_thinking();
1048        let result = formatter.render(&request).unwrap();
1049
1050        // "thinking": false should win over "thinking_mode": "thinking"
1051        assert!(
1052            result.ends_with(&format!(
1053                "{}{}",
1054                tokens::ASSISTANT_START,
1055                tokens::THINKING_END
1056            )),
1057            "Boolean 'thinking' key should take precedence over 'thinking_mode' string",
1058        );
1059    }
1060}