Skip to main content

dynamo_renderer/deepseek/
common.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared DeepSeek native prompt-formatting helpers.
5
6use anyhow::{Context, Result};
7use serde_json::Value as JsonValue;
8
9/// Special tokens for DeepSeek prompt formatting.
10pub mod tokens {
11    pub const BOS: &str = "<|begin▁of▁sentence|>";
12    pub const EOS: &str = "<|end▁of▁sentence|>";
13    pub const THINKING_START: &str = "<think>";
14    pub const THINKING_END: &str = "</think>";
15    pub const DSML_TOKEN: &str = "|DSML|";
16    pub const USER_START: &str = "<|User|>";
17    pub const ASSISTANT_START: &str = "<|Assistant|>";
18    pub const LATEST_REMINDER: &str = "<|latest_reminder|>";
19
20    // Quick-instruction task tokens
21    pub const TASK_ACTION: &str = "<|action|>";
22    pub const TASK_QUERY: &str = "<|query|>";
23    pub const TASK_AUTHORITY: &str = "<|authority|>";
24    pub const TASK_DOMAIN: &str = "<|domain|>";
25    pub const TASK_TITLE: &str = "<|title|>";
26    pub const TASK_READ_URL: &str = "<|read_url|>";
27}
28
29pub(crate) const TOOL_CALLS_BLOCK_NAME: &str = "tool_calls";
30
31pub(crate) const RESPONSE_FORMAT_TEMPLATE: &str =
32    "## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n{schema}";
33
34pub(crate) const TOOLS_TEMPLATE: &str = r#"## Tools
35
36You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml_token}tool_calls>" block like the following:
37
38<{dsml_token}tool_calls>
39<{dsml_token}invoke name="$TOOL_NAME">
40<{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>
41...
42</{dsml_token}invoke>
43<{dsml_token}invoke name="$TOOL_NAME2">
44...
45</{dsml_token}invoke>
46</{dsml_token}tool_calls>
47
48String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
49
50If thinking_mode is enabled (triggered by {thinking_start_token}), you MUST output your complete reasoning inside {thinking_start_token}...{thinking_end_token} BEFORE any tool calls or final response.
51
52Otherwise, output directly after {thinking_end_token} with tool calls or final response.
53
54### Available Tool Schemas
55
56{tool_schemas}
57
58You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
59"#;
60
61/// System message template for tools.
62pub(crate) const TOOLS_SYSTEM_TEMPLATE: &str = r#"## Tools
63
64You have access to a set of tools you can use to answer the user's question.
65You can invoke functions by writing a "<{dsml_token}function_calls>" block like the following as part of your reply to the user:
66<{dsml_token}function_calls>
67<{dsml_token}invoke name="$FUNCTION_NAME">
68<{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>
69...
70</{dsml_token}invoke>
71<{dsml_token}invoke name="$FUNCTION_NAME2">
72...
73</{dsml_token}invoke>
74</{dsml_token}function_calls>
75
76String and scalar parameters should be specified as is without any escaping or quotes, while lists and objects should use JSON format. The "string" attribute should be set to "true" for string type parameters and "false" for other types (numbers, booleans, arrays, objects).
77
78If the thinking_mode is enabled, then after function results you should strongly consider outputting a thinking block. Here is an example:
79
80<{dsml_token}function_calls>
81...
82</{dsml_token}function_calls>
83
84<function_results>
85...
86</function_results>
87
88{thinking_start_token}...thinking about results{thinking_end_token}
89
90Here are the functions available in JSONSchema format:
91<functions>
92{tool_schemas}
93</functions>
94"#;
95
96pub(crate) const TOOL_CALL_TEMPLATE: &str =
97    "<{dsml_token}invoke name=\"{name}\">\n{arguments}\n</{dsml_token}invoke>";
98
99pub(crate) const TOOL_OUTPUT_TEMPLATE: &str = "\n<result>{content}</result>";
100
101pub(crate) const REASONING_EFFORT_HIGH: &str = "Reasoning Effort: Absolute maximum with no shortcuts permitted.\nYou 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.\nExplicitly 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";
102
103pub(crate) const REASONING_EFFORT_MAX: &str = "Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.\nYou 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.\nDo 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";
104
105/// Thinking mode for the model.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum ThinkingMode {
108    Chat,
109    Thinking,
110}
111
112impl ThinkingMode {
113    pub fn as_str(&self) -> &'static str {
114        match self {
115            ThinkingMode::Chat => "chat",
116            ThinkingMode::Thinking => "thinking",
117        }
118    }
119}
120
121/// Reasoning effort level. `None` conveyed as `Option<ReasoningEffort>`.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum ReasoningEffort {
124    Max,
125    High,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub(crate) enum NormalizeNonText {
130    SerializeJson,
131    LeaveUntouched,
132}
133
134// Serialize a JSON value to match Python's `json.dumps(ensure_ascii=False)` spacing.
135// Python's default separators are `(', ', ': ')`; we use a custom `Formatter`
136// so escape sequences inside strings can't confuse state tracking.
137pub(crate) fn to_json(value: &JsonValue) -> String {
138    use serde::Serialize;
139    use serde_json::ser::Formatter;
140    use std::io;
141
142    struct PythonFormatter;
143
144    impl Formatter for PythonFormatter {
145        fn begin_array_value<W: ?Sized + io::Write>(
146            &mut self,
147            writer: &mut W,
148            first: bool,
149        ) -> io::Result<()> {
150            if first {
151                Ok(())
152            } else {
153                writer.write_all(b", ")
154            }
155        }
156
157        fn begin_object_key<W: ?Sized + io::Write>(
158            &mut self,
159            writer: &mut W,
160            first: bool,
161        ) -> io::Result<()> {
162            if first {
163                Ok(())
164            } else {
165                writer.write_all(b", ")
166            }
167        }
168
169        fn begin_object_value<W: ?Sized + io::Write>(&mut self, writer: &mut W) -> io::Result<()> {
170            writer.write_all(b": ")
171        }
172    }
173
174    // Serializing a JsonValue into Vec<u8> is infallible; the output is always UTF-8.
175    let mut buf = Vec::with_capacity(64);
176    let mut ser = serde_json::Serializer::with_formatter(&mut buf, PythonFormatter);
177    value
178        .serialize(&mut ser)
179        .expect("JsonValue serialization to Vec<u8> is infallible");
180    String::from_utf8(buf).expect("serde_json output is always valid UTF-8")
181}
182
183pub(crate) fn render_tools(template: &str, tools: &[JsonValue]) -> String {
184    let tools_json: Vec<String> = tools
185        .iter()
186        .filter_map(|tool| tool.get("function"))
187        .map(to_json)
188        .collect();
189
190    // Always do the tool_schemas last because they are user controlled.
191    // See test_render_tools_preserves_placeholder_text_inside_tool_schema.
192    template
193        .replace("{dsml_token}", tokens::DSML_TOKEN)
194        .replace("{thinking_start_token}", tokens::THINKING_START)
195        .replace("{thinking_end_token}", tokens::THINKING_END)
196        .replace("{tool_schemas}", &tools_json.join("\n"))
197}
198
199pub(crate) fn find_last_user_index(messages: &[JsonValue]) -> Option<usize> {
200    messages
201        .iter()
202        .enumerate()
203        .rev()
204        .find(|(_, msg)| {
205            msg.get("role")
206                .and_then(|r| r.as_str())
207                .map(|r| r == "user" || r == "developer")
208                .unwrap_or(false)
209        })
210        .map(|(idx, _)| idx)
211}
212
213pub(crate) fn extract_visible_text(content: &JsonValue) -> String {
214    match content {
215        JsonValue::String(text) => text.clone(),
216        JsonValue::Array(items) => items
217            .iter()
218            .filter_map(|item| {
219                if let Some(text) = item.as_str() {
220                    return Some(text.to_string());
221                }
222                let item_type = item.get("type").and_then(|v| v.as_str());
223                if item_type == Some("text") {
224                    return item
225                        .get("text")
226                        .and_then(|v| v.as_str())
227                        .map(|text| text.to_string());
228                }
229                tracing::warn!(
230                    chunk_type = item_type.unwrap_or("unknown"),
231                    "DeepSeek formatter dropped non-text content chunk while normalizing message content",
232                );
233                None
234            })
235            .collect::<String>(),
236        _ => to_json(content),
237    }
238}
239
240pub(crate) fn normalize_message_contents(messages: &mut [JsonValue], non_text: NormalizeNonText) {
241    for msg in messages {
242        let Some(content) = msg.get("content") else {
243            continue;
244        };
245        if !content.is_string()
246            && !content.is_array()
247            && non_text == NormalizeNonText::LeaveUntouched
248        {
249            continue;
250        }
251        let normalized = extract_visible_text(content);
252        if let Some(obj) = msg.as_object_mut() {
253            obj.insert("content".to_string(), JsonValue::String(normalized));
254        }
255    }
256}
257
258pub(crate) fn encode_arguments_to_dsml(tool_call: &JsonValue) -> Result<String> {
259    let arguments_str = tool_call
260        .get("arguments")
261        .and_then(|a| a.as_str())
262        .context("Missing or invalid 'arguments' field")?;
263
264    // Python falls back to `{"arguments": raw_string}` on parse failure.
265    let arguments: JsonValue = match serde_json::from_str(arguments_str) {
266        Ok(v) => v,
267        Err(_) => serde_json::json!({ "arguments": arguments_str }),
268    };
269
270    let arguments_obj = arguments
271        .as_object()
272        .context("Arguments must be a JSON object")?;
273
274    let mut params = Vec::new();
275    for (key, value) in arguments_obj {
276        let value_str = if let Some(vs) = value.as_str() {
277            vs.to_string()
278        } else {
279            to_json(value)
280        };
281        params.push(format!(
282            "<{}parameter name=\"{}\" string=\"{}\">{}</{}parameter>",
283            tokens::DSML_TOKEN,
284            key,
285            if value.is_string() { "true" } else { "false" },
286            value_str,
287            tokens::DSML_TOKEN
288        ));
289    }
290
291    Ok(params.join("\n"))
292}
293
294pub(crate) fn task_token(task: &str) -> Option<&'static str> {
295    match task {
296        "action" => Some(tokens::TASK_ACTION),
297        "query" => Some(tokens::TASK_QUERY),
298        "authority" => Some(tokens::TASK_AUTHORITY),
299        "domain" => Some(tokens::TASK_DOMAIN),
300        "title" => Some(tokens::TASK_TITLE),
301        "read_url" => Some(tokens::TASK_READ_URL),
302        _ => None,
303    }
304}
305
306const USER_FIELDS_TO_PRESERVE: [&str; 3] = ["task", "wo_eos", "mask"];
307
308fn preserve_user_fields(target: &mut JsonValue, source: &JsonValue) {
309    if let Some(obj) = target.as_object_mut() {
310        for key in USER_FIELDS_TO_PRESERVE {
311            if let Some(v) = source.get(key) {
312                obj.insert(key.to_string(), v.clone());
313            }
314        }
315    }
316}
317
318// Merge `tool` role messages into preceding user `content_blocks` and collapse
319// consecutive user turns, matching Python's `merge_tool_messages`.
320pub(crate) fn merge_tool_messages(messages: &[JsonValue]) -> Vec<JsonValue> {
321    let mut merged: Vec<JsonValue> = Vec::with_capacity(messages.len());
322
323    for msg in messages {
324        let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
325
326        if role == "tool" {
327            let tool_block = serde_json::json!({
328                "type": "tool_result",
329                "tool_use_id": msg.get("tool_call_id").cloned().unwrap_or_else(|| JsonValue::String(String::new())),
330                "content": msg.get("content").cloned().unwrap_or_else(|| JsonValue::String(String::new())),
331            });
332
333            let can_merge = merged
334                .last()
335                .map(|m| {
336                    m.get("role").and_then(|r| r.as_str()) == Some("user")
337                        && m.get("content_blocks").is_some()
338                })
339                .unwrap_or(false);
340
341            if can_merge {
342                let last = merged.last_mut().unwrap();
343                if let Some(blocks) = last
344                    .as_object_mut()
345                    .and_then(|o| o.get_mut("content_blocks"))
346                    .and_then(|v| v.as_array_mut())
347                {
348                    blocks.push(tool_block);
349                }
350            } else {
351                merged.push(serde_json::json!({
352                    "role": "user",
353                    "content_blocks": [tool_block],
354                }));
355            }
356        } else if role == "user" {
357            let text = msg
358                .get("content")
359                .and_then(|c| c.as_str())
360                .unwrap_or("")
361                .to_string();
362            let text_block = serde_json::json!({ "type": "text", "text": text });
363
364            let can_merge = merged
365                .last()
366                .map(|m| {
367                    m.get("role").and_then(|r| r.as_str()) == Some("user")
368                        && m.get("content_blocks").is_some()
369                        && m.get("task").map(|v| v.is_null()).unwrap_or(true)
370                })
371                .unwrap_or(false);
372
373            if can_merge {
374                let last = merged.last_mut().unwrap();
375                let appended = last
376                    .as_object_mut()
377                    .and_then(|o| o.get_mut("content_blocks"))
378                    .and_then(|v| v.as_array_mut())
379                    .map(|blocks| {
380                        blocks.push(text_block);
381                    })
382                    .is_some();
383                if appended {
384                    preserve_user_fields(last, msg);
385                }
386            } else {
387                let mut new_msg = serde_json::json!({
388                    "role": "user",
389                    "content": text,
390                    "content_blocks": [text_block],
391                });
392                preserve_user_fields(&mut new_msg, msg);
393                merged.push(new_msg);
394            }
395        } else {
396            merged.push(msg.clone());
397        }
398    }
399
400    merged
401}
402
403// Sort `tool_result` blocks within user messages by the `tool_calls[].id` order
404// of the preceding assistant message.
405pub(crate) fn sort_tool_results_by_call_order(mut messages: Vec<JsonValue>) -> Vec<JsonValue> {
406    use std::collections::HashMap;
407    let mut last_order: HashMap<String, usize> = HashMap::new();
408
409    for msg in &mut messages {
410        let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
411        if role == "assistant" {
412            if let Some(tcs) = msg.get("tool_calls").and_then(|t| t.as_array()) {
413                last_order.clear();
414                for (idx, tc) in tcs.iter().enumerate() {
415                    let id = tc
416                        .get("id")
417                        .and_then(|v| v.as_str())
418                        .or_else(|| {
419                            tc.get("function")
420                                .and_then(|f| f.get("id"))
421                                .and_then(|v| v.as_str())
422                        })
423                        .unwrap_or("");
424                    if !id.is_empty() {
425                        last_order.insert(id.to_string(), idx);
426                    }
427                }
428            }
429        } else if role == "user" && !last_order.is_empty() {
430            let Some(blocks) = msg
431                .as_object_mut()
432                .and_then(|o| o.get_mut("content_blocks"))
433                .and_then(|v| v.as_array_mut())
434            else {
435                continue;
436            };
437
438            // Collect tool_result blocks with their positions.
439            let tool_positions: Vec<usize> = blocks
440                .iter()
441                .enumerate()
442                .filter(|(_, b)| b.get("type").and_then(|v| v.as_str()) == Some("tool_result"))
443                .map(|(i, _)| i)
444                .collect();
445
446            if tool_positions.len() > 1 {
447                let start = *tool_positions
448                    .first()
449                    .expect("tool_positions has length > 1");
450                let end = *tool_positions
451                    .last()
452                    .expect("tool_positions has length > 1");
453                let is_contiguous = end - start + 1 == tool_positions.len();
454
455                if is_contiguous {
456                    // Fast path: sort the contiguous slice in place
457                    blocks[start..=end].sort_by_key(|b| {
458                        let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
459                        *last_order.get(id).unwrap_or(&0)
460                    });
461                } else {
462                    // Fallback: extract, sort, and replace for non-contiguous blocks
463                    let mut tool_blocks: Vec<JsonValue> = tool_positions
464                        .iter()
465                        .map(|&i| std::mem::take(&mut blocks[i]))
466                        .collect();
467
468                    tool_blocks.sort_by_key(|b| {
469                        let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
470                        *last_order.get(id).unwrap_or(&0)
471                    });
472
473                    for (sorted_idx, &pos) in tool_positions.iter().enumerate() {
474                        blocks[pos] = std::mem::take(&mut tool_blocks[sorted_idx]);
475                    }
476                }
477            }
478        }
479    }
480
481    messages
482}
483
484// Drop reasoning and non-essential messages before the last user message.
485pub(crate) fn drop_thinking_messages(messages: Vec<JsonValue>) -> Vec<JsonValue> {
486    let last_user_idx = find_last_user_index(&messages);
487    let mut out = Vec::with_capacity(messages.len());
488    const KEEP: &[&str] = &[
489        "user",
490        "system",
491        "tool",
492        "latest_reminder",
493        "direct_search_results",
494    ];
495
496    for (idx, mut msg) in messages.into_iter().enumerate() {
497        let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
498        if KEEP.contains(&role) || last_user_idx.is_none_or(|u| idx >= u) {
499            out.push(msg);
500        } else if role == "assistant" {
501            if let Some(obj) = msg.as_object_mut() {
502                obj.remove("reasoning_content");
503            }
504            out.push(msg);
505        }
506        // developer and other roles before last_user_idx are dropped.
507    }
508    out
509}
510
511pub(crate) fn resolve_thinking_mode(
512    args: Option<&std::collections::HashMap<String, serde_json::Value>>,
513    default_mode: ThinkingMode,
514) -> ThinkingMode {
515    if let Some(enabled) = crate::thinking_bool_from_args(args) {
516        return if enabled {
517            ThinkingMode::Thinking
518        } else {
519            ThinkingMode::Chat
520        };
521    }
522    if let Some(args) = args
523        && let Some(mode) = args.get("thinking_mode").and_then(|v| v.as_str())
524    {
525        match mode {
526            "chat" => return ThinkingMode::Chat,
527            "thinking" => return ThinkingMode::Thinking,
528            _ => {}
529        }
530    }
531    default_mode
532}
533
534pub(crate) fn inject_tools_and_response_format(
535    messages_array: &mut Vec<JsonValue>,
536    req: &dyn crate::OAIChatLikeRequest,
537) -> Result<()> {
538    let tools_json = req
539        .tools()
540        .map(|t| serde_json::to_value(&t))
541        .transpose()
542        .context("Failed to convert tools to JSON")?;
543
544    // OpenAI semantics for `tool_choice: "none"`: the model must not call
545    // tools. Strip the tool definitions from the prompt so the model never
546    // sees the DSML tool instructions and cannot emit raw tool markup.
547    // response_format is kept intact. Mirrors the jinja path's
548    // exclude_tools_when_tool_choice_none handling (template/oai.rs).
549    let tools_json = match req.tool_choice() {
550        Some(ref tc) if tc.as_str() == Some("none") => None,
551        _ => tools_json,
552    };
553
554    let response_format_json = req
555        .response_format()
556        .map(|rf| serde_json::to_value(&rf))
557        .transpose()
558        .context("Failed to convert response_format to JSON")?;
559
560    if tools_json.is_some() || response_format_json.is_some() {
561        let system_idx = messages_array
562            .iter()
563            .position(|msg| msg.get("role").and_then(|r| r.as_str()) == Some("system"));
564
565        if let Some(idx) = system_idx {
566            if let Some(msg) = messages_array.get_mut(idx)
567                && let Some(obj) = msg.as_object_mut()
568            {
569                if let Some(tools) = tools_json {
570                    obj.insert("tools".to_string(), tools);
571                }
572                if let Some(rf) = response_format_json {
573                    obj.insert("response_format".to_string(), rf);
574                }
575            }
576        } else {
577            let mut system_msg = serde_json::json!({
578                "role": "system",
579                "content": ""
580            });
581            if let Some(obj) = system_msg.as_object_mut() {
582                if let Some(tools) = tools_json {
583                    obj.insert("tools".to_string(), tools);
584                }
585                if let Some(rf) = response_format_json {
586                    obj.insert("response_format".to_string(), rf);
587                }
588            }
589            messages_array.insert(0, system_msg);
590        }
591    }
592    Ok(())
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598    use serde_json::json;
599
600    #[test]
601    fn test_extract_visible_text_from_content_array() {
602        let content = json!([
603            {"type": "text", "text": "who "},
604            {"type": "text", "text": "are "},
605            {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
606            {"type": "text", "text": "you?"}
607        ]);
608        assert_eq!(extract_visible_text(&content), "who are you?");
609    }
610
611    #[test]
612    fn test_render_tools_preserves_placeholder_text_inside_tool_schema() {
613        let tools = json!([{
614            "type": "function",
615            "function": {
616                "name": "placeholder_tool",
617                "description": "literal {dsml_token} {thinking_start_token} {thinking_end_token}",
618                "parameters": {"type": "object", "properties": {}}
619            }
620        }]);
621        let rendered = render_tools(
622            "static {dsml_token} {thinking_start_token} {thinking_end_token}\n{tool_schemas}",
623            tools.as_array().unwrap(),
624        );
625
626        assert!(rendered.starts_with(&format!(
627            "static {} {} {}\n",
628            tokens::DSML_TOKEN,
629            tokens::THINKING_START,
630            tokens::THINKING_END
631        )));
632        assert!(
633            rendered.contains("literal {dsml_token} {thinking_start_token} {thinking_end_token}")
634        );
635    }
636}