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