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;
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
22/// Render a single message
23fn 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            // Handle reasoning content
99            // NOTE: If this assistant comes after last user message, the opening <think>
100            // was already added in the user message. We only need to add content and closing tag.
101            //
102            // Handle reasoning_content which may be a plain string or an array of segments.
103            // DeepSeek V3.2 always places its <think> block before all tool calls, so
104            // joining segments produces the correct flat form here.
105            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            // Handle content
135            if let Some(content) = msg.get("content").and_then(|c| c.as_str()) {
136                prompt.push_str(content);
137            }
138
139            // Handle tool calls
140            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            // Find the previous assistant message
175            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            // Add opening tag for first tool result
191            if tool_call_order == 1 {
192                prompt.push_str("\n\n<function_results>");
193            }
194
195            // Add result
196            let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
197            write!(prompt, "\n<result>{}</result>", content)?;
198
199            // Check if this is the last tool result
200            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
229/// Encode messages to prompt string
230///
231/// # Arguments
232/// * `messages` - Array of messages in OpenAI format
233/// * `thinking_mode` - Whether to use thinking mode
234/// * `add_bos_token` - Whether to add BOS token at start
235///
236/// # Returns
237/// Formatted prompt string ready for tokenization
238pub 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/// DeepSeek V3.2 Prompt Formatter
259///
260/// Implements OAIPromptFormatter for DeepSeek V3.2 models using native Rust implementation
261#[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    /// Create formatter with thinking mode enabled (default for DSV3.2)
272    pub fn new_thinking() -> Self {
273        Self::new(ThinkingMode::Thinking)
274    }
275
276    /// Create formatter with chat mode
277    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        // DeepSeek V3.2 native formatter expects text content in each message.
299        // Normalize OpenAI content arrays (e.g. [{type: "text", text: "..."}]) to strings.
300        normalize_message_contents(&mut messages_array, NormalizeNonText::SerializeJson);
301
302        // Inject tools and response_format from request into the first system message
303        // DeepSeek V3.2 expects these to be part of the system message for prompt rendering
304        super::common::inject_tools_and_response_format(&mut messages_array, req)?;
305
306        // Encode with native implementation
307        encode_messages(
308            &messages_array,
309            thinking_mode,
310            true, // always add BOS token
311        )
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    // Mock request for testing OAIPromptFormatter implementation
475    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        // Verify tools were injected into the prompt
573        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        // Request without system message
619        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        // Verify tools were injected via auto-created system message
628        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        // Verify no tools section was added
655        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        // Verify both tools are present
712        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    // ==================== Structured Output Tests ====================
723
724    #[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        // Verify response format was injected into the prompt
755        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        // Request without system message
789        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        // Verify response format was injected via auto-created system message
798        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        // Verify both tools and response format are present
851        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        // Verify no response format section was added
883        assert!(
884            !result.contains("## Response Format:"),
885            "Should not contain Response Format section when not provided"
886        );
887    }
888
889    // ==================== Thinking Mode Override Tests ====================
890
891    #[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        // In chat mode, the last user message should be followed by </think> (closing tag)
907        // rather than <think> (opening tag)
908        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        // No chat_template_args — should default to formatter's thinking mode
1013        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        // Verify new_chat() default also works
1026        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        // Formatter defaults to thinking, but request overrides to chat
1052        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        // Formatter defaults to chat, but request overrides to thinking
1078        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        // "thinking": false should win over "thinking_mode": "thinking"
1110        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}