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        crate::reject_unsupported_partial_assistant(&messages_json)?;
307        crate::reject_unsupported_message_tools(&messages_json, &["developer"])?;
308
309        let mut messages_array = messages_json
310            .as_array()
311            .context("Messages is not an array")?
312            .clone();
313
314        // DeepSeek V3.2 native formatter expects text content in each message.
315        // Normalize OpenAI content arrays (e.g. [{type: "text", text: "..."}]) to strings.
316        normalize_message_contents(&mut messages_array, NormalizeNonText::SerializeJson);
317
318        // Inject tools and response_format from request into the first system message
319        // DeepSeek V3.2 expects these to be part of the system message for prompt rendering
320        super::common::inject_tools_and_response_format(&mut messages_array, req)?;
321
322        // Encode with native implementation
323        encode_messages(
324            &messages_array,
325            thinking_mode,
326            true, // always add BOS token
327        )
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use serde_json::json;
335
336    #[test]
337    fn test_simple_conversation() {
338        let messages = json!([
339            {"role": "system", "content": "You are a helpful assistant."},
340            {"role": "user", "content": "Hello!"}
341        ]);
342
343        let result =
344            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
345
346        assert!(result.starts_with(tokens::BOS));
347        assert!(result.contains("You are a helpful assistant."));
348        assert!(result.contains(tokens::USER_START));
349        assert!(result.contains("Hello!"));
350        assert!(result.contains(tokens::ASSISTANT_START));
351        assert!(result.contains(tokens::THINKING_START));
352    }
353
354    #[test]
355    fn test_formatter_handles_user_content_array() {
356        use crate::OAIPromptFormatter;
357
358        let request = MockRequest::new(json!([
359            {"role": "user", "content": [
360                {"type": "text", "text": "who are you?"}
361            ]}
362        ]));
363
364        let formatter = DeepSeekV32Formatter::new_thinking();
365        let result = formatter.render(&request).unwrap();
366
367        assert!(result.contains("who are you?"));
368        assert!(result.contains(tokens::USER_START));
369        assert!(result.contains(tokens::ASSISTANT_START));
370    }
371
372    #[test]
373    fn test_formatter_serializes_non_text_content() {
374        use crate::OAIPromptFormatter;
375
376        let request = MockRequest::new(json!([
377            {"role": "user", "content": {"foo": "bar"}}
378        ]));
379
380        let formatter = DeepSeekV32Formatter::new_thinking();
381        let result = formatter.render(&request).unwrap();
382
383        assert!(result.contains(r#"{"foo": "bar"}"#));
384    }
385
386    #[test]
387    fn test_formatter_rejects_unsupported_partial_assistant() {
388        use crate::OAIPromptFormatter;
389
390        let request = MockRequest::new(json!([
391            {"role": "user", "content": "Continue"},
392            {"role": "assistant", "content": "prefix", "partial": true}
393        ]));
394        let error = DeepSeekV32Formatter::new_thinking()
395            .render(&request)
396            .unwrap_err();
397
398        assert!(matches!(
399            error.downcast_ref::<crate::PromptRenderError>(),
400            Some(crate::PromptRenderError::InvalidRequest(message))
401                if message.contains("`partial: true` is not supported")
402        ));
403    }
404
405    #[test]
406    fn test_formatter_rejects_system_tools_before_injection() {
407        use crate::OAIPromptFormatter;
408
409        let request = MockRequest::new(json!([
410            {"role": "system", "tools": [
411                {"type": "function", "function": {"name": "dynamic_tool"}}
412            ]},
413            {"role": "user", "content": "Use a tool"}
414        ]))
415        .with_tools(json!([{"type": "function", "function": {"name": "top_level_tool"}}]));
416        let error = DeepSeekV32Formatter::new_thinking()
417            .render(&request)
418            .unwrap_err();
419
420        assert!(matches!(
421            error.downcast_ref::<crate::PromptRenderError>(),
422            Some(crate::PromptRenderError::InvalidRequest(message))
423                if message.contains("message-level `tools`") && message.contains("system")
424        ));
425    }
426
427    #[test]
428    fn test_formatter_preserves_developer_tools_with_top_level_tools() {
429        use crate::OAIPromptFormatter;
430
431        let request = MockRequest::new(json!([
432            {"role": "developer", "content": "Use a tool", "tools": [
433                {"type": "function", "function": {"name": "developer_tool"}}
434            ]}
435        ]))
436        .with_tools(json!([{"type": "function", "function": {"name": "top_level_tool"}}]));
437        let rendered = DeepSeekV32Formatter::new_thinking()
438            .render(&request)
439            .unwrap();
440
441        assert!(rendered.contains("developer_tool"));
442        assert!(rendered.contains("top_level_tool"));
443    }
444
445    #[test]
446    fn test_tools_rendering() {
447        let messages = json!([
448            {
449                "role": "system",
450                "content": "You are helpful.",
451                "tools": [{
452                    "type": "function",
453                    "function": {
454                        "name": "get_weather",
455                        "description": "Get weather",
456                        "parameters": {
457                            "type": "object",
458                            "properties": {
459                                "location": {"type": "string"}
460                            }
461                        }
462                    }
463                }]
464            },
465            {"role": "user", "content": "What's the weather?"}
466        ]);
467
468        let result =
469            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
470
471        assert!(result.contains("## Tools"));
472        assert!(result.contains("get_weather"));
473        assert!(result.contains("<functions>"));
474    }
475
476    // Mock request for testing OAIPromptFormatter implementation
477    struct MockRequest {
478        messages: JsonValue,
479        tools: Option<JsonValue>,
480        response_format: Option<JsonValue>,
481        chat_template_args: Option<std::collections::HashMap<String, JsonValue>>,
482    }
483
484    impl MockRequest {
485        fn new(messages: JsonValue) -> Self {
486            Self {
487                messages,
488                tools: None,
489                response_format: None,
490                chat_template_args: None,
491            }
492        }
493
494        fn with_tools(mut self, tools: JsonValue) -> Self {
495            self.tools = Some(tools);
496            self
497        }
498
499        fn with_response_format(mut self, response_format: JsonValue) -> Self {
500            self.response_format = Some(response_format);
501            self
502        }
503
504        fn with_chat_template_args(
505            mut self,
506            args: std::collections::HashMap<String, JsonValue>,
507        ) -> Self {
508            self.chat_template_args = Some(args);
509            self
510        }
511    }
512
513    impl crate::OAIChatLikeRequest for MockRequest {
514        fn model(&self) -> String {
515            "deepseek-v3.2".to_string()
516        }
517
518        fn messages(&self) -> minijinja::value::Value {
519            minijinja::value::Value::from_serialize(&self.messages)
520        }
521
522        fn tools(&self) -> Option<minijinja::value::Value> {
523            self.tools
524                .as_ref()
525                .map(minijinja::value::Value::from_serialize)
526        }
527
528        fn response_format(&self) -> Option<minijinja::value::Value> {
529            self.response_format
530                .as_ref()
531                .map(minijinja::value::Value::from_serialize)
532        }
533
534        fn should_add_generation_prompt(&self) -> bool {
535            true
536        }
537
538        fn chat_template_args(
539            &self,
540        ) -> Option<&std::collections::HashMap<String, serde_json::Value>> {
541            self.chat_template_args.as_ref()
542        }
543    }
544
545    #[test]
546    fn test_formatter_injects_tools_into_existing_system_message() {
547        use crate::OAIPromptFormatter;
548
549        let tools = json!([{
550            "type": "function",
551            "function": {
552                "name": "get_weather",
553                "description": "Get current weather",
554                "parameters": {
555                    "type": "object",
556                    "properties": {
557                        "location": {"type": "string", "description": "City name"},
558                        "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
559                    },
560                    "required": ["location"]
561                }
562            }
563        }]);
564
565        let request = MockRequest::new(json!([
566            {"role": "system", "content": "You are a helpful assistant."},
567            {"role": "user", "content": "What's the weather in Moscow?"}
568        ]))
569        .with_tools(tools);
570
571        let formatter = DeepSeekV32Formatter::new_thinking();
572        let result = formatter.render(&request).unwrap();
573
574        // Verify tools were injected into the prompt
575        assert!(
576            result.contains("## Tools"),
577            "Should contain Tools section header"
578        );
579        assert!(
580            result.contains("get_weather"),
581            "Should contain function name"
582        );
583        assert!(
584            result.contains("<functions>"),
585            "Should contain functions block"
586        );
587        assert!(
588            result.contains("</functions>"),
589            "Should contain closing functions tag"
590        );
591        assert!(
592            result.contains("You are a helpful assistant."),
593            "Should preserve original system content"
594        );
595        assert!(
596            result.contains(&format!("<{}function_calls>", tokens::DSML_TOKEN)),
597            "Should contain DSML format instructions"
598        );
599    }
600
601    #[test]
602    fn test_formatter_creates_system_message_for_tools_when_missing() {
603        use crate::OAIPromptFormatter;
604
605        let tools = json!([{
606            "type": "function",
607            "function": {
608                "name": "get_current_time",
609                "description": "Get current time in a timezone",
610                "parameters": {
611                    "type": "object",
612                    "properties": {
613                        "timezone": {"type": "string"}
614                    },
615                    "required": ["timezone"]
616                }
617            }
618        }]);
619
620        // Request without system message
621        let request = MockRequest::new(json!([
622            {"role": "user", "content": "What time is it in Tokyo?"}
623        ]))
624        .with_tools(tools);
625
626        let formatter = DeepSeekV32Formatter::new_thinking();
627        let result = formatter.render(&request).unwrap();
628
629        // Verify tools were injected via auto-created system message
630        assert!(
631            result.contains("## Tools"),
632            "Should contain Tools section even without explicit system message"
633        );
634        assert!(
635            result.contains("get_current_time"),
636            "Should contain function name"
637        );
638        assert!(
639            result.contains("<functions>"),
640            "Should contain functions block"
641        );
642    }
643
644    #[test]
645    fn test_formatter_without_tools_does_not_add_tools_section() {
646        use crate::OAIPromptFormatter;
647
648        let request = MockRequest::new(json!([
649            {"role": "system", "content": "You are a helpful assistant."},
650            {"role": "user", "content": "Hello!"}
651        ]));
652
653        let formatter = DeepSeekV32Formatter::new_thinking();
654        let result = formatter.render(&request).unwrap();
655
656        // Verify no tools section was added
657        assert!(
658            !result.contains("## Tools"),
659            "Should not contain Tools section when no tools provided"
660        );
661        assert!(
662            !result.contains("<functions>"),
663            "Should not contain functions block when no tools provided"
664        );
665        assert!(
666            result.contains("You are a helpful assistant."),
667            "Should preserve system content"
668        );
669    }
670
671    #[test]
672    fn test_formatter_with_multiple_tools() {
673        use crate::OAIPromptFormatter;
674
675        let tools = json!([
676            {
677                "type": "function",
678                "function": {
679                    "name": "get_weather",
680                    "description": "Get current weather",
681                    "parameters": {
682                        "type": "object",
683                        "properties": {
684                            "location": {"type": "string"}
685                        }
686                    }
687                }
688            },
689            {
690                "type": "function",
691                "function": {
692                    "name": "get_current_time",
693                    "description": "Get current time",
694                    "parameters": {
695                        "type": "object",
696                        "properties": {
697                            "timezone": {"type": "string"}
698                        }
699                    }
700                }
701            }
702        ]);
703
704        let request = MockRequest::new(json!([
705            {"role": "system", "content": "You are helpful."},
706            {"role": "user", "content": "Weather and time in Moscow?"}
707        ]))
708        .with_tools(tools);
709
710        let formatter = DeepSeekV32Formatter::new_thinking();
711        let result = formatter.render(&request).unwrap();
712
713        // Verify both tools are present
714        assert!(
715            result.contains("get_weather"),
716            "Should contain first function"
717        );
718        assert!(
719            result.contains("get_current_time"),
720            "Should contain second function"
721        );
722    }
723
724    // ==================== Structured Output Tests ====================
725
726    #[test]
727    fn test_formatter_injects_response_format_into_existing_system_message() {
728        use crate::OAIPromptFormatter;
729
730        let response_format = json!({
731            "type": "json_schema",
732            "json_schema": {
733                "name": "city_info",
734                "strict": true,
735                "schema": {
736                    "type": "object",
737                    "properties": {
738                        "city": {"type": "string"},
739                        "country": {"type": "string"},
740                        "population": {"type": "number"}
741                    },
742                    "required": ["city", "country", "population"]
743                }
744            }
745        });
746
747        let request = MockRequest::new(json!([
748            {"role": "system", "content": "You are a helpful assistant."},
749            {"role": "user", "content": "Tell me about Moscow."}
750        ]))
751        .with_response_format(response_format);
752
753        let formatter = DeepSeekV32Formatter::new_thinking();
754        let result = formatter.render(&request).unwrap();
755
756        // Verify response format was injected into the prompt
757        assert!(
758            result.contains("## Response Format:"),
759            "Should contain Response Format section header"
760        );
761        assert!(
762            result.contains("json_schema"),
763            "Should contain json_schema type"
764        );
765        assert!(result.contains("city_info"), "Should contain schema name");
766        assert!(
767            result.contains("You are a helpful assistant."),
768            "Should preserve original system content"
769        );
770    }
771
772    #[test]
773    fn test_formatter_creates_system_message_for_response_format_when_missing() {
774        use crate::OAIPromptFormatter;
775
776        let response_format = json!({
777            "type": "json_schema",
778            "json_schema": {
779                "name": "weather_response",
780                "schema": {
781                    "type": "object",
782                    "properties": {
783                        "temperature": {"type": "number"},
784                        "conditions": {"type": "string"}
785                    }
786                }
787            }
788        });
789
790        // Request without system message
791        let request = MockRequest::new(json!([
792            {"role": "user", "content": "What's the weather?"}
793        ]))
794        .with_response_format(response_format);
795
796        let formatter = DeepSeekV32Formatter::new_thinking();
797        let result = formatter.render(&request).unwrap();
798
799        // Verify response format was injected via auto-created system message
800        assert!(
801            result.contains("## Response Format:"),
802            "Should contain Response Format section even without explicit system message"
803        );
804        assert!(
805            result.contains("weather_response"),
806            "Should contain schema name"
807        );
808    }
809
810    #[test]
811    fn test_formatter_with_both_tools_and_response_format() {
812        use crate::OAIPromptFormatter;
813
814        let tools = json!([{
815            "type": "function",
816            "function": {
817                "name": "search_database",
818                "description": "Search the database",
819                "parameters": {
820                    "type": "object",
821                    "properties": {
822                        "query": {"type": "string"}
823                    }
824                }
825            }
826        }]);
827
828        let response_format = json!({
829            "type": "json_schema",
830            "json_schema": {
831                "name": "search_result",
832                "schema": {
833                    "type": "object",
834                    "properties": {
835                        "results": {"type": "array"},
836                        "total_count": {"type": "number"}
837                    }
838                }
839            }
840        });
841
842        let request = MockRequest::new(json!([
843            {"role": "system", "content": "You are a search assistant."},
844            {"role": "user", "content": "Find documents about Rust."}
845        ]))
846        .with_tools(tools)
847        .with_response_format(response_format);
848
849        let formatter = DeepSeekV32Formatter::new_thinking();
850        let result = formatter.render(&request).unwrap();
851
852        // Verify both tools and response format are present
853        assert!(result.contains("## Tools"), "Should contain Tools section");
854        assert!(
855            result.contains("search_database"),
856            "Should contain function name"
857        );
858        assert!(
859            result.contains("## Response Format:"),
860            "Should contain Response Format section"
861        );
862        assert!(
863            result.contains("search_result"),
864            "Should contain schema name"
865        );
866        assert!(
867            result.contains("You are a search assistant."),
868            "Should preserve original system content"
869        );
870    }
871
872    #[test]
873    fn test_formatter_without_response_format_does_not_add_response_format_section() {
874        use crate::OAIPromptFormatter;
875
876        let request = MockRequest::new(json!([
877            {"role": "system", "content": "You are a helpful assistant."},
878            {"role": "user", "content": "Hello!"}
879        ]));
880
881        let formatter = DeepSeekV32Formatter::new_thinking();
882        let result = formatter.render(&request).unwrap();
883
884        // Verify no response format section was added
885        assert!(
886            !result.contains("## Response Format:"),
887            "Should not contain Response Format section when not provided"
888        );
889    }
890
891    // ==================== Thinking Mode Override Tests ====================
892
893    #[test]
894    fn test_chat_mode_via_thinking_false() {
895        use crate::OAIPromptFormatter;
896
897        let args = std::collections::HashMap::from([("thinking".to_string(), json!(false))]);
898
899        let request = MockRequest::new(json!([
900            {"role": "system", "content": "You are a helpful assistant."},
901            {"role": "user", "content": "Hello!"}
902        ]))
903        .with_chat_template_args(args);
904
905        let formatter = DeepSeekV32Formatter::new_thinking();
906        let result = formatter.render(&request).unwrap();
907
908        // In chat mode, the last user message should be followed by </think> (closing tag)
909        // rather than <think> (opening tag)
910        assert!(
911            result.ends_with(&format!(
912                "{}{}",
913                tokens::ASSISTANT_START,
914                tokens::THINKING_END
915            )),
916            "Chat mode should end with </think> after Assistant token, got: ...{}",
917            &result[result.len().saturating_sub(80)..],
918        );
919        assert!(
920            !result.ends_with(&format!(
921                "{}{}",
922                tokens::ASSISTANT_START,
923                tokens::THINKING_START
924            )),
925            "Chat mode should NOT end with <think>",
926        );
927    }
928
929    #[test]
930    fn test_explicit_thinking_true_via_args() {
931        use crate::OAIPromptFormatter;
932
933        let args = std::collections::HashMap::from([("thinking".to_string(), json!(true))]);
934
935        let request = MockRequest::new(json!([
936            {"role": "system", "content": "You are a helpful assistant."},
937            {"role": "user", "content": "Hello!"}
938        ]))
939        .with_chat_template_args(args);
940
941        let formatter = DeepSeekV32Formatter::new_thinking();
942        let result = formatter.render(&request).unwrap();
943
944        assert!(
945            result.ends_with(&format!(
946                "{}{}",
947                tokens::ASSISTANT_START,
948                tokens::THINKING_START
949            )),
950            "Thinking mode should end with <think> after Assistant token",
951        );
952    }
953
954    #[test]
955    fn test_chat_mode_via_thinking_mode_string() {
956        use crate::OAIPromptFormatter;
957
958        let args = std::collections::HashMap::from([("thinking_mode".to_string(), json!("chat"))]);
959
960        let request = MockRequest::new(json!([
961            {"role": "system", "content": "You are a helpful assistant."},
962            {"role": "user", "content": "Hello!"}
963        ]))
964        .with_chat_template_args(args);
965
966        let formatter = DeepSeekV32Formatter::new_thinking();
967        let result = formatter.render(&request).unwrap();
968
969        assert!(
970            result.ends_with(&format!(
971                "{}{}",
972                tokens::ASSISTANT_START,
973                tokens::THINKING_END
974            )),
975            "thinking_mode='chat' should produce chat mode (ends with </think>)",
976        );
977    }
978
979    #[test]
980    fn test_thinking_mode_string_thinking() {
981        use crate::OAIPromptFormatter;
982
983        let args =
984            std::collections::HashMap::from([("thinking_mode".to_string(), json!("thinking"))]);
985
986        let request = MockRequest::new(json!([
987            {"role": "system", "content": "You are a helpful assistant."},
988            {"role": "user", "content": "Hello!"}
989        ]))
990        .with_chat_template_args(args);
991
992        let formatter = DeepSeekV32Formatter::new_thinking();
993        let result = formatter.render(&request).unwrap();
994
995        assert!(
996            result.ends_with(&format!(
997                "{}{}",
998                tokens::ASSISTANT_START,
999                tokens::THINKING_START
1000            )),
1001            "thinking_mode='thinking' should produce thinking mode (ends with <think>)",
1002        );
1003    }
1004
1005    #[test]
1006    fn test_default_thinking_mode_without_args() {
1007        use crate::OAIPromptFormatter;
1008
1009        let request = MockRequest::new(json!([
1010            {"role": "system", "content": "You are a helpful assistant."},
1011            {"role": "user", "content": "Hello!"}
1012        ]));
1013
1014        // No chat_template_args — should default to formatter's thinking mode
1015        let formatter = DeepSeekV32Formatter::new_thinking();
1016        let result = formatter.render(&request).unwrap();
1017
1018        assert!(
1019            result.ends_with(&format!(
1020                "{}{}",
1021                tokens::ASSISTANT_START,
1022                tokens::THINKING_START
1023            )),
1024            "Default (new_thinking) should produce thinking mode",
1025        );
1026
1027        // Verify new_chat() default also works
1028        let formatter_chat = DeepSeekV32Formatter::new_chat();
1029        let result_chat = formatter_chat.render(&request).unwrap();
1030
1031        assert!(
1032            result_chat.ends_with(&format!(
1033                "{}{}",
1034                tokens::ASSISTANT_START,
1035                tokens::THINKING_END
1036            )),
1037            "Default (new_chat) should produce chat mode",
1038        );
1039    }
1040
1041    #[test]
1042    fn test_thinking_false_overrides_default_thinking() {
1043        use crate::OAIPromptFormatter;
1044
1045        let args = std::collections::HashMap::from([("thinking".to_string(), json!(false))]);
1046
1047        let request = MockRequest::new(json!([
1048            {"role": "system", "content": "You are a helpful assistant."},
1049            {"role": "user", "content": "Hello!"}
1050        ]))
1051        .with_chat_template_args(args);
1052
1053        // Formatter defaults to thinking, but request overrides to chat
1054        let formatter = DeepSeekV32Formatter::new_thinking();
1055        let result = formatter.render(&request).unwrap();
1056
1057        assert!(
1058            result.ends_with(&format!(
1059                "{}{}",
1060                tokens::ASSISTANT_START,
1061                tokens::THINKING_END
1062            )),
1063            "Per-request thinking=false should override new_thinking() default",
1064        );
1065    }
1066
1067    #[test]
1068    fn test_thinking_true_overrides_default_chat() {
1069        use crate::OAIPromptFormatter;
1070
1071        let args = std::collections::HashMap::from([("thinking".to_string(), json!(true))]);
1072
1073        let request = MockRequest::new(json!([
1074            {"role": "system", "content": "You are a helpful assistant."},
1075            {"role": "user", "content": "Hello!"}
1076        ]))
1077        .with_chat_template_args(args);
1078
1079        // Formatter defaults to chat, but request overrides to thinking
1080        let formatter = DeepSeekV32Formatter::new_chat();
1081        let result = formatter.render(&request).unwrap();
1082
1083        assert!(
1084            result.ends_with(&format!(
1085                "{}{}",
1086                tokens::ASSISTANT_START,
1087                tokens::THINKING_START
1088            )),
1089            "Per-request thinking=true should override new_chat() default",
1090        );
1091    }
1092
1093    #[test]
1094    fn test_thinking_bool_takes_precedence_over_thinking_mode_string() {
1095        use crate::OAIPromptFormatter;
1096
1097        let args = std::collections::HashMap::from([
1098            ("thinking".to_string(), json!(false)),
1099            ("thinking_mode".to_string(), json!("thinking")),
1100        ]);
1101
1102        let request = MockRequest::new(json!([
1103            {"role": "system", "content": "You are a helpful assistant."},
1104            {"role": "user", "content": "Hello!"}
1105        ]))
1106        .with_chat_template_args(args);
1107
1108        let formatter = DeepSeekV32Formatter::new_thinking();
1109        let result = formatter.render(&request).unwrap();
1110
1111        // "thinking": false should win over "thinking_mode": "thinking"
1112        assert!(
1113            result.ends_with(&format!(
1114                "{}{}",
1115                tokens::ASSISTANT_START,
1116                tokens::THINKING_END
1117            )),
1118            "Boolean 'thinking' key should take precedence over 'thinking_mode' string",
1119        );
1120    }
1121}