Skip to main content

dynamo_renderer/deepseek/
v4.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! DeepSeek V4 native prompt formatting
5//!
6//! Native Rust port of DeepSeek V4's chat encoding (encoding_dsv4.py).
7//!
8//! Reference: DeepSeek-V4-Pro/encoding/encoding_dsv4.py
9
10use anyhow::{Context, Result};
11use serde_json::Value as JsonValue;
12
13use super::common::{
14    NormalizeNonText, REASONING_EFFORT_MAX, RESPONSE_FORMAT_TEMPLATE, TOOL_CALLS_BLOCK_NAME,
15    TOOLS_TEMPLATE, drop_thinking_messages, encode_arguments_to_dsml, find_last_user_index,
16    merge_tool_messages, normalize_message_contents, render_tools, sort_tool_results_by_call_order,
17    task_token, to_json,
18};
19pub use super::common::{ReasoningEffort, ThinkingMode, tokens};
20
21/// Render a single message at the given index.
22fn render_message(
23    index: usize,
24    messages: &[JsonValue],
25    thinking_mode: ThinkingMode,
26    drop_thinking: bool,
27    reasoning_effort: Option<ReasoningEffort>,
28    last_user_idx: Option<usize>,
29) -> Result<String> {
30    let msg = &messages[index];
31
32    let role = msg
33        .get("role")
34        .and_then(|r| r.as_str())
35        .context("Missing 'role' field")?;
36
37    let mut prompt = String::new();
38
39    // Reasoning effort prefix (only at index 0 in thinking mode with max effort).
40    if index == 0
41        && thinking_mode == ThinkingMode::Thinking
42        && reasoning_effort == Some(ReasoningEffort::Max)
43    {
44        prompt.push_str(REASONING_EFFORT_MAX);
45    }
46
47    match role {
48        "system" => {
49            let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
50            prompt.push_str(content);
51            if let Some(tools) = msg.get("tools").and_then(|t| t.as_array()) {
52                prompt.push_str("\n\n");
53                prompt.push_str(&render_tools(TOOLS_TEMPLATE, tools));
54            }
55            if let Some(response_format) = msg.get("response_format") {
56                prompt.push_str("\n\n");
57                prompt.push_str(
58                    &RESPONSE_FORMAT_TEMPLATE.replace("{schema}", &to_json(response_format)),
59                );
60            }
61        }
62
63        "developer" => {
64            let content = msg
65                .get("content")
66                .and_then(|c| c.as_str())
67                .filter(|s| !s.is_empty())
68                .context("Developer role requires content")?;
69
70            let mut content_developer = String::from(tokens::USER_START);
71            content_developer.push_str(content);
72
73            if let Some(tools) = msg.get("tools").and_then(|t| t.as_array()) {
74                content_developer.push_str("\n\n");
75                content_developer.push_str(&render_tools(TOOLS_TEMPLATE, tools));
76            }
77            if let Some(response_format) = msg.get("response_format") {
78                content_developer.push_str("\n\n");
79                content_developer.push_str(
80                    &RESPONSE_FORMAT_TEMPLATE.replace("{schema}", &to_json(response_format)),
81                );
82            }
83            prompt.push_str(&content_developer);
84        }
85
86        "user" => {
87            prompt.push_str(tokens::USER_START);
88            if let Some(blocks) = msg.get("content_blocks").and_then(|b| b.as_array()) {
89                let mut parts: Vec<String> = Vec::with_capacity(blocks.len());
90                for block in blocks {
91                    let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
92                    match block_type {
93                        "text" => {
94                            let text = block.get("text").and_then(|v| v.as_str()).unwrap_or("");
95                            parts.push(text.to_string());
96                        }
97                        "tool_result" => {
98                            let rendered = render_tool_result_content(
99                                block.get("content").unwrap_or(&JsonValue::Null),
100                            );
101                            parts.push(format!("<tool_result>{}</tool_result>", rendered));
102                        }
103                        other => {
104                            parts.push(format!("[Unsupported {}]", other));
105                        }
106                    }
107                }
108                prompt.push_str(&parts.join("\n\n"));
109            } else {
110                let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
111                prompt.push_str(content);
112            }
113        }
114
115        "latest_reminder" => {
116            let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
117            prompt.push_str(tokens::LATEST_REMINDER);
118            prompt.push_str(content);
119        }
120
121        "tool" => {
122            anyhow::bail!(
123                "deepseek_v4 merges tool messages into user; preprocess with merge_tool_messages()"
124            );
125        }
126
127        "assistant" => {
128            let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
129            let reasoning = msg
130                .get("reasoning_content")
131                .and_then(|c| c.as_str())
132                .unwrap_or("");
133            let wo_eos = msg.get("wo_eos").and_then(|v| v.as_bool()).unwrap_or(false);
134
135            let prev_has_task = index > 0
136                && messages[index - 1]
137                    .get("task")
138                    .map(|v| !v.is_null())
139                    .unwrap_or(false);
140
141            let mut thinking_part = String::new();
142            if thinking_mode == ThinkingMode::Thinking && !prev_has_task {
143                let render_thinking = !drop_thinking || last_user_idx.is_none_or(|u| index > u);
144                if render_thinking {
145                    thinking_part.push_str(reasoning);
146                    thinking_part.push_str(tokens::THINKING_END);
147                }
148            }
149
150            prompt.push_str(&thinking_part);
151            prompt.push_str(content);
152
153            if let Some(tool_calls) = msg.get("tool_calls").and_then(|t| t.as_array())
154                && !tool_calls.is_empty()
155            {
156                prompt.push_str("\n\n");
157                prompt.push_str(&format!(
158                    "<{}{}>\n",
159                    tokens::DSML_TOKEN,
160                    TOOL_CALLS_BLOCK_NAME
161                ));
162
163                let mut invocations = Vec::with_capacity(tool_calls.len());
164                for tc in tool_calls {
165                    // Accept both OpenAI-format (nested `function`) and internal
166                    // `{name, arguments}` shape, matching Python's `tool_calls_from_openai_format`.
167                    let fn_obj = tc.get("function").unwrap_or(tc);
168                    let name = fn_obj
169                        .get("name")
170                        .and_then(|n| n.as_str())
171                        .context("Missing tool call name")?;
172                    let arguments = encode_arguments_to_dsml(fn_obj)?;
173                    invocations.push(format!(
174                        "<{}invoke name=\"{}\">\n{}\n</{}invoke>",
175                        tokens::DSML_TOKEN,
176                        name,
177                        arguments,
178                        tokens::DSML_TOKEN
179                    ));
180                }
181                prompt.push_str(&invocations.join("\n"));
182                prompt.push_str(&format!(
183                    "\n</{}{}>",
184                    tokens::DSML_TOKEN,
185                    TOOL_CALLS_BLOCK_NAME
186                ));
187            }
188
189            if !wo_eos {
190                prompt.push_str(tokens::EOS);
191            }
192        }
193
194        other => anyhow::bail!("Unknown role: {}", other),
195    }
196
197    // Early return if the next message is not assistant/latest_reminder — no transition appended.
198    if index + 1 < messages.len() {
199        let next_role = messages[index + 1].get("role").and_then(|r| r.as_str());
200        if !matches!(next_role, Some("assistant") | Some("latest_reminder")) {
201            return Ok(prompt);
202        }
203    }
204
205    // Transition tokens based on task field and role.
206    let task = msg.get("task").and_then(|v| v.as_str());
207    if let Some(task) = task {
208        let sp = task_token(task).with_context(|| format!("Invalid task: '{}'", task))?;
209        if task != "action" {
210            prompt.push_str(sp);
211        } else {
212            prompt.push_str(tokens::ASSISTANT_START);
213            prompt.push_str(if thinking_mode != ThinkingMode::Thinking {
214                tokens::THINKING_END
215            } else {
216                tokens::THINKING_START
217            });
218            prompt.push_str(sp);
219        }
220    } else if matches!(role, "user" | "developer") {
221        prompt.push_str(tokens::ASSISTANT_START);
222        let seed_thinking = thinking_mode == ThinkingMode::Thinking
223            && (!drop_thinking || last_user_idx.is_none_or(|u| index >= u));
224        prompt.push_str(if seed_thinking {
225            tokens::THINKING_START
226        } else {
227            tokens::THINKING_END
228        });
229    }
230
231    Ok(prompt)
232}
233
234/// Render a tool_result `content` payload (string or content-block list).
235fn render_tool_result_content(content: &JsonValue) -> String {
236    match content {
237        JsonValue::String(s) => s.clone(),
238        JsonValue::Array(items) => {
239            let mut parts: Vec<String> = Vec::with_capacity(items.len());
240            for item in items {
241                let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
242                if item_type == "text" {
243                    parts.push(
244                        item.get("text")
245                            .and_then(|v| v.as_str())
246                            .unwrap_or("")
247                            .to_string(),
248                    );
249                } else {
250                    parts.push(format!("[Unsupported {}]", item_type));
251                }
252            }
253            parts.join("\n\n")
254        }
255        JsonValue::Null => String::new(),
256        _ => to_json(content),
257    }
258}
259
260/// Encode messages to prompt string with default options.
261///
262/// Equivalent to `encode_messages_with_options(.., drop_thinking=true, reasoning_effort=None)`.
263pub fn encode_messages(
264    messages: &[JsonValue],
265    thinking_mode: ThinkingMode,
266    add_bos_token: bool,
267) -> Result<String> {
268    encode_messages_with_options(messages, thinking_mode, add_bos_token, true, None)
269}
270
271/// Encode messages to prompt string.
272///
273/// # Arguments
274/// * `messages` - Array of messages in OpenAI format
275/// * `thinking_mode` - Chat or Thinking
276/// * `add_bos_token` - Whether to prepend BOS token
277/// * `drop_thinking` - Drop reasoning_content from earlier turns (auto-disabled if tools present)
278/// * `reasoning_effort` - Optional reasoning effort level (Max prepends a verbatim block)
279pub fn encode_messages_with_options(
280    messages: &[JsonValue],
281    thinking_mode: ThinkingMode,
282    add_bos_token: bool,
283    drop_thinking: bool,
284    reasoning_effort: Option<ReasoningEffort>,
285) -> Result<String> {
286    let merged = merge_tool_messages(messages);
287    let mut full = sort_tool_results_by_call_order(merged);
288
289    let mut prompt = String::new();
290    if add_bos_token {
291        prompt.push_str(tokens::BOS);
292    }
293
294    // Auto-disable drop_thinking when any message carries a `tools` field.
295    let has_tools = full.iter().any(|m| {
296        m.get("tools")
297            .map(|v| match v {
298                JsonValue::Array(a) => !a.is_empty(),
299                JsonValue::Null => false,
300                _ => true,
301            })
302            .unwrap_or(false)
303    });
304    let effective_drop_thinking = drop_thinking && !has_tools;
305
306    if thinking_mode == ThinkingMode::Thinking && effective_drop_thinking {
307        full = drop_thinking_messages(full);
308    }
309
310    let last_user_idx = find_last_user_index(&full);
311    for idx in 0..full.len() {
312        let part = render_message(
313            idx,
314            &full,
315            thinking_mode,
316            effective_drop_thinking,
317            reasoning_effort,
318            last_user_idx,
319        )?;
320        prompt.push_str(&part);
321    }
322
323    Ok(prompt)
324}
325
326/// DeepSeek V4 Prompt Formatter
327#[derive(Debug)]
328pub struct DeepSeekV4Formatter {
329    thinking_mode: ThinkingMode,
330}
331
332impl DeepSeekV4Formatter {
333    pub fn new(thinking_mode: ThinkingMode) -> Self {
334        Self { thinking_mode }
335    }
336
337    /// Create formatter with thinking mode enabled (default for DSV4)
338    pub fn new_thinking() -> Self {
339        Self::new(ThinkingMode::Thinking)
340    }
341
342    /// Create formatter with chat mode
343    pub fn new_chat() -> Self {
344        Self::new(ThinkingMode::Chat)
345    }
346
347    fn resolve_reasoning_effort(
348        args: Option<&std::collections::HashMap<String, serde_json::Value>>,
349    ) -> Option<ReasoningEffort> {
350        let args = args?;
351        let v = args.get("reasoning_effort")?;
352        match v.as_str() {
353            Some("max") | Some("xhigh") => Some(ReasoningEffort::Max),
354            // DeepSeek V4 only natively distinguishes none/high/max, so the
355            // OpenAI-style intermediate levels map to "high" — the model still
356            // reasons, it just doesn't get the max-effort preamble.
357            Some("high") | Some("minimal") | Some("low") | Some("medium") => {
358                Some(ReasoningEffort::High)
359            }
360            Some("none") => None,
361            _ => {
362                tracing::warn!(
363                    value = ?v,
364                    "chat_template_args.reasoning_effort must be one of \"none\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", \"max\"; ignoring and using default (none)"
365                );
366                None
367            }
368        }
369    }
370
371    fn resolve_drop_thinking(
372        args: Option<&std::collections::HashMap<String, serde_json::Value>>,
373    ) -> bool {
374        let Some(args) = args else { return true };
375        let Some(v) = args.get("drop_thinking") else {
376            return true;
377        };
378        if let Some(b) = v.as_bool() {
379            return b;
380        }
381        tracing::warn!(
382            value = ?v,
383            "chat_template_args.drop_thinking must be a bool; ignoring and using default (true)"
384        );
385        true
386    }
387}
388
389impl crate::OAIPromptFormatter for DeepSeekV4Formatter {
390    fn supports_add_generation_prompt(&self) -> bool {
391        true
392    }
393
394    fn render(&self, req: &dyn crate::OAIChatLikeRequest) -> Result<String> {
395        let args = req.chat_template_args();
396        let thinking_mode = super::common::resolve_thinking_mode(args, self.thinking_mode);
397        let reasoning_effort = Self::resolve_reasoning_effort(args);
398        let drop_thinking = Self::resolve_drop_thinking(args);
399
400        let messages_value = req.messages();
401        let messages_json =
402            serde_json::to_value(&messages_value).context("Failed to convert messages to JSON")?;
403
404        let mut messages_array = messages_json
405            .as_array()
406            .context("Messages is not an array")?
407            .clone();
408
409        normalize_message_contents(&mut messages_array, NormalizeNonText::LeaveUntouched);
410
411        super::common::inject_tools_and_response_format(&mut messages_array, req)?;
412
413        encode_messages_with_options(
414            &messages_array,
415            thinking_mode,
416            true,
417            drop_thinking,
418            reasoning_effort,
419        )
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use serde_json::json;
427
428    #[test]
429    fn test_simple_conversation() {
430        let messages = json!([
431            {"role": "system", "content": "You are a helpful assistant."},
432            {"role": "user", "content": "Hello"},
433            {"role": "assistant", "reasoning_content": "greet", "content": "Hi!"},
434            {"role": "user", "content": "What is 2+2?"}
435        ]);
436        let out =
437            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
438        assert!(out.starts_with(tokens::BOS));
439        assert!(out.ends_with(&format!(
440            "{}{}",
441            tokens::ASSISTANT_START,
442            tokens::THINKING_START
443        )));
444        // drop_thinking default true → earlier reasoning stripped
445        assert!(!out.contains("greet"));
446    }
447
448    #[test]
449    fn test_reasoning_effort_max_prefix() {
450        let messages = json!([
451            {"role": "system", "content": "hi"},
452            {"role": "user", "content": "hello"}
453        ]);
454        let out = encode_messages_with_options(
455            messages.as_array().unwrap(),
456            ThinkingMode::Thinking,
457            true,
458            true,
459            Some(ReasoningEffort::Max),
460        )
461        .unwrap();
462        assert!(out.contains("Reasoning Effort: Absolute maximum"));
463        // Prefix comes between BOS and system content.
464        let after_bos = &out[tokens::BOS.len()..];
465        assert!(after_bos.starts_with("Reasoning Effort:"));
466
467        // High and None do not emit the prefix.
468        let out2 = encode_messages_with_options(
469            messages.as_array().unwrap(),
470            ThinkingMode::Thinking,
471            true,
472            true,
473            Some(ReasoningEffort::High),
474        )
475        .unwrap();
476        assert!(!out2.contains("Reasoning Effort: Absolute maximum"));
477    }
478
479    #[test]
480    fn test_content_blocks_with_tool_result() {
481        // `merge_tool_messages` turns a `tool` role followed by a plain user text
482        // into a single user turn whose `content_blocks` interleave the tool result
483        // with the text, joined by "\n\n" at render time. Users don't construct
484        // `content_blocks` directly — both the Python reference and this port
485        // overwrite any user-supplied `content_blocks` with a single text block.
486        let messages = json!([
487            {"role": "user", "content": "call tool"},
488            {"role": "assistant", "content": "", "tool_calls": [{
489                "id": "c1", "type": "function",
490                "function": {"name": "f", "arguments": "{}"}
491            }]},
492            {"role": "tool", "tool_call_id": "c1", "content": "RESULT"},
493            {"role": "user", "content": "thanks"}
494        ]);
495        let out = encode_messages(messages.as_array().unwrap(), ThinkingMode::Chat, true).unwrap();
496        assert!(
497            out.contains("<tool_result>RESULT</tool_result>\n\nthanks"),
498            "expected tool_result block followed by 'thanks' in the merged user turn, got:\n{}",
499            out
500        );
501    }
502
503    #[test]
504    fn test_user_task_preserved_when_merged_after_tool_result() {
505        let messages = json!([
506            {"role": "assistant", "content": "", "tool_calls": [{
507                "id": "c1", "type": "function",
508                "function": {"name": "search", "arguments": "{}"}
509            }]},
510            {"role": "tool", "tool_call_id": "c1", "content": "RESULT"},
511            {"role": "user", "content": "Search", "task": "action"},
512            {"role": "assistant", "content": "OK"}
513        ]);
514
515        let out = encode_messages(messages.as_array().unwrap(), ThinkingMode::Chat, true).unwrap();
516        assert!(
517            out.contains(&format!(
518                "{}Search{}{}{}OK",
519                "<tool_result>RESULT</tool_result>\n\n",
520                tokens::ASSISTANT_START,
521                tokens::THINKING_END,
522                tokens::TASK_ACTION
523            )),
524            "expected merged user text to keep the action task transition, got:\n{}",
525            out
526        );
527    }
528
529    #[test]
530    fn test_drop_thinking_auto_disable_when_tools_present() {
531        let messages = json!([
532            {"role": "system", "content": "s", "tools": [{
533                "type": "function",
534                "function": {"name": "f", "description": "", "parameters": {"type": "object", "properties": {}}}
535            }]},
536            {"role": "user", "content": "hi"},
537            {"role": "assistant", "reasoning_content": "PRIOR_REASONING", "content": "reply"},
538            {"role": "user", "content": "again"}
539        ]);
540        let out =
541            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
542        // Tools present → drop_thinking auto-disabled → earlier reasoning preserved.
543        assert!(out.contains("PRIOR_REASONING"));
544    }
545
546    // ---- Regression tests for known divergences from the Python reference ----
547
548    /// Bug: `last_user_idx = None` (no user/developer in history) should behave
549    /// like Python's `-1` sentinel — `index >= -1` / `idx >= -1` always true, so
550    /// earlier reasoning is preserved and the assistant's reasoning block is
551    /// rendered. Rust defaulting `None` to `usize::MAX` / `is_some_and` silently
552    /// stripped reasoning instead.
553    ///
554    /// Byte-equivalent to Python reference with the same input:
555    /// `<BOS>sysREASONING_BLOCK</think>hello<EOS>`
556    #[test]
557    fn test_assistant_reasoning_preserved_when_no_user_in_history() {
558        let messages = json!([
559            {"role": "system", "content": "sys"},
560            {"role": "assistant", "content": "hello", "reasoning_content": "REASONING_BLOCK"}
561        ]);
562        let out =
563            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
564        assert_eq!(
565            out, "<|begin▁of▁sentence|>sysREASONING_BLOCK</think>hello<|end▁of▁sentence|>",
566            "Output must match Python reference byte-for-byte when no user/developer in history"
567        );
568    }
569
570    /// Bug: `to_json` tracks in-string state via `prev_char != '\\'` which
571    /// mis-handles consecutive backslashes. A value containing `\\` (one literal
572    /// backslash in JSON) makes the helper think the closing `"` is escaped,
573    /// so it stops inserting Python-compatible spaces after subsequent `:`/`,`.
574    ///
575    /// Python `json.dumps({"path": "\\", "count": 5}, ensure_ascii=False)`
576    /// emits `{"path": "\\", "count": 5}` — space after every `:` and `,`.
577    #[test]
578    fn test_to_json_preserves_spacing_past_escaped_backslash() {
579        let v = json!({"path": "\\", "count": 5});
580        let got = to_json(&v);
581        assert_eq!(
582            got, r#"{"path": "\\", "count": 5}"#,
583            "to_json must match Python's json.dumps formatting past an escaped backslash"
584        );
585    }
586
587    #[test]
588    fn test_resolve_drop_thinking_warns_on_malformed_value() {
589        use std::collections::HashMap;
590        // String "false" where a bool is expected → fall back to default (true) and warn.
591        let mut args = HashMap::new();
592        args.insert(
593            "drop_thinking".to_string(),
594            serde_json::Value::String("false".to_string()),
595        );
596        assert!(DeepSeekV4Formatter::resolve_drop_thinking(Some(&args)));
597        // Malformed reasoning_effort falls back to None.
598        let mut args2 = HashMap::new();
599        args2.insert(
600            "reasoning_effort".to_string(),
601            serde_json::Value::String("HIGH".to_string()),
602        );
603        assert_eq!(
604            DeepSeekV4Formatter::resolve_reasoning_effort(Some(&args2)),
605            None
606        );
607    }
608
609    #[test]
610    fn test_resolve_thinking_mode_honors_enable_thinking() {
611        use std::collections::HashMap;
612        let mut args = HashMap::new();
613        args.insert(
614            "enable_thinking".to_string(),
615            serde_json::Value::Bool(false),
616        );
617        assert_eq!(
618            super::super::common::resolve_thinking_mode(Some(&args), ThinkingMode::Thinking),
619            ThinkingMode::Chat
620        );
621        args.insert("enable_thinking".to_string(), serde_json::Value::Bool(true));
622        assert_eq!(
623            super::super::common::resolve_thinking_mode(Some(&args), ThinkingMode::Thinking),
624            ThinkingMode::Thinking
625        );
626    }
627
628    struct MockRequest {
629        messages: JsonValue,
630        chat_template_args: Option<std::collections::HashMap<String, JsonValue>>,
631        tools: Option<JsonValue>,
632        tool_choice: Option<JsonValue>,
633        response_format: Option<JsonValue>,
634    }
635
636    impl MockRequest {
637        fn new(messages: JsonValue) -> Self {
638            Self {
639                messages,
640                chat_template_args: None,
641                tools: None,
642                tool_choice: None,
643                response_format: None,
644            }
645        }
646
647        fn with_chat_template_args(
648            mut self,
649            args: std::collections::HashMap<String, JsonValue>,
650        ) -> Self {
651            self.chat_template_args = Some(args);
652            self
653        }
654
655        fn with_tools(mut self, tools: JsonValue) -> Self {
656            self.tools = Some(tools);
657            self
658        }
659
660        fn with_tool_choice(mut self, tool_choice: JsonValue) -> Self {
661            self.tool_choice = Some(tool_choice);
662            self
663        }
664
665        fn with_response_format(mut self, response_format: JsonValue) -> Self {
666            self.response_format = Some(response_format);
667            self
668        }
669    }
670
671    impl crate::OAIChatLikeRequest for MockRequest {
672        fn model(&self) -> String {
673            "deepseek-v4".to_string()
674        }
675
676        fn messages(&self) -> minijinja::value::Value {
677            minijinja::value::Value::from_serialize(&self.messages)
678        }
679
680        fn should_add_generation_prompt(&self) -> bool {
681            true
682        }
683
684        fn chat_template_args(
685            &self,
686        ) -> Option<&std::collections::HashMap<String, serde_json::Value>> {
687            self.chat_template_args.as_ref()
688        }
689
690        fn tools(&self) -> Option<minijinja::value::Value> {
691            self.tools
692                .as_ref()
693                .map(minijinja::value::Value::from_serialize)
694        }
695
696        fn tool_choice(&self) -> Option<minijinja::value::Value> {
697            self.tool_choice
698                .as_ref()
699                .map(minijinja::value::Value::from_serialize)
700        }
701
702        fn response_format(&self) -> Option<minijinja::value::Value> {
703            self.response_format
704                .as_ref()
705                .map(minijinja::value::Value::from_serialize)
706        }
707    }
708
709    fn weather_tool() -> JsonValue {
710        json!([{
711            "type": "function",
712            "function": {
713                "name": "get_current_weather",
714                "description": "Get the current weather in a given location",
715                "parameters": {
716                    "type": "object",
717                    "properties": {"location": {"type": "string"}},
718                    "required": ["location"]
719                }
720            }
721        }])
722    }
723
724    #[test]
725    fn test_render_tool_choice_none_strips_tools_keeps_response_format() {
726        use crate::OAIPromptFormatter;
727
728        let req = MockRequest::new(json!([
729            {"role": "system", "content": "sys"},
730            {"role": "user", "content": "weather in Boston?"}
731        ]))
732        .with_tools(weather_tool())
733        .with_tool_choice(json!("none"))
734        .with_response_format(json!({"type": "json_object"}));
735
736        let formatter = DeepSeekV4Formatter::new_chat();
737        let out = formatter.render(&req).unwrap();
738
739        assert!(
740            !out.contains("## Tools"),
741            "tool_choice=none must strip the tools block, got: {out}"
742        );
743        assert!(
744            !out.contains("get_current_weather"),
745            "tool schema leaked into prompt despite tool_choice=none: {out}"
746        );
747        assert!(
748            out.contains("## Response Format"),
749            "response_format must survive tool_choice=none: {out}"
750        );
751    }
752
753    #[test]
754    fn test_render_tool_choice_auto_keeps_tools() {
755        use crate::OAIPromptFormatter;
756
757        let req = MockRequest::new(json!([
758            {"role": "system", "content": "sys"},
759            {"role": "user", "content": "weather in Boston?"}
760        ]))
761        .with_tools(weather_tool())
762        .with_tool_choice(json!("auto"));
763
764        let formatter = DeepSeekV4Formatter::new_chat();
765        let out = formatter.render(&req).unwrap();
766
767        assert!(out.contains("## Tools"));
768        assert!(out.contains("get_current_weather"));
769    }
770
771    #[test]
772    fn test_render_absent_tool_choice_keeps_tools() {
773        use crate::OAIPromptFormatter;
774
775        let req = MockRequest::new(json!([
776            {"role": "system", "content": "sys"},
777            {"role": "user", "content": "weather in Boston?"}
778        ]))
779        .with_tools(weather_tool());
780
781        let formatter = DeepSeekV4Formatter::new_chat();
782        let out = formatter.render(&req).unwrap();
783
784        assert!(out.contains("## Tools"));
785        assert!(out.contains("get_current_weather"));
786    }
787
788    #[test]
789    fn test_resolve_reasoning_effort_accepts_full_range() {
790        use std::collections::HashMap;
791
792        let effort = |v: &str| {
793            let mut args = HashMap::new();
794            args.insert("reasoning_effort".to_string(), json!(v));
795            DeepSeekV4Formatter::resolve_reasoning_effort(Some(&args))
796        };
797
798        assert_eq!(effort("max"), Some(ReasoningEffort::Max));
799        assert_eq!(effort("xhigh"), Some(ReasoningEffort::Max));
800        assert_eq!(effort("high"), Some(ReasoningEffort::High));
801        assert_eq!(effort("minimal"), Some(ReasoningEffort::High));
802        assert_eq!(effort("low"), Some(ReasoningEffort::High));
803        assert_eq!(effort("medium"), Some(ReasoningEffort::High));
804        assert_eq!(effort("none"), None);
805        assert_eq!(effort("bogus"), None);
806    }
807
808    #[test]
809    fn test_render_leaves_null_assistant_tool_content_empty() {
810        use crate::OAIPromptFormatter;
811
812        let req = MockRequest::new(json!([
813            {"role": "user", "content": "call tool"},
814            {"role": "assistant", "content": null, "tool_calls": [{
815                "id": "c1", "type": "function",
816                "function": {"name": "f", "arguments": "{}"}
817            }]}
818        ]));
819
820        let formatter = DeepSeekV4Formatter::new_chat();
821        let out = formatter.render(&req).unwrap();
822
823        assert!(out.contains(&format!(
824            "<{}{}>",
825            tokens::DSML_TOKEN,
826            TOOL_CALLS_BLOCK_NAME
827        )));
828        assert!(!out.contains("null"));
829    }
830
831    #[test]
832    fn test_render_wires_reasoning_effort_max_from_chat_template_args() {
833        use crate::OAIPromptFormatter;
834        use std::collections::HashMap;
835
836        let mut args = HashMap::new();
837        args.insert("reasoning_effort".to_string(), json!("max"));
838
839        let req = MockRequest::new(json!([
840            {"role": "system", "content": "sys"},
841            {"role": "user", "content": "hi"}
842        ]))
843        .with_chat_template_args(args);
844
845        let formatter = DeepSeekV4Formatter::new_thinking();
846        let out = formatter.render(&req).unwrap();
847
848        assert!(out.starts_with(tokens::BOS));
849        let after_bos = &out[tokens::BOS.len()..];
850        assert!(
851            after_bos.starts_with("Reasoning Effort:"),
852            "REASONING_EFFORT_MAX preamble should appear at start (after BOS), got:\n{}",
853            out
854        );
855    }
856
857    #[test]
858    fn test_render_drop_thinking_override_from_chat_template_args() {
859        use crate::OAIPromptFormatter;
860        use std::collections::HashMap;
861
862        let messages = json!([
863            {"role": "user", "content": "first"},
864            {"role": "assistant", "reasoning_content": "PRIOR", "content": "reply"},
865            {"role": "user", "content": "again"}
866        ]);
867
868        // Default (drop_thinking=true): prior reasoning stripped.
869        let req_default = MockRequest::new(messages.clone());
870        let formatter = DeepSeekV4Formatter::new_thinking();
871        let out_default = formatter.render(&req_default).unwrap();
872        assert!(
873            !out_default.contains("PRIOR"),
874            "default drop_thinking=true should strip prior reasoning, got:\n{}",
875            out_default
876        );
877
878        // drop_thinking=false override: prior reasoning survives.
879        let mut args = HashMap::new();
880        args.insert("drop_thinking".to_string(), json!(false));
881        let req_keep = MockRequest::new(messages).with_chat_template_args(args);
882        let out_keep = formatter.render(&req_keep).unwrap();
883        assert!(
884            out_keep.contains("PRIOR"),
885            "drop_thinking=false override should preserve prior reasoning, got:\n{}",
886            out_keep
887        );
888    }
889
890    // N4: developer-role interactions with drop_thinking.
891    // find_last_user_index returns the index of user OR developer messages; the
892    // drop_thinking reasoning cutoff and the thinking-seed insertion treat
893    // user and developer identically.
894
895    #[test]
896    fn test_developer_only_conversation_renders_developer_content() {
897        let messages = json!([
898            {"role": "system", "content": "sys"},
899            {"role": "developer", "content": "x"},
900            {"role": "assistant", "reasoning_content": "R", "content": "ok"}
901        ]);
902        let out =
903            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
904        assert!(
905            out.contains("x"),
906            "developer content should appear in output, got:\n{}",
907            out
908        );
909    }
910
911    #[test]
912    fn test_developer_as_last_user_index_controls_reasoning_cutoff() {
913        // Indices: 0=user, 1=assistant(FIRST), 2=developer(y), 3=assistant(SECOND).
914        // find_last_user_index = 2 (developer). With drop_thinking=true:
915        //   - assistant idx=1 < 2  → reasoning_content stripped.
916        //   - assistant idx=3 >= 2 → reasoning_content preserved.
917        let messages = json!([
918            {"role": "user", "content": "a"},
919            {"role": "assistant", "reasoning_content": "FIRST", "content": "r1"},
920            {"role": "developer", "content": "y"},
921            {"role": "assistant", "reasoning_content": "SECOND", "content": "r2"}
922        ]);
923        let out =
924            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
925        assert!(
926            !out.contains("FIRST"),
927            "reasoning before last user/developer (idx 1 < 2) should be stripped, got:\n{}",
928            out
929        );
930        assert!(
931            out.contains("SECOND"),
932            "reasoning at/after last user/developer (idx 3 > 2) should survive, got:\n{}",
933            out
934        );
935    }
936}