Skip to main content

dynamo_renderer/deepseek/
v41.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Text prompt encoding for DeepSeek V4.1.
5
6use anyhow::{Context, Result, ensure};
7use serde_json::Value;
8
9use super::common::{ThinkingMode, resolve_thinking_mode, to_json};
10use super::v4::{Encoding, encode_messages_with_encoding};
11
12pub(super) fn find_last_user_index(messages: &[Value]) -> Option<usize> {
13    messages.iter().enumerate().rposition(|(index, message)| {
14        let role = message.get("role").and_then(Value::as_str);
15        role == Some("user") || (role == Some("system") && index > 0)
16    })
17}
18
19pub(super) fn drop_thinking_messages(mut messages: Vec<Value>) -> Vec<Value> {
20    if let Some(last_user) = find_last_user_index(&messages) {
21        for message in &mut messages[..last_user] {
22            if message.get("role").and_then(Value::as_str) == Some("assistant") {
23                message.as_object_mut().unwrap().remove("reasoning_content");
24            }
25        }
26    }
27    messages
28}
29
30pub(super) fn encode_arguments(tool_call: &Value) -> Result<String> {
31    let original = tool_call
32        .get("arguments")
33        .context("Missing tool arguments")?;
34    let mut arguments = original.clone();
35    for _ in 0..2 {
36        let Some(text) = arguments.as_str() else {
37            break;
38        };
39        let Ok(decoded) = serde_json::from_str(text) else {
40            break;
41        };
42        arguments = decoded;
43    }
44    if !arguments.is_object() {
45        arguments = serde_json::json!({"arguments": original});
46    }
47    let parameters = arguments
48        .as_object()
49        .unwrap()
50        .iter()
51        .map(|(name, value)| {
52            let content = value
53                .as_str()
54                .map(str::to_owned)
55                .unwrap_or_else(|| to_json(value));
56            format!(
57                "<|DSML| parameter name=\"{name}\" string=\"{}\">{content}</|DSML| parameter>",
58                value.is_string()
59            )
60        })
61        .collect::<Vec<_>>();
62    Ok(parameters.join("\n"))
63}
64
65fn normalize_text(messages: &mut [Value]) -> Result<()> {
66    for message in messages {
67        for field in ["tools", "tool_calls"] {
68            for tool in message
69                .get(field)
70                .and_then(Value::as_array)
71                .into_iter()
72                .flatten()
73            {
74                ensure!(
75                    tool.get("namespace").is_none_or(Value::is_null)
76                        && tool
77                            .get("function")
78                            .and_then(|function| function.get("namespace"))
79                            .is_none_or(Value::is_null),
80                    "DeepSeek V4.1 native formatter does not support explicit tool namespaces; use a qualified function name"
81                );
82            }
83        }
84        ensure!(
85            message.get("content_blocks").is_none(),
86            "DeepSeek V4.1 expects OpenAI text content blocks in content"
87        );
88        ensure!(
89            !message
90                .get("reasoning_content")
91                .and_then(Value::as_str)
92                .is_some_and(|text| text.contains("<|deepseek_image|>")),
93            "DeepSeek V4.1 native formatter supports text content only"
94        );
95        if message.get("role").and_then(Value::as_str) == Some("developer") {
96            message["role"] = Value::String("system".into());
97        }
98        if let Some(content) = message.get("content") {
99            let text = match content {
100                Value::Null => String::new(),
101                Value::String(text) => text.clone(),
102                Value::Array(blocks) => {
103                    let mut texts = Vec::with_capacity(blocks.len());
104                    for block in blocks {
105                        ensure!(
106                            block.get("type").and_then(Value::as_str) == Some("text"),
107                            "DeepSeek V4.1 native formatter supports text content only"
108                        );
109                        texts.push(
110                            block
111                                .get("text")
112                                .and_then(Value::as_str)
113                                .context("Text block requires text")?,
114                        );
115                    }
116                    texts.join("\n\n")
117                }
118                _ => anyhow::bail!("DeepSeek V4.1 message content must be text"),
119            };
120            ensure!(
121                !text.contains("<|deepseek_image|>"),
122                "DeepSeek V4.1 native formatter supports text content only"
123            );
124            message["content"] = Value::String(text);
125        }
126    }
127    Ok(())
128}
129
130/// Encode text messages with the model's numeric reasoning effort (1–100).
131pub fn encode_messages(
132    messages: &[Value],
133    thinking_mode: ThinkingMode,
134    drop_thinking: bool,
135    reasoning_effort: u8,
136) -> Result<String> {
137    ensure!(
138        (1..=100).contains(&reasoning_effort),
139        "DeepSeek V4.1 reasoning effort must be within 1–100"
140    );
141    let mut messages = messages.to_vec();
142    normalize_text(&mut messages)?;
143    encode_messages_with_encoding(
144        &messages,
145        thinking_mode,
146        true,
147        drop_thinking,
148        Encoding::V41(reasoning_effort),
149    )
150}
151
152/// Native text formatter with OpenAI reasoning-effort names mapped as in the
153/// DeepSeek V4.1 reference encoder.
154#[derive(Debug, Default)]
155pub struct DeepSeekV41Formatter;
156
157impl crate::OAIPromptFormatter for DeepSeekV41Formatter {
158    fn supports_add_generation_prompt(&self) -> bool {
159        false
160    }
161
162    fn render(&self, req: &dyn crate::OAIChatLikeRequest) -> Result<String> {
163        let args = req.chat_template_args();
164        let effort = req
165            .reasoning_effort()
166            .map(|value| serde_json::to_value(value).context("Serialize reasoning effort"))
167            .transpose()?
168            .or_else(|| args.and_then(|args| args.get("reasoning_effort").cloned()));
169        let mut thinking_mode = resolve_thinking_mode(args, ThinkingMode::Thinking);
170        let budget = match effort.as_ref() {
171            None | Some(Value::Null) => 75,
172            Some(value) => match value.as_str() {
173                Some("none") => {
174                    thinking_mode = ThinkingMode::Chat;
175                    75
176                }
177                Some("low") => 50,
178                Some("high") => 75,
179                Some("max") => 100,
180                _ => value
181                    .as_u64()
182                    .filter(|v| (1..=100).contains(v))
183                    .context("DeepSeek V4.1 reasoning effort must be low, high, max, none, or an integer within 1–100")?
184                    as u8,
185            },
186        };
187        let drop_thinking = match args.and_then(|args| args.get("drop_thinking")) {
188            None => true,
189            Some(value) => value.as_bool().context("drop_thinking must be a boolean")?,
190        };
191        let mut messages: Vec<Value> =
192            serde_json::from_value(serde_json::to_value(req.messages())?)?;
193        let tools_enabled =
194            req.tool_choice().as_ref().and_then(|value| value.as_str()) != Some("none");
195        let tools = req
196            .tools()
197            .filter(|tools| tools_enabled && tools.len().is_some_and(|length| length > 0))
198            .map(serde_json::to_value)
199            .transpose()?;
200        let response_format = req
201            .response_format()
202            .map(serde_json::to_value)
203            .transpose()?;
204        if tools.is_some() || response_format.is_some() {
205            if !matches!(
206                messages
207                    .first()
208                    .and_then(|m| m.get("role"))
209                    .and_then(Value::as_str),
210                Some("system" | "developer")
211            ) {
212                messages.insert(0, serde_json::json!({"role": "system", "content": ""}));
213            }
214            if let Some(tools) = tools {
215                messages[0]["tools"] = tools;
216            }
217            if let Some(response_format) = response_format {
218                messages[0]["response_format"] = response_format;
219            }
220        }
221        encode_messages(&messages, thinking_mode, drop_thinking, budget)
222    }
223}