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