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;
12use std::fmt::Write;
13
14use super::common::{
15    NormalizeNonText, REASONING_EFFORT_HIGH, REASONING_EFFORT_MAX, RESPONSE_FORMAT_TEMPLATE,
16    TOOL_CALLS_BLOCK_NAME, TOOLS_TEMPLATE, drop_thinking_messages, encode_arguments_to_dsml,
17    find_last_user_index, merge_tool_messages, normalize_message_contents, render_tools,
18    sort_tool_results_by_call_order, task_token, to_json,
19};
20pub use super::common::{ReasoningEffort, ThinkingMode, tokens};
21
22#[derive(Clone, Copy)]
23pub(super) enum Encoding {
24    V4(Option<ReasoningEffort>),
25    V41(u8),
26}
27
28impl Encoding {
29    fn is_v41(self) -> bool {
30        matches!(self, Self::V41(_))
31    }
32
33    fn tag(self, v4: &'static str, v41: &'static str) -> &'static str {
34        if self.is_v41() { v41 } else { v4 }
35    }
36
37    fn reasoning_prefix(self) -> String {
38        match self {
39            Self::V4(Some(ReasoningEffort::High)) => REASONING_EFFORT_HIGH.to_string(),
40            Self::V4(Some(ReasoningEffort::Max)) => REASONING_EFFORT_MAX.to_string(),
41            Self::V4(None) => String::new(),
42            Self::V41(effort) => format!(
43                "Reasoning Effort: {effort} (range 1-100, the higher the value, the more thorough the reasoning)\n\n"
44            ),
45        }
46    }
47
48    fn render_tools(self, tools: &[JsonValue]) -> String {
49        let template = if self.is_v41() {
50            TOOLS_TEMPLATE
51                .replace("{dsml_token}tool_calls", "{dsml_token} calls")
52                .replace("{dsml_token}invoke", "{dsml_token} invoke")
53                .replace("{dsml_token}parameter", "{dsml_token} parameter")
54        } else {
55            TOOLS_TEMPLATE.to_string()
56        };
57        render_tools(&template, tools)
58    }
59}
60
61/// Render a single message at the given index.
62fn render_message(
63    prompt: &mut String,
64    index: usize,
65    messages: &[JsonValue],
66    thinking_mode: ThinkingMode,
67    drop_thinking: bool,
68    encoding: Encoding,
69    last_user_idx: Option<usize>,
70) -> Result<()> {
71    let msg = &messages[index];
72
73    let role = msg
74        .get("role")
75        .and_then(|r| r.as_str())
76        .context("Missing 'role' field")?;
77
78    if encoding.is_v41()
79        && (role == "system" || (index == 0 && thinking_mode == ThinkingMode::Thinking))
80    {
81        prompt.push_str("<|System|>");
82    }
83    if index == 0 && thinking_mode == ThinkingMode::Thinking {
84        prompt.push_str(&encoding.reasoning_prefix());
85    }
86
87    match role {
88        "system" => {
89            let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
90            prompt.push_str(content);
91            // Like the reference's `if tools:`, an empty list renders no tools section.
92            if let Some(tools) = msg
93                .get("tools")
94                .and_then(|t| t.as_array())
95                .filter(|t| !t.is_empty())
96            {
97                prompt.push_str("\n\n");
98                prompt.push_str(&encoding.render_tools(tools));
99            }
100            if let Some(response_format) = msg.get("response_format") {
101                prompt.push_str("\n\n");
102                prompt.push_str(
103                    &RESPONSE_FORMAT_TEMPLATE.replace("{schema}", &to_json(response_format)),
104                );
105            }
106        }
107
108        "developer" => {
109            let content = msg
110                .get("content")
111                .and_then(|c| c.as_str())
112                .filter(|s| !s.is_empty())
113                .context("Developer role requires content")?;
114
115            prompt.push_str(tokens::USER_START);
116            prompt.push_str(content);
117
118            if let Some(tools) = msg
119                .get("tools")
120                .and_then(|t| t.as_array())
121                .filter(|t| !t.is_empty())
122            {
123                prompt.push_str("\n\n");
124                prompt.push_str(&encoding.render_tools(tools));
125            }
126            if let Some(response_format) = msg.get("response_format") {
127                prompt.push_str("\n\n");
128                prompt.push_str(
129                    &RESPONSE_FORMAT_TEMPLATE.replace("{schema}", &to_json(response_format)),
130                );
131            }
132        }
133
134        "user" => {
135            prompt.push_str(tokens::USER_START);
136            if let Some(blocks) = msg.get("content_blocks").and_then(|b| b.as_array()) {
137                for (block_idx, block) in blocks.iter().enumerate() {
138                    if block_idx > 0 {
139                        prompt.push_str("\n\n");
140                    }
141                    let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
142                    match block_type {
143                        "text" => {
144                            let text = block.get("text").and_then(|v| v.as_str()).unwrap_or("");
145                            prompt.push_str(text);
146                        }
147                        "tool_result" => {
148                            prompt.push_str("<tool_result>");
149                            render_tool_result_content(
150                                prompt,
151                                block.get("content").unwrap_or(&JsonValue::Null),
152                            )?;
153                            prompt.push_str("</tool_result>");
154                        }
155                        other => {
156                            write!(prompt, "[Unsupported {}]", other)?;
157                        }
158                    }
159                }
160            }
161        }
162
163        "latest_reminder" => {
164            let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
165            prompt.push_str(tokens::LATEST_REMINDER);
166            prompt.push_str(content);
167        }
168
169        "tool" => {
170            anyhow::bail!(
171                "deepseek_v4 merges tool messages into user; preprocess with merge_tool_messages()"
172            );
173        }
174
175        "assistant" => {
176            let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
177            let reasoning = msg
178                .get("reasoning_content")
179                .and_then(|c| c.as_str())
180                .unwrap_or("");
181            let wo_eos = msg.get("wo_eos").and_then(|v| v.as_bool()).unwrap_or(false);
182
183            let prev_has_task = index > 0
184                && messages[index - 1]
185                    .get("task")
186                    .map(|v| !v.is_null())
187                    .unwrap_or(false);
188
189            if thinking_mode == ThinkingMode::Thinking && !prev_has_task {
190                let render_thinking = !drop_thinking || last_user_idx.is_none_or(|u| index > u);
191                if render_thinking {
192                    prompt.push_str(reasoning);
193                    prompt.push_str(tokens::THINKING_END);
194                }
195            }
196
197            prompt.push_str(content);
198
199            if let Some(tool_calls) = msg.get("tool_calls").and_then(|t| t.as_array())
200                && !tool_calls.is_empty()
201            {
202                prompt.push_str("\n\n");
203                writeln!(
204                    prompt,
205                    "<{}{}>",
206                    tokens::DSML_TOKEN,
207                    encoding.tag(TOOL_CALLS_BLOCK_NAME, " calls")
208                )?;
209
210                for (call_idx, tc) in tool_calls.iter().enumerate() {
211                    if call_idx > 0 {
212                        prompt.push('\n');
213                    }
214                    // Accept both OpenAI-format (nested `function`) and internal
215                    // `{name, arguments}` shape, matching Python's `tool_calls_from_openai_format`.
216                    let fn_obj = tc.get("function").unwrap_or(tc);
217                    let name = fn_obj
218                        .get("name")
219                        .and_then(|n| n.as_str())
220                        .context("Missing tool call name")?;
221                    let arguments = if encoding.is_v41() {
222                        super::v41::encode_arguments(fn_obj)?
223                    } else {
224                        encode_arguments_to_dsml(fn_obj)?
225                    };
226                    write!(
227                        prompt,
228                        "<{}{} name=\"{}\">\n{}\n</{}{}>",
229                        tokens::DSML_TOKEN,
230                        encoding.tag("invoke", " invoke"),
231                        name,
232                        arguments,
233                        tokens::DSML_TOKEN,
234                        encoding.tag("invoke", " invoke")
235                    )?;
236                }
237                write!(
238                    prompt,
239                    "\n</{}{}>",
240                    tokens::DSML_TOKEN,
241                    encoding.tag(TOOL_CALLS_BLOCK_NAME, " calls")
242                )?;
243            }
244
245            if !wo_eos {
246                prompt.push_str(tokens::EOS);
247            }
248        }
249
250        other => anyhow::bail!("Unknown role: {}", other),
251    }
252
253    // Early return if the next message is not assistant/latest_reminder — no transition appended.
254    if index + 1 < messages.len() {
255        let next_role = messages[index + 1].get("role").and_then(|r| r.as_str());
256        if !matches!(next_role, Some("assistant") | Some("latest_reminder")) {
257            return Ok(());
258        }
259    }
260
261    // Transition tokens based on task field and role.
262    let task = msg.get("task").and_then(|v| v.as_str());
263    if let Some(task) = task {
264        let sp = task_token(task).with_context(|| format!("Invalid task: '{}'", task))?;
265        if task != "action" {
266            prompt.push_str(sp);
267        } else {
268            prompt.push_str(tokens::ASSISTANT_START);
269            prompt.push_str(if thinking_mode != ThinkingMode::Thinking {
270                tokens::THINKING_END
271            } else {
272                tokens::THINKING_START
273            });
274            prompt.push_str(sp);
275        }
276    } else if matches!(role, "user" | "developer")
277        || (encoding.is_v41() && role == "system" && index > 0)
278    {
279        prompt.push_str(tokens::ASSISTANT_START);
280        let seed_thinking = thinking_mode == ThinkingMode::Thinking
281            && (!drop_thinking || last_user_idx.is_none_or(|u| index >= u));
282        prompt.push_str(if seed_thinking {
283            tokens::THINKING_START
284        } else {
285            tokens::THINKING_END
286        });
287    }
288
289    Ok(())
290}
291
292/// Render a tool_result `content` payload (string or content-block list).
293fn render_tool_result_content(prompt: &mut String, content: &JsonValue) -> Result<()> {
294    match content {
295        JsonValue::String(s) => prompt.push_str(s),
296        JsonValue::Array(items) => {
297            for (index, item) in items.iter().enumerate() {
298                if index > 0 {
299                    prompt.push_str("\n\n");
300                }
301                let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
302                if item_type == "text" {
303                    prompt.push_str(item.get("text").and_then(|v| v.as_str()).unwrap_or(""));
304                } else {
305                    write!(prompt, "[Unsupported {}]", item_type)?;
306                }
307            }
308        }
309        JsonValue::Null => {}
310        _ => prompt.push_str(&to_json(content)),
311    }
312    Ok(())
313}
314
315/// Encode messages to prompt string with default options.
316///
317/// Equivalent to `encode_messages_with_options(.., drop_thinking=true, reasoning_effort=None)`.
318pub fn encode_messages(
319    messages: &[JsonValue],
320    thinking_mode: ThinkingMode,
321    add_bos_token: bool,
322) -> Result<String> {
323    encode_messages_with_options(messages, thinking_mode, add_bos_token, true, None)
324}
325
326/// Encode messages to prompt string.
327///
328/// # Arguments
329/// * `messages` - Array of messages in OpenAI format
330/// * `thinking_mode` - Chat or Thinking
331/// * `add_bos_token` - Whether to prepend BOS token
332/// * `drop_thinking` - Drop reasoning_content from earlier turns (auto-disabled if tools present)
333/// * `reasoning_effort` - Optional reasoning effort level (High and Max prepend distinct verbatim blocks)
334pub fn encode_messages_with_options(
335    messages: &[JsonValue],
336    thinking_mode: ThinkingMode,
337    add_bos_token: bool,
338    drop_thinking: bool,
339    reasoning_effort: Option<ReasoningEffort>,
340) -> Result<String> {
341    encode_owned_messages(
342        messages.to_vec(),
343        thinking_mode,
344        add_bos_token,
345        drop_thinking,
346        Encoding::V4(reasoning_effort),
347    )
348}
349
350pub(super) fn encode_owned_messages(
351    messages: Vec<JsonValue>,
352    thinking_mode: ThinkingMode,
353    add_bos_token: bool,
354    drop_thinking: bool,
355    encoding: Encoding,
356) -> Result<String> {
357    let merged = merge_tool_messages(messages);
358    // V4.1 orders source messages before merging, using the same routine as its media hook.
359    let mut full = if encoding.is_v41() {
360        merged
361    } else {
362        sort_tool_results_by_call_order(merged)
363    };
364
365    let mut prompt = String::new();
366    if add_bos_token {
367        prompt.push_str(tokens::BOS);
368    }
369
370    // Auto-disable drop_thinking when any message carries a `tools` field.
371    let has_tools = full.iter().any(|m| {
372        m.get("tools")
373            .map(|v| match v {
374                JsonValue::Array(a) => !a.is_empty(),
375                JsonValue::Null => false,
376                _ => true,
377            })
378            .unwrap_or(false)
379    });
380    let effective_drop_thinking = drop_thinking && !has_tools;
381
382    if thinking_mode == ThinkingMode::Thinking && effective_drop_thinking {
383        full = if encoding.is_v41() {
384            super::v41::drop_thinking_messages(full)
385        } else {
386            drop_thinking_messages(full)
387        };
388    }
389
390    let last_user_idx = if encoding.is_v41() {
391        super::v41::find_last_user_index(&full)
392    } else {
393        find_last_user_index(&full)
394    };
395    for idx in 0..full.len() {
396        render_message(
397            &mut prompt,
398            idx,
399            &full,
400            thinking_mode,
401            effective_drop_thinking,
402            encoding,
403            last_user_idx,
404        )?;
405    }
406
407    Ok(prompt)
408}
409
410/// DeepSeek V4 Prompt Formatter
411#[derive(Debug)]
412pub struct DeepSeekV4Formatter {
413    thinking_mode: ThinkingMode,
414}
415
416impl DeepSeekV4Formatter {
417    pub fn new(thinking_mode: ThinkingMode) -> Self {
418        Self { thinking_mode }
419    }
420
421    /// Create formatter with thinking mode enabled (default for DSV4)
422    pub fn new_thinking() -> Self {
423        Self::new(ThinkingMode::Thinking)
424    }
425
426    /// Create formatter with chat mode
427    pub fn new_chat() -> Self {
428        Self::new(ThinkingMode::Chat)
429    }
430
431    fn resolve_reasoning_effort(v: Option<&JsonValue>) -> (bool, Option<ReasoningEffort>) {
432        match v.and_then(JsonValue::as_str) {
433            Some("none") => (true, None),
434            Some("max") => (false, Some(ReasoningEffort::Max)),
435            Some("high") | Some("medium") | Some("xhigh") => (false, Some(ReasoningEffort::High)),
436            Some("low") | Some("minimal") => (false, None),
437            None if v.is_none() => (false, Some(ReasoningEffort::High)),
438            _ => {
439                tracing::warn!(
440                    value = ?v,
441                    "reasoning_effort must be one of \"none\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", \"max\"; ignoring and using API default (high)"
442                );
443                (false, Some(ReasoningEffort::High))
444            }
445        }
446    }
447
448    fn resolve_drop_thinking(
449        args: Option<&std::collections::HashMap<String, serde_json::Value>>,
450    ) -> bool {
451        let Some(args) = args else { return true };
452        let Some(v) = args.get("drop_thinking") else {
453            return true;
454        };
455        if let Some(b) = v.as_bool() {
456            return b;
457        }
458        tracing::warn!(
459            value = ?v,
460            "chat_template_args.drop_thinking must be a bool; ignoring and using default (true)"
461        );
462        true
463    }
464}
465
466impl crate::OAIPromptFormatter for DeepSeekV4Formatter {
467    fn supports_add_generation_prompt(&self) -> bool {
468        true
469    }
470
471    fn render(&self, req: &dyn crate::OAIChatLikeRequest) -> Result<String> {
472        let args = req.chat_template_args();
473        let effort_value = req
474            .reasoning_effort()
475            .map(|value| serde_json::to_value(value).context("serialize reasoning_effort"))
476            .transpose()?
477            .or_else(|| args.and_then(|args| args.get("reasoning_effort").cloned()));
478        let (disable_thinking, reasoning_effort) =
479            Self::resolve_reasoning_effort(effort_value.as_ref());
480        let mut thinking_mode = super::common::resolve_thinking_mode(args, self.thinking_mode);
481        if disable_thinking {
482            thinking_mode = ThinkingMode::Chat;
483        }
484        let drop_thinking = Self::resolve_drop_thinking(args);
485
486        let messages_json = crate::messages_to_json(req)?;
487        crate::reject_unsupported_partial_assistant(&messages_json)?;
488        crate::reject_unsupported_message_tools(&messages_json, &["developer"])?;
489
490        let JsonValue::Array(mut messages_array) = messages_json else {
491            anyhow::bail!("Messages is not an array");
492        };
493
494        normalize_message_contents(&mut messages_array, NormalizeNonText::LeaveUntouched);
495
496        super::common::inject_tools_and_response_format(&mut messages_array, req)?;
497
498        encode_owned_messages(
499            messages_array,
500            thinking_mode,
501            true,
502            drop_thinking,
503            Encoding::V4(reasoning_effort),
504        )
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use serde_json::json;
512
513    #[test]
514    fn test_simple_conversation() {
515        let messages = json!([
516            {"role": "system", "content": "You are a helpful assistant."},
517            {"role": "user", "content": "Hello"},
518            {"role": "assistant", "reasoning_content": "greet", "content": "Hi!"},
519            {"role": "user", "content": "What is 2+2?"}
520        ]);
521        let out =
522            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
523        assert!(out.starts_with(tokens::BOS));
524        assert!(out.ends_with(&format!(
525            "{}{}",
526            tokens::ASSISTANT_START,
527            tokens::THINKING_START
528        )));
529        // drop_thinking default true → earlier reasoning stripped
530        assert!(!out.contains("greet"));
531    }
532
533    #[test]
534    fn test_reasoning_effort_prefixes() {
535        let messages = json!([
536            {"role": "system", "content": "hi"},
537            {"role": "user", "content": "hello"}
538        ]);
539
540        let high = encode_messages_with_options(
541            messages.as_array().unwrap(),
542            ThinkingMode::Thinking,
543            true,
544            true,
545            Some(ReasoningEffort::High),
546        )
547        .unwrap();
548        let max = encode_messages_with_options(
549            messages.as_array().unwrap(),
550            ThinkingMode::Thinking,
551            true,
552            true,
553            Some(ReasoningEffort::Max),
554        )
555        .unwrap();
556        let low = encode_messages_with_options(
557            messages.as_array().unwrap(),
558            ThinkingMode::Thinking,
559            true,
560            true,
561            None,
562        )
563        .unwrap();
564
565        assert_eq!(
566            high,
567            concat!(
568                "<|begin▁of▁sentence|>Reasoning Effort: Absolute maximum with no shortcuts permitted.\n",
569                "You MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\n",
570                "Explicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.\n\n",
571                "hi<|User|>hello<|Assistant|><think>"
572            )
573        );
574        assert_eq!(
575            max,
576            concat!(
577                "<|begin▁of▁sentence|>Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.\n",
578                "You MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.\n",
579                "Do not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.\n\n",
580                "hi<|User|>hello<|Assistant|><think>"
581            )
582        );
583        assert_eq!(
584            low,
585            "<|begin▁of▁sentence|>hi<|User|>hello<|Assistant|><think>"
586        );
587    }
588
589    #[test]
590    fn test_content_blocks_with_tool_result() {
591        // `merge_tool_messages` turns a `tool` role followed by a plain user text
592        // into a single user turn whose `content_blocks` interleave the tool result
593        // with the text, joined by "\n\n" at render time. Users don't construct
594        // `content_blocks` directly — both the Python reference and this port
595        // overwrite any user-supplied `content_blocks` with a single text block.
596        let messages = json!([
597            {"role": "user", "content": "call tool"},
598            {"role": "assistant", "content": "", "tool_calls": [{
599                "id": "c1", "type": "function",
600                "function": {"name": "f", "arguments": "{}"}
601            }]},
602            {"role": "tool", "tool_call_id": "c1", "content": "RESULT"},
603            {"role": "user", "content": "thanks"}
604        ]);
605        let out = encode_messages(messages.as_array().unwrap(), ThinkingMode::Chat, true).unwrap();
606        assert!(
607            out.contains("<tool_result>RESULT</tool_result>\n\nthanks"),
608            "expected tool_result block followed by 'thanks' in the merged user turn, got:\n{}",
609            out
610        );
611    }
612
613    #[test]
614    fn test_empty_tool_lists_and_merged_user_task_match_reference() {
615        let call = |id: &str| json!({"id": id, "type": "function", "function": {"name": "search", "arguments": "{}"}});
616        let invoke = "<|DSML|invoke name=\"search\">\n\n</|DSML|invoke>";
617        let calls = format!(
618            "<|Assistant|></think>\n\n<|DSML|tool_calls>\n{invoke}\n{invoke}\n</|DSML|tool_calls><|end▁of▁sentence|>"
619        );
620        // Expected prompts come from the official DeepSeek-V4-Flash encoding_dsv4.py.
621        for (messages, expected) in [
622            (
623                json!([{"role": "system", "content": "S", "tools": []}, {"role": "user", "content": "Hi"}]),
624                "<|begin▁of▁sentence|>S<|User|>Hi<|Assistant|></think>".to_owned(),
625            ),
626            (
627                json!([
628                    {"role": "user", "content": "q"},
629                    {"role": "assistant", "content": "", "tool_calls": [call("a"), call("b")]},
630                    {"role": "assistant", "content": "x", "tool_calls": []},
631                    {"role": "tool", "tool_call_id": "b", "content": "B"},
632                    {"role": "tool", "tool_call_id": "a", "content": "A"}
633                ]),
634                format!(
635                    "<|begin▁of▁sentence|><|User|>q{calls}x<|end▁of▁sentence|><|User|><tool_result>A</tool_result>\n\n<tool_result>B</tool_result><|Assistant|></think>"
636                ),
637            ),
638            (
639                json!([
640                    {"role": "user", "content": "q"},
641                    {"role": "assistant", "content": "", "tool_calls": [call("a"), call("b")]},
642                    {"role": "tool", "tool_call_id": "b", "content": "B"},
643                    {"role": "user", "content": "Search", "task": "action"},
644                    {"role": "user", "content": "More"},
645                    {"role": "tool", "tool_call_id": "a", "content": "A"},
646                    {"role": "assistant", "content": "OK"}
647                ]),
648                format!(
649                    "<|begin▁of▁sentence|><|User|>q{calls}<|User|><tool_result>A</tool_result>\n\nSearch\n\nMore\n\n<tool_result>B</tool_result><|Assistant|></think>OK<|end▁of▁sentence|>"
650                ),
651            ),
652        ] {
653            let out =
654                encode_messages(messages.as_array().unwrap(), ThinkingMode::Chat, true).unwrap();
655            assert_eq!(out, expected);
656        }
657    }
658
659    #[test]
660    fn test_drop_thinking_auto_disable_when_tools_present() {
661        let messages = json!([
662            {"role": "system", "content": "s", "tools": [{
663                "type": "function",
664                "function": {"name": "f", "description": "", "parameters": {"type": "object", "properties": {}}}
665            }]},
666            {"role": "user", "content": "hi"},
667            {"role": "assistant", "reasoning_content": "PRIOR_REASONING", "content": "reply"},
668            {"role": "user", "content": "again"}
669        ]);
670        let out =
671            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
672        // Tools present → drop_thinking auto-disabled → earlier reasoning preserved.
673        assert!(out.contains("PRIOR_REASONING"));
674    }
675
676    // ---- Regression tests for known divergences from the Python reference ----
677
678    /// Bug: `last_user_idx = None` (no user/developer in history) should behave
679    /// like Python's `-1` sentinel — `index >= -1` / `idx >= -1` always true, so
680    /// earlier reasoning is preserved and the assistant's reasoning block is
681    /// rendered. Rust defaulting `None` to `usize::MAX` / `is_some_and` silently
682    /// stripped reasoning instead.
683    ///
684    /// Byte-equivalent to Python reference with the same input:
685    /// `<BOS>sysREASONING_BLOCK</think>hello<EOS>`
686    #[test]
687    fn test_assistant_reasoning_preserved_when_no_user_in_history() {
688        let messages = json!([
689            {"role": "system", "content": "sys"},
690            {"role": "assistant", "content": "hello", "reasoning_content": "REASONING_BLOCK"}
691        ]);
692        let out =
693            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
694        assert_eq!(
695            out, "<|begin▁of▁sentence|>sysREASONING_BLOCK</think>hello<|end▁of▁sentence|>",
696            "Output must match Python reference byte-for-byte when no user/developer in history"
697        );
698    }
699
700    /// Bug: `to_json` tracks in-string state via `prev_char != '\\'` which
701    /// mis-handles consecutive backslashes. A value containing `\\` (one literal
702    /// backslash in JSON) makes the helper think the closing `"` is escaped,
703    /// so it stops inserting Python-compatible spaces after subsequent `:`/`,`.
704    ///
705    /// Python `json.dumps({"path": "\\", "count": 5}, ensure_ascii=False)`
706    /// emits `{"path": "\\", "count": 5}` — space after every `:` and `,`.
707    #[test]
708    fn test_to_json_preserves_spacing_past_escaped_backslash() {
709        let v = json!({"path": "\\", "count": 5});
710        let got = to_json(&v);
711        assert_eq!(
712            got, r#"{"path": "\\", "count": 5}"#,
713            "to_json must match Python's json.dumps formatting past an escaped backslash"
714        );
715    }
716
717    #[test]
718    fn test_resolve_drop_thinking_warns_on_malformed_value() {
719        use std::collections::HashMap;
720        // String "false" where a bool is expected → fall back to default (true) and warn.
721        let mut args = HashMap::new();
722        args.insert(
723            "drop_thinking".to_string(),
724            serde_json::Value::String("false".to_string()),
725        );
726        assert!(DeepSeekV4Formatter::resolve_drop_thinking(Some(&args)));
727        // Malformed reasoning_effort falls back to the API default (high).
728        let malformed = serde_json::Value::String("HIGH".to_string());
729        assert_eq!(
730            DeepSeekV4Formatter::resolve_reasoning_effort(Some(&malformed)),
731            (false, Some(ReasoningEffort::High))
732        );
733    }
734
735    #[test]
736    fn test_resolve_thinking_mode_honors_enable_thinking() {
737        use std::collections::HashMap;
738        let mut args = HashMap::new();
739        args.insert(
740            "enable_thinking".to_string(),
741            serde_json::Value::Bool(false),
742        );
743        assert_eq!(
744            super::super::common::resolve_thinking_mode(Some(&args), ThinkingMode::Thinking),
745            ThinkingMode::Chat
746        );
747        args.insert("enable_thinking".to_string(), serde_json::Value::Bool(true));
748        assert_eq!(
749            super::super::common::resolve_thinking_mode(Some(&args), ThinkingMode::Thinking),
750            ThinkingMode::Thinking
751        );
752    }
753
754    struct MockRequest {
755        messages: JsonValue,
756        typed: Option<Vec<dynamo_protocols::types::ChatCompletionRequestMessage>>,
757        chat_template_args: Option<std::collections::HashMap<String, JsonValue>>,
758        reasoning_effort: Option<JsonValue>,
759        tools: Option<JsonValue>,
760        tool_choice: Option<JsonValue>,
761        response_format: Option<JsonValue>,
762    }
763
764    impl MockRequest {
765        fn new(messages: JsonValue) -> Self {
766            Self {
767                messages,
768                typed: None,
769                chat_template_args: None,
770                reasoning_effort: None,
771                tools: None,
772                tool_choice: None,
773                response_format: None,
774            }
775        }
776
777        fn with_chat_template_args(
778            mut self,
779            args: std::collections::HashMap<String, JsonValue>,
780        ) -> Self {
781            self.chat_template_args = Some(args);
782            self
783        }
784
785        fn with_reasoning_effort(mut self, reasoning_effort: JsonValue) -> Self {
786            self.reasoning_effort = Some(reasoning_effort);
787            self
788        }
789
790        fn with_tools(mut self, tools: JsonValue) -> Self {
791            self.tools = Some(tools);
792            self
793        }
794
795        fn with_tool_choice(mut self, tool_choice: JsonValue) -> Self {
796            self.tool_choice = Some(tool_choice);
797            self
798        }
799
800        fn with_response_format(mut self, response_format: JsonValue) -> Self {
801            self.response_format = Some(response_format);
802            self
803        }
804    }
805
806    impl crate::OAIChatLikeRequest for MockRequest {
807        fn model(&self) -> String {
808            "deepseek-v4".to_string()
809        }
810
811        fn messages(&self) -> minijinja::value::Value {
812            assert!(
813                self.typed.is_none(),
814                "typed requests must skip MiniJinja conversion"
815            );
816            minijinja::value::Value::from_serialize(&self.messages)
817        }
818
819        fn typed_messages(
820            &self,
821        ) -> Option<&[dynamo_protocols::types::ChatCompletionRequestMessage]> {
822            self.typed.as_deref()
823        }
824
825        fn should_add_generation_prompt(&self) -> bool {
826            true
827        }
828
829        fn chat_template_args(
830            &self,
831        ) -> Option<&std::collections::HashMap<String, serde_json::Value>> {
832            self.chat_template_args.as_ref()
833        }
834
835        fn reasoning_effort(&self) -> Option<minijinja::value::Value> {
836            self.reasoning_effort
837                .as_ref()
838                .map(minijinja::value::Value::from_serialize)
839        }
840
841        fn tools(&self) -> Option<minijinja::value::Value> {
842            self.tools
843                .as_ref()
844                .map(minijinja::value::Value::from_serialize)
845        }
846
847        fn tool_choice(&self) -> Option<minijinja::value::Value> {
848            self.tool_choice
849                .as_ref()
850                .map(minijinja::value::Value::from_serialize)
851        }
852
853        fn response_format(&self) -> Option<minijinja::value::Value> {
854            self.response_format
855                .as_ref()
856                .map(minijinja::value::Value::from_serialize)
857        }
858    }
859
860    #[test]
861    fn typed_messages_match_value_messages() {
862        use crate::OAIPromptFormatter;
863        let messages = json!([
864            {"role": "system", "content": "Use tools. 中文 🦀"},
865            {"role": "user", "content": [{"type": "text", "text": "weather?"}]},
866            {"role": "assistant", "content": null, "reasoning_content": "check",
867             "tool_calls": [{"id": "call_1", "type": "function",
868                 "function": {"name": "weather", "arguments": "{\"city\":\"東京\"}"}}]},
869            {"role": "tool", "tool_call_id": "call_1", "content": "sunny"},
870            {"role": "user", "content": "explain"}
871        ]);
872        let mut typed = MockRequest::new(messages.clone());
873        typed.typed = Some(serde_json::from_value(messages.clone()).unwrap());
874        let value = MockRequest::new(messages);
875        for formatter in [
876            DeepSeekV4Formatter::new_thinking(),
877            DeepSeekV4Formatter::new_chat(),
878        ] {
879            assert_eq!(
880                formatter.render(&typed).unwrap(),
881                formatter.render(&value).unwrap()
882            );
883        }
884    }
885
886    fn weather_tool() -> JsonValue {
887        json!([{
888            "type": "function",
889            "function": {
890                "name": "get_current_weather",
891                "description": "Get the current weather in a given location",
892                "parameters": {
893                    "type": "object",
894                    "properties": {"location": {"type": "string"}},
895                    "required": ["location"]
896                }
897            }
898        }])
899    }
900
901    #[test]
902    fn test_formatter_rejects_unsupported_partial_assistant() {
903        use crate::OAIPromptFormatter;
904
905        let request = MockRequest::new(json!([
906            {"role": "user", "content": "Continue"},
907            {"role": "assistant", "content": "prefix", "partial": true}
908        ]));
909        let error = DeepSeekV4Formatter::new_thinking()
910            .render(&request)
911            .unwrap_err();
912
913        assert!(matches!(
914            error.downcast_ref::<crate::PromptRenderError>(),
915            Some(crate::PromptRenderError::InvalidRequest(message))
916                if message.contains("`partial: true` is not supported")
917        ));
918    }
919
920    #[test]
921    fn test_formatter_rejects_system_tools_before_injection() {
922        use crate::OAIPromptFormatter;
923
924        let request = MockRequest::new(json!([
925            {"role": "system", "tools": [
926                {"type": "function", "function": {"name": "dynamic_tool"}}
927            ]},
928            {"role": "user", "content": "Use a tool"}
929        ]))
930        .with_tools(weather_tool());
931        let error = DeepSeekV4Formatter::new_thinking()
932            .render(&request)
933            .unwrap_err();
934
935        assert!(matches!(
936            error.downcast_ref::<crate::PromptRenderError>(),
937            Some(crate::PromptRenderError::InvalidRequest(message))
938                if message.contains("message-level `tools`") && message.contains("system")
939        ));
940    }
941
942    #[test]
943    fn test_formatter_preserves_developer_tools_with_top_level_tools() {
944        use crate::OAIPromptFormatter;
945
946        let request = MockRequest::new(json!([
947            {"role": "developer", "content": "Use a tool", "tools": [
948                {"type": "function", "function": {"name": "developer_tool"}}
949            ]}
950        ]))
951        .with_tools(weather_tool());
952        let rendered = DeepSeekV4Formatter::new_thinking()
953            .render(&request)
954            .unwrap();
955
956        assert!(rendered.contains("developer_tool"));
957        assert!(rendered.contains("get_current_weather"));
958    }
959
960    #[test]
961    fn test_render_tool_choice_none_strips_tools_keeps_response_format() {
962        use crate::OAIPromptFormatter;
963
964        let req = MockRequest::new(json!([
965            {"role": "system", "content": "sys"},
966            {"role": "user", "content": "weather in Boston?"}
967        ]))
968        .with_tools(weather_tool())
969        .with_tool_choice(json!("none"))
970        .with_response_format(json!({"type": "json_object"}));
971
972        let formatter = DeepSeekV4Formatter::new_chat();
973        let out = formatter.render(&req).unwrap();
974
975        assert!(
976            !out.contains("## Tools"),
977            "tool_choice=none must strip the tools block, got: {out}"
978        );
979        assert!(
980            !out.contains("get_current_weather"),
981            "tool schema leaked into prompt despite tool_choice=none: {out}"
982        );
983        assert!(
984            out.contains("## Response Format"),
985            "response_format must survive tool_choice=none: {out}"
986        );
987    }
988
989    #[test]
990    fn test_render_tool_choice_auto_keeps_tools() {
991        use crate::OAIPromptFormatter;
992
993        let req = MockRequest::new(json!([
994            {"role": "system", "content": "sys"},
995            {"role": "user", "content": "weather in Boston?"}
996        ]))
997        .with_tools(weather_tool())
998        .with_tool_choice(json!("auto"));
999
1000        let formatter = DeepSeekV4Formatter::new_chat();
1001        let out = formatter.render(&req).unwrap();
1002
1003        assert!(out.contains("## Tools"));
1004        assert!(out.contains("get_current_weather"));
1005    }
1006
1007    #[test]
1008    fn test_render_absent_tool_choice_keeps_tools() {
1009        use crate::OAIPromptFormatter;
1010
1011        let req = MockRequest::new(json!([
1012            {"role": "system", "content": "sys"},
1013            {"role": "user", "content": "weather in Boston?"}
1014        ]))
1015        .with_tools(weather_tool());
1016
1017        let formatter = DeepSeekV4Formatter::new_chat();
1018        let out = formatter.render(&req).unwrap();
1019
1020        assert!(out.contains("## Tools"));
1021        assert!(out.contains("get_current_weather"));
1022    }
1023
1024    #[test]
1025    fn test_resolve_reasoning_effort_accepts_full_range() {
1026        let effort = |v: &str| {
1027            let value = json!(v);
1028            DeepSeekV4Formatter::resolve_reasoning_effort(Some(&value))
1029        };
1030
1031        assert_eq!(effort("max"), (false, Some(ReasoningEffort::Max)));
1032        assert_eq!(effort("xhigh"), (false, Some(ReasoningEffort::High)));
1033        assert_eq!(effort("high"), (false, Some(ReasoningEffort::High)));
1034        assert_eq!(effort("minimal"), (false, None));
1035        assert_eq!(effort("low"), (false, None));
1036        assert_eq!(effort("medium"), (false, Some(ReasoningEffort::High)));
1037        assert_eq!(effort("none"), (true, None));
1038        assert_eq!(effort("bogus"), (false, Some(ReasoningEffort::High)));
1039        assert_eq!(
1040            DeepSeekV4Formatter::resolve_reasoning_effort(None),
1041            (false, Some(ReasoningEffort::High))
1042        );
1043    }
1044
1045    #[test]
1046    fn test_render_leaves_null_assistant_tool_content_empty() {
1047        use crate::OAIPromptFormatter;
1048
1049        let req = MockRequest::new(json!([
1050            {"role": "user", "content": "call tool"},
1051            {"role": "assistant", "content": null, "tool_calls": [{
1052                "id": "c1", "type": "function",
1053                "function": {"name": "f", "arguments": "{}"}
1054            }]}
1055        ]));
1056
1057        let formatter = DeepSeekV4Formatter::new_chat();
1058        let out = formatter.render(&req).unwrap();
1059
1060        assert!(out.contains(&format!(
1061            "<{}{}>",
1062            tokens::DSML_TOKEN,
1063            TOOL_CALLS_BLOCK_NAME
1064        )));
1065        assert!(!out.contains("null"));
1066    }
1067
1068    #[test]
1069    fn test_render_wires_reasoning_effort_from_chat_template_args() {
1070        use crate::OAIPromptFormatter;
1071        use std::collections::HashMap;
1072
1073        for (effort, expected) in [
1074            ("high", REASONING_EFFORT_HIGH),
1075            ("max", REASONING_EFFORT_MAX),
1076        ] {
1077            let mut args = HashMap::new();
1078            args.insert("reasoning_effort".to_string(), json!(effort));
1079
1080            let req = MockRequest::new(json!([
1081                {"role": "system", "content": "sys"},
1082                {"role": "user", "content": "hi"}
1083            ]))
1084            .with_chat_template_args(args);
1085
1086            let formatter = DeepSeekV4Formatter::new_thinking();
1087            let out = formatter.render(&req).unwrap();
1088
1089            assert!(out.starts_with(tokens::BOS));
1090            assert!(
1091                out[tokens::BOS.len()..].starts_with(expected),
1092                "{effort} preamble should appear after BOS, got:\n{out}"
1093            );
1094        }
1095    }
1096
1097    #[test]
1098    fn test_render_wires_top_level_reasoning_effort_and_none_disables_thinking() {
1099        use crate::OAIPromptFormatter;
1100
1101        let formatter = DeepSeekV4Formatter::new_thinking();
1102        for (effort, expected_prefix) in [
1103            ("high", "Reasoning Effort: Absolute maximum"),
1104            ("max", "Reasoning Effort: Beyond maximum"),
1105        ] {
1106            let req: dynamo_protocols::types::CreateChatCompletionRequest =
1107                serde_json::from_value(json!({
1108                    "model": "deepseek-v4",
1109                    "messages": [{"role": "user", "content": "hi"}],
1110                    "reasoning_effort": effort
1111                }))
1112                .unwrap();
1113            let out = formatter.render(&req).unwrap();
1114
1115            assert!(
1116                out[tokens::BOS.len()..].starts_with(expected_prefix),
1117                "top-level {effort} did not select its prefix: {out}"
1118            );
1119            assert!(out.ends_with(tokens::THINKING_START));
1120        }
1121
1122        let req: dynamo_protocols::types::CreateChatCompletionRequest =
1123            serde_json::from_value(json!({
1124                "model": "deepseek-v4",
1125                "messages": [{"role": "user", "content": "hi"}],
1126                "reasoning_effort": "none"
1127            }))
1128            .unwrap();
1129        let out = formatter.render(&req).unwrap();
1130
1131        assert_eq!(
1132            out,
1133            "<|begin▁of▁sentence|><|User|>hi<|Assistant|></think>"
1134        );
1135    }
1136
1137    #[test]
1138    fn test_top_level_reasoning_effort_precedes_template_argument() {
1139        use crate::OAIPromptFormatter;
1140        use std::collections::HashMap;
1141
1142        let mut args = HashMap::new();
1143        args.insert("reasoning_effort".to_string(), json!("max"));
1144        let req = MockRequest::new(json!([{"role": "user", "content": "hi"}]))
1145            .with_chat_template_args(args)
1146            .with_reasoning_effort(json!("low"));
1147
1148        let out = DeepSeekV4Formatter::new_thinking().render(&req).unwrap();
1149
1150        assert_eq!(
1151            out,
1152            "<|begin▁of▁sentence|><|User|>hi<|Assistant|><think>"
1153        );
1154    }
1155
1156    #[test]
1157    fn test_render_drop_thinking_override_from_chat_template_args() {
1158        use crate::OAIPromptFormatter;
1159        use std::collections::HashMap;
1160
1161        let messages = json!([
1162            {"role": "user", "content": "first"},
1163            {"role": "assistant", "reasoning_content": "PRIOR", "content": "reply"},
1164            {"role": "user", "content": "again"}
1165        ]);
1166
1167        // Default (drop_thinking=true): prior reasoning stripped.
1168        let req_default = MockRequest::new(messages.clone());
1169        let formatter = DeepSeekV4Formatter::new_thinking();
1170        let out_default = formatter.render(&req_default).unwrap();
1171        assert!(
1172            !out_default.contains("PRIOR"),
1173            "default drop_thinking=true should strip prior reasoning, got:\n{}",
1174            out_default
1175        );
1176
1177        // drop_thinking=false override: prior reasoning survives.
1178        let mut args = HashMap::new();
1179        args.insert("drop_thinking".to_string(), json!(false));
1180        let req_keep = MockRequest::new(messages).with_chat_template_args(args);
1181        let out_keep = formatter.render(&req_keep).unwrap();
1182        assert!(
1183            out_keep.contains("PRIOR"),
1184            "drop_thinking=false override should preserve prior reasoning, got:\n{}",
1185            out_keep
1186        );
1187    }
1188
1189    // N4: developer-role interactions with drop_thinking.
1190    // find_last_user_index returns the index of user OR developer messages; the
1191    // drop_thinking reasoning cutoff and the thinking-seed insertion treat
1192    // user and developer identically.
1193
1194    #[test]
1195    fn test_developer_only_conversation_renders_developer_content() {
1196        let messages = json!([
1197            {"role": "system", "content": "sys"},
1198            {"role": "developer", "content": "x"},
1199            {"role": "assistant", "reasoning_content": "R", "content": "ok"}
1200        ]);
1201        let out =
1202            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
1203        assert!(
1204            out.contains("x"),
1205            "developer content should appear in output, got:\n{}",
1206            out
1207        );
1208    }
1209
1210    #[test]
1211    fn test_developer_as_last_user_index_controls_reasoning_cutoff() {
1212        // Indices: 0=user, 1=assistant(FIRST), 2=developer(y), 3=assistant(SECOND).
1213        // find_last_user_index = 2 (developer). With drop_thinking=true:
1214        //   - assistant idx=1 < 2  → reasoning_content stripped.
1215        //   - assistant idx=3 >= 2 → reasoning_content preserved.
1216        let messages = json!([
1217            {"role": "user", "content": "a"},
1218            {"role": "assistant", "reasoning_content": "FIRST", "content": "r1"},
1219            {"role": "developer", "content": "y"},
1220            {"role": "assistant", "reasoning_content": "SECOND", "content": "r2"}
1221        ]);
1222        let out =
1223            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
1224        assert!(
1225            !out.contains("FIRST"),
1226            "reasoning before last user/developer (idx 1 < 2) should be stripped, got:\n{}",
1227            out
1228        );
1229        assert!(
1230            out.contains("SECOND"),
1231            "reasoning at/after last user/developer (idx 3 > 2) should survive, got:\n{}",
1232            out
1233        );
1234    }
1235}