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        crate::reject_unsupported_partial_assistant(&messages_json)?;
478        crate::reject_unsupported_message_tools(&messages_json, &["developer"])?;
479
480        let mut messages_array = messages_json
481            .as_array()
482            .context("Messages is not an array")?
483            .clone();
484
485        normalize_message_contents(&mut messages_array, NormalizeNonText::LeaveUntouched);
486
487        super::common::inject_tools_and_response_format(&mut messages_array, req)?;
488
489        encode_messages_with_options(
490            &messages_array,
491            thinking_mode,
492            true,
493            drop_thinking,
494            reasoning_effort,
495        )
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use serde_json::json;
503
504    #[test]
505    fn test_simple_conversation() {
506        let messages = json!([
507            {"role": "system", "content": "You are a helpful assistant."},
508            {"role": "user", "content": "Hello"},
509            {"role": "assistant", "reasoning_content": "greet", "content": "Hi!"},
510            {"role": "user", "content": "What is 2+2?"}
511        ]);
512        let out =
513            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
514        assert!(out.starts_with(tokens::BOS));
515        assert!(out.ends_with(&format!(
516            "{}{}",
517            tokens::ASSISTANT_START,
518            tokens::THINKING_START
519        )));
520        // drop_thinking default true → earlier reasoning stripped
521        assert!(!out.contains("greet"));
522    }
523
524    #[test]
525    fn test_reasoning_effort_prefixes() {
526        let messages = json!([
527            {"role": "system", "content": "hi"},
528            {"role": "user", "content": "hello"}
529        ]);
530
531        let high = encode_messages_with_options(
532            messages.as_array().unwrap(),
533            ThinkingMode::Thinking,
534            true,
535            true,
536            Some(ReasoningEffort::High),
537        )
538        .unwrap();
539        let max = encode_messages_with_options(
540            messages.as_array().unwrap(),
541            ThinkingMode::Thinking,
542            true,
543            true,
544            Some(ReasoningEffort::Max),
545        )
546        .unwrap();
547        let low = encode_messages_with_options(
548            messages.as_array().unwrap(),
549            ThinkingMode::Thinking,
550            true,
551            true,
552            None,
553        )
554        .unwrap();
555
556        assert_eq!(
557            high,
558            concat!(
559                "<|begin▁of▁sentence|>Reasoning Effort: Absolute maximum with no shortcuts permitted.\n",
560                "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",
561                "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",
562                "hi<|User|>hello<|Assistant|><think>"
563            )
564        );
565        assert_eq!(
566            max,
567            concat!(
568                "<|begin▁of▁sentence|>Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.\n",
569                "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",
570                "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",
571                "hi<|User|>hello<|Assistant|><think>"
572            )
573        );
574        assert_eq!(
575            low,
576            "<|begin▁of▁sentence|>hi<|User|>hello<|Assistant|><think>"
577        );
578    }
579
580    #[test]
581    fn test_content_blocks_with_tool_result() {
582        // `merge_tool_messages` turns a `tool` role followed by a plain user text
583        // into a single user turn whose `content_blocks` interleave the tool result
584        // with the text, joined by "\n\n" at render time. Users don't construct
585        // `content_blocks` directly — both the Python reference and this port
586        // overwrite any user-supplied `content_blocks` with a single text block.
587        let messages = json!([
588            {"role": "user", "content": "call tool"},
589            {"role": "assistant", "content": "", "tool_calls": [{
590                "id": "c1", "type": "function",
591                "function": {"name": "f", "arguments": "{}"}
592            }]},
593            {"role": "tool", "tool_call_id": "c1", "content": "RESULT"},
594            {"role": "user", "content": "thanks"}
595        ]);
596        let out = encode_messages(messages.as_array().unwrap(), ThinkingMode::Chat, true).unwrap();
597        assert!(
598            out.contains("<tool_result>RESULT</tool_result>\n\nthanks"),
599            "expected tool_result block followed by 'thanks' in the merged user turn, got:\n{}",
600            out
601        );
602    }
603
604    #[test]
605    fn test_user_task_preserved_when_merged_after_tool_result() {
606        let messages = json!([
607            {"role": "assistant", "content": "", "tool_calls": [{
608                "id": "c1", "type": "function",
609                "function": {"name": "search", "arguments": "{}"}
610            }]},
611            {"role": "tool", "tool_call_id": "c1", "content": "RESULT"},
612            {"role": "user", "content": "Search", "task": "action"},
613            {"role": "assistant", "content": "OK"}
614        ]);
615
616        let out = encode_messages(messages.as_array().unwrap(), ThinkingMode::Chat, true).unwrap();
617        assert!(
618            out.contains(&format!(
619                "{}Search{}{}{}OK",
620                "<tool_result>RESULT</tool_result>\n\n",
621                tokens::ASSISTANT_START,
622                tokens::THINKING_END,
623                tokens::TASK_ACTION
624            )),
625            "expected merged user text to keep the action task transition, got:\n{}",
626            out
627        );
628    }
629
630    #[test]
631    fn test_drop_thinking_auto_disable_when_tools_present() {
632        let messages = json!([
633            {"role": "system", "content": "s", "tools": [{
634                "type": "function",
635                "function": {"name": "f", "description": "", "parameters": {"type": "object", "properties": {}}}
636            }]},
637            {"role": "user", "content": "hi"},
638            {"role": "assistant", "reasoning_content": "PRIOR_REASONING", "content": "reply"},
639            {"role": "user", "content": "again"}
640        ]);
641        let out =
642            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
643        // Tools present → drop_thinking auto-disabled → earlier reasoning preserved.
644        assert!(out.contains("PRIOR_REASONING"));
645    }
646
647    // ---- Regression tests for known divergences from the Python reference ----
648
649    /// Bug: `last_user_idx = None` (no user/developer in history) should behave
650    /// like Python's `-1` sentinel — `index >= -1` / `idx >= -1` always true, so
651    /// earlier reasoning is preserved and the assistant's reasoning block is
652    /// rendered. Rust defaulting `None` to `usize::MAX` / `is_some_and` silently
653    /// stripped reasoning instead.
654    ///
655    /// Byte-equivalent to Python reference with the same input:
656    /// `<BOS>sysREASONING_BLOCK</think>hello<EOS>`
657    #[test]
658    fn test_assistant_reasoning_preserved_when_no_user_in_history() {
659        let messages = json!([
660            {"role": "system", "content": "sys"},
661            {"role": "assistant", "content": "hello", "reasoning_content": "REASONING_BLOCK"}
662        ]);
663        let out =
664            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
665        assert_eq!(
666            out, "<|begin▁of▁sentence|>sysREASONING_BLOCK</think>hello<|end▁of▁sentence|>",
667            "Output must match Python reference byte-for-byte when no user/developer in history"
668        );
669    }
670
671    /// Bug: `to_json` tracks in-string state via `prev_char != '\\'` which
672    /// mis-handles consecutive backslashes. A value containing `\\` (one literal
673    /// backslash in JSON) makes the helper think the closing `"` is escaped,
674    /// so it stops inserting Python-compatible spaces after subsequent `:`/`,`.
675    ///
676    /// Python `json.dumps({"path": "\\", "count": 5}, ensure_ascii=False)`
677    /// emits `{"path": "\\", "count": 5}` — space after every `:` and `,`.
678    #[test]
679    fn test_to_json_preserves_spacing_past_escaped_backslash() {
680        let v = json!({"path": "\\", "count": 5});
681        let got = to_json(&v);
682        assert_eq!(
683            got, r#"{"path": "\\", "count": 5}"#,
684            "to_json must match Python's json.dumps formatting past an escaped backslash"
685        );
686    }
687
688    #[test]
689    fn test_resolve_drop_thinking_warns_on_malformed_value() {
690        use std::collections::HashMap;
691        // String "false" where a bool is expected → fall back to default (true) and warn.
692        let mut args = HashMap::new();
693        args.insert(
694            "drop_thinking".to_string(),
695            serde_json::Value::String("false".to_string()),
696        );
697        assert!(DeepSeekV4Formatter::resolve_drop_thinking(Some(&args)));
698        // Malformed reasoning_effort falls back to the API default (high).
699        let malformed = serde_json::Value::String("HIGH".to_string());
700        assert_eq!(
701            DeepSeekV4Formatter::resolve_reasoning_effort(Some(&malformed)),
702            (false, Some(ReasoningEffort::High))
703        );
704    }
705
706    #[test]
707    fn test_resolve_thinking_mode_honors_enable_thinking() {
708        use std::collections::HashMap;
709        let mut args = HashMap::new();
710        args.insert(
711            "enable_thinking".to_string(),
712            serde_json::Value::Bool(false),
713        );
714        assert_eq!(
715            super::super::common::resolve_thinking_mode(Some(&args), ThinkingMode::Thinking),
716            ThinkingMode::Chat
717        );
718        args.insert("enable_thinking".to_string(), serde_json::Value::Bool(true));
719        assert_eq!(
720            super::super::common::resolve_thinking_mode(Some(&args), ThinkingMode::Thinking),
721            ThinkingMode::Thinking
722        );
723    }
724
725    struct MockRequest {
726        messages: JsonValue,
727        chat_template_args: Option<std::collections::HashMap<String, JsonValue>>,
728        reasoning_effort: Option<JsonValue>,
729        tools: Option<JsonValue>,
730        tool_choice: Option<JsonValue>,
731        response_format: Option<JsonValue>,
732    }
733
734    impl MockRequest {
735        fn new(messages: JsonValue) -> Self {
736            Self {
737                messages,
738                chat_template_args: None,
739                reasoning_effort: None,
740                tools: None,
741                tool_choice: None,
742                response_format: None,
743            }
744        }
745
746        fn with_chat_template_args(
747            mut self,
748            args: std::collections::HashMap<String, JsonValue>,
749        ) -> Self {
750            self.chat_template_args = Some(args);
751            self
752        }
753
754        fn with_reasoning_effort(mut self, reasoning_effort: JsonValue) -> Self {
755            self.reasoning_effort = Some(reasoning_effort);
756            self
757        }
758
759        fn with_tools(mut self, tools: JsonValue) -> Self {
760            self.tools = Some(tools);
761            self
762        }
763
764        fn with_tool_choice(mut self, tool_choice: JsonValue) -> Self {
765            self.tool_choice = Some(tool_choice);
766            self
767        }
768
769        fn with_response_format(mut self, response_format: JsonValue) -> Self {
770            self.response_format = Some(response_format);
771            self
772        }
773    }
774
775    impl crate::OAIChatLikeRequest for MockRequest {
776        fn model(&self) -> String {
777            "deepseek-v4".to_string()
778        }
779
780        fn messages(&self) -> minijinja::value::Value {
781            minijinja::value::Value::from_serialize(&self.messages)
782        }
783
784        fn should_add_generation_prompt(&self) -> bool {
785            true
786        }
787
788        fn chat_template_args(
789            &self,
790        ) -> Option<&std::collections::HashMap<String, serde_json::Value>> {
791            self.chat_template_args.as_ref()
792        }
793
794        fn reasoning_effort(&self) -> Option<minijinja::value::Value> {
795            self.reasoning_effort
796                .as_ref()
797                .map(minijinja::value::Value::from_serialize)
798        }
799
800        fn tools(&self) -> Option<minijinja::value::Value> {
801            self.tools
802                .as_ref()
803                .map(minijinja::value::Value::from_serialize)
804        }
805
806        fn tool_choice(&self) -> Option<minijinja::value::Value> {
807            self.tool_choice
808                .as_ref()
809                .map(minijinja::value::Value::from_serialize)
810        }
811
812        fn response_format(&self) -> Option<minijinja::value::Value> {
813            self.response_format
814                .as_ref()
815                .map(minijinja::value::Value::from_serialize)
816        }
817    }
818
819    fn weather_tool() -> JsonValue {
820        json!([{
821            "type": "function",
822            "function": {
823                "name": "get_current_weather",
824                "description": "Get the current weather in a given location",
825                "parameters": {
826                    "type": "object",
827                    "properties": {"location": {"type": "string"}},
828                    "required": ["location"]
829                }
830            }
831        }])
832    }
833
834    #[test]
835    fn test_formatter_rejects_unsupported_partial_assistant() {
836        use crate::OAIPromptFormatter;
837
838        let request = MockRequest::new(json!([
839            {"role": "user", "content": "Continue"},
840            {"role": "assistant", "content": "prefix", "partial": true}
841        ]));
842        let error = DeepSeekV4Formatter::new_thinking()
843            .render(&request)
844            .unwrap_err();
845
846        assert!(matches!(
847            error.downcast_ref::<crate::PromptRenderError>(),
848            Some(crate::PromptRenderError::InvalidRequest(message))
849                if message.contains("`partial: true` is not supported")
850        ));
851    }
852
853    #[test]
854    fn test_formatter_rejects_system_tools_before_injection() {
855        use crate::OAIPromptFormatter;
856
857        let request = MockRequest::new(json!([
858            {"role": "system", "tools": [
859                {"type": "function", "function": {"name": "dynamic_tool"}}
860            ]},
861            {"role": "user", "content": "Use a tool"}
862        ]))
863        .with_tools(weather_tool());
864        let error = DeepSeekV4Formatter::new_thinking()
865            .render(&request)
866            .unwrap_err();
867
868        assert!(matches!(
869            error.downcast_ref::<crate::PromptRenderError>(),
870            Some(crate::PromptRenderError::InvalidRequest(message))
871                if message.contains("message-level `tools`") && message.contains("system")
872        ));
873    }
874
875    #[test]
876    fn test_formatter_preserves_developer_tools_with_top_level_tools() {
877        use crate::OAIPromptFormatter;
878
879        let request = MockRequest::new(json!([
880            {"role": "developer", "content": "Use a tool", "tools": [
881                {"type": "function", "function": {"name": "developer_tool"}}
882            ]}
883        ]))
884        .with_tools(weather_tool());
885        let rendered = DeepSeekV4Formatter::new_thinking()
886            .render(&request)
887            .unwrap();
888
889        assert!(rendered.contains("developer_tool"));
890        assert!(rendered.contains("get_current_weather"));
891    }
892
893    #[test]
894    fn test_render_tool_choice_none_strips_tools_keeps_response_format() {
895        use crate::OAIPromptFormatter;
896
897        let req = MockRequest::new(json!([
898            {"role": "system", "content": "sys"},
899            {"role": "user", "content": "weather in Boston?"}
900        ]))
901        .with_tools(weather_tool())
902        .with_tool_choice(json!("none"))
903        .with_response_format(json!({"type": "json_object"}));
904
905        let formatter = DeepSeekV4Formatter::new_chat();
906        let out = formatter.render(&req).unwrap();
907
908        assert!(
909            !out.contains("## Tools"),
910            "tool_choice=none must strip the tools block, got: {out}"
911        );
912        assert!(
913            !out.contains("get_current_weather"),
914            "tool schema leaked into prompt despite tool_choice=none: {out}"
915        );
916        assert!(
917            out.contains("## Response Format"),
918            "response_format must survive tool_choice=none: {out}"
919        );
920    }
921
922    #[test]
923    fn test_render_tool_choice_auto_keeps_tools() {
924        use crate::OAIPromptFormatter;
925
926        let req = MockRequest::new(json!([
927            {"role": "system", "content": "sys"},
928            {"role": "user", "content": "weather in Boston?"}
929        ]))
930        .with_tools(weather_tool())
931        .with_tool_choice(json!("auto"));
932
933        let formatter = DeepSeekV4Formatter::new_chat();
934        let out = formatter.render(&req).unwrap();
935
936        assert!(out.contains("## Tools"));
937        assert!(out.contains("get_current_weather"));
938    }
939
940    #[test]
941    fn test_render_absent_tool_choice_keeps_tools() {
942        use crate::OAIPromptFormatter;
943
944        let req = MockRequest::new(json!([
945            {"role": "system", "content": "sys"},
946            {"role": "user", "content": "weather in Boston?"}
947        ]))
948        .with_tools(weather_tool());
949
950        let formatter = DeepSeekV4Formatter::new_chat();
951        let out = formatter.render(&req).unwrap();
952
953        assert!(out.contains("## Tools"));
954        assert!(out.contains("get_current_weather"));
955    }
956
957    #[test]
958    fn test_resolve_reasoning_effort_accepts_full_range() {
959        let effort = |v: &str| {
960            let value = json!(v);
961            DeepSeekV4Formatter::resolve_reasoning_effort(Some(&value))
962        };
963
964        assert_eq!(effort("max"), (false, Some(ReasoningEffort::Max)));
965        assert_eq!(effort("xhigh"), (false, Some(ReasoningEffort::High)));
966        assert_eq!(effort("high"), (false, Some(ReasoningEffort::High)));
967        assert_eq!(effort("minimal"), (false, None));
968        assert_eq!(effort("low"), (false, None));
969        assert_eq!(effort("medium"), (false, Some(ReasoningEffort::High)));
970        assert_eq!(effort("none"), (true, None));
971        assert_eq!(effort("bogus"), (false, Some(ReasoningEffort::High)));
972        assert_eq!(
973            DeepSeekV4Formatter::resolve_reasoning_effort(None),
974            (false, Some(ReasoningEffort::High))
975        );
976    }
977
978    #[test]
979    fn test_render_leaves_null_assistant_tool_content_empty() {
980        use crate::OAIPromptFormatter;
981
982        let req = MockRequest::new(json!([
983            {"role": "user", "content": "call tool"},
984            {"role": "assistant", "content": null, "tool_calls": [{
985                "id": "c1", "type": "function",
986                "function": {"name": "f", "arguments": "{}"}
987            }]}
988        ]));
989
990        let formatter = DeepSeekV4Formatter::new_chat();
991        let out = formatter.render(&req).unwrap();
992
993        assert!(out.contains(&format!(
994            "<{}{}>",
995            tokens::DSML_TOKEN,
996            TOOL_CALLS_BLOCK_NAME
997        )));
998        assert!(!out.contains("null"));
999    }
1000
1001    #[test]
1002    fn test_render_wires_reasoning_effort_from_chat_template_args() {
1003        use crate::OAIPromptFormatter;
1004        use std::collections::HashMap;
1005
1006        for (effort, expected) in [
1007            ("high", REASONING_EFFORT_HIGH),
1008            ("max", REASONING_EFFORT_MAX),
1009        ] {
1010            let mut args = HashMap::new();
1011            args.insert("reasoning_effort".to_string(), json!(effort));
1012
1013            let req = MockRequest::new(json!([
1014                {"role": "system", "content": "sys"},
1015                {"role": "user", "content": "hi"}
1016            ]))
1017            .with_chat_template_args(args);
1018
1019            let formatter = DeepSeekV4Formatter::new_thinking();
1020            let out = formatter.render(&req).unwrap();
1021
1022            assert!(out.starts_with(tokens::BOS));
1023            assert!(
1024                out[tokens::BOS.len()..].starts_with(expected),
1025                "{effort} preamble should appear after BOS, got:\n{out}"
1026            );
1027        }
1028    }
1029
1030    #[test]
1031    fn test_render_wires_top_level_reasoning_effort_and_none_disables_thinking() {
1032        use crate::OAIPromptFormatter;
1033
1034        let formatter = DeepSeekV4Formatter::new_thinking();
1035        for (effort, expected_prefix) in [
1036            ("high", "Reasoning Effort: Absolute maximum"),
1037            ("max", "Reasoning Effort: Beyond maximum"),
1038        ] {
1039            let req: dynamo_protocols::types::CreateChatCompletionRequest =
1040                serde_json::from_value(json!({
1041                    "model": "deepseek-v4",
1042                    "messages": [{"role": "user", "content": "hi"}],
1043                    "reasoning_effort": effort
1044                }))
1045                .unwrap();
1046            let out = formatter.render(&req).unwrap();
1047
1048            assert!(
1049                out[tokens::BOS.len()..].starts_with(expected_prefix),
1050                "top-level {effort} did not select its prefix: {out}"
1051            );
1052            assert!(out.ends_with(tokens::THINKING_START));
1053        }
1054
1055        let req: dynamo_protocols::types::CreateChatCompletionRequest =
1056            serde_json::from_value(json!({
1057                "model": "deepseek-v4",
1058                "messages": [{"role": "user", "content": "hi"}],
1059                "reasoning_effort": "none"
1060            }))
1061            .unwrap();
1062        let out = formatter.render(&req).unwrap();
1063
1064        assert_eq!(
1065            out,
1066            "<|begin▁of▁sentence|><|User|>hi<|Assistant|></think>"
1067        );
1068    }
1069
1070    #[test]
1071    fn test_top_level_reasoning_effort_precedes_template_argument() {
1072        use crate::OAIPromptFormatter;
1073        use std::collections::HashMap;
1074
1075        let mut args = HashMap::new();
1076        args.insert("reasoning_effort".to_string(), json!("max"));
1077        let req = MockRequest::new(json!([{"role": "user", "content": "hi"}]))
1078            .with_chat_template_args(args)
1079            .with_reasoning_effort(json!("low"));
1080
1081        let out = DeepSeekV4Formatter::new_thinking().render(&req).unwrap();
1082
1083        assert_eq!(
1084            out,
1085            "<|begin▁of▁sentence|><|User|>hi<|Assistant|><think>"
1086        );
1087    }
1088
1089    #[test]
1090    fn test_render_drop_thinking_override_from_chat_template_args() {
1091        use crate::OAIPromptFormatter;
1092        use std::collections::HashMap;
1093
1094        let messages = json!([
1095            {"role": "user", "content": "first"},
1096            {"role": "assistant", "reasoning_content": "PRIOR", "content": "reply"},
1097            {"role": "user", "content": "again"}
1098        ]);
1099
1100        // Default (drop_thinking=true): prior reasoning stripped.
1101        let req_default = MockRequest::new(messages.clone());
1102        let formatter = DeepSeekV4Formatter::new_thinking();
1103        let out_default = formatter.render(&req_default).unwrap();
1104        assert!(
1105            !out_default.contains("PRIOR"),
1106            "default drop_thinking=true should strip prior reasoning, got:\n{}",
1107            out_default
1108        );
1109
1110        // drop_thinking=false override: prior reasoning survives.
1111        let mut args = HashMap::new();
1112        args.insert("drop_thinking".to_string(), json!(false));
1113        let req_keep = MockRequest::new(messages).with_chat_template_args(args);
1114        let out_keep = formatter.render(&req_keep).unwrap();
1115        assert!(
1116            out_keep.contains("PRIOR"),
1117            "drop_thinking=false override should preserve prior reasoning, got:\n{}",
1118            out_keep
1119        );
1120    }
1121
1122    // N4: developer-role interactions with drop_thinking.
1123    // find_last_user_index returns the index of user OR developer messages; the
1124    // drop_thinking reasoning cutoff and the thinking-seed insertion treat
1125    // user and developer identically.
1126
1127    #[test]
1128    fn test_developer_only_conversation_renders_developer_content() {
1129        let messages = json!([
1130            {"role": "system", "content": "sys"},
1131            {"role": "developer", "content": "x"},
1132            {"role": "assistant", "reasoning_content": "R", "content": "ok"}
1133        ]);
1134        let out =
1135            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
1136        assert!(
1137            out.contains("x"),
1138            "developer content should appear in output, got:\n{}",
1139            out
1140        );
1141    }
1142
1143    #[test]
1144    fn test_developer_as_last_user_index_controls_reasoning_cutoff() {
1145        // Indices: 0=user, 1=assistant(FIRST), 2=developer(y), 3=assistant(SECOND).
1146        // find_last_user_index = 2 (developer). With drop_thinking=true:
1147        //   - assistant idx=1 < 2  → reasoning_content stripped.
1148        //   - assistant idx=3 >= 2 → reasoning_content preserved.
1149        let messages = json!([
1150            {"role": "user", "content": "a"},
1151            {"role": "assistant", "reasoning_content": "FIRST", "content": "r1"},
1152            {"role": "developer", "content": "y"},
1153            {"role": "assistant", "reasoning_content": "SECOND", "content": "r2"}
1154        ]);
1155        let out =
1156            encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
1157        assert!(
1158            !out.contains("FIRST"),
1159            "reasoning before last user/developer (idx 1 < 2) should be stripped, got:\n{}",
1160            out
1161        );
1162        assert!(
1163            out.contains("SECOND"),
1164            "reasoning at/after last user/developer (idx 3 > 2) should survive, got:\n{}",
1165            out
1166        );
1167    }
1168}