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 and image 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
12const IMAGE_PLACEHOLDER: &str = "<|deepseek_image|>";
13
14fn validate_text(text: &str) -> Result<&str> {
15    ensure!(
16        !text.contains(IMAGE_PLACEHOLDER),
17        "Images must be supplied as image content blocks, not literal image placeholders"
18    );
19    Ok(text)
20}
21
22pub(super) fn find_last_user_index(messages: &[Value]) -> Option<usize> {
23    messages.iter().enumerate().rposition(|(index, message)| {
24        let role = message.get("role").and_then(Value::as_str);
25        role == Some("user") || (role == Some("system") && index > 0)
26    })
27}
28
29pub(super) fn drop_thinking_messages(mut messages: Vec<Value>) -> Vec<Value> {
30    if let Some(last_user) = find_last_user_index(&messages) {
31        for message in &mut messages[..last_user] {
32            if message.get("role").and_then(Value::as_str) == Some("assistant") {
33                message.as_object_mut().unwrap().remove("reasoning_content");
34            }
35        }
36    }
37    messages
38}
39
40pub(super) fn encode_arguments(tool_call: &Value) -> Result<String> {
41    let original = tool_call
42        .get("arguments")
43        .context("Missing tool arguments")?;
44    let mut arguments = original.clone();
45    for _ in 0..2 {
46        let Some(text) = arguments.as_str() else {
47            break;
48        };
49        let Ok(decoded) = serde_json::from_str(text) else {
50            break;
51        };
52        arguments = decoded;
53    }
54    if !arguments.is_object() {
55        arguments = serde_json::json!({"arguments": original});
56    }
57    let parameters = arguments
58        .as_object()
59        .unwrap()
60        .iter()
61        .map(|(name, value)| {
62            let content = value
63                .as_str()
64                .map(str::to_owned)
65                .unwrap_or_else(|| to_json(value));
66            format!(
67                "<|DSML| parameter name=\"{name}\" string=\"{}\">{content}</|DSML| parameter>",
68                value.is_string()
69            )
70        })
71        .collect::<Vec<_>>();
72    Ok(parameters.join("\n"))
73}
74
75fn normalize_content(messages: &mut [Value]) -> Result<()> {
76    for message in messages {
77        for field in ["tools", "tool_calls"] {
78            for tool in message
79                .get(field)
80                .and_then(Value::as_array)
81                .into_iter()
82                .flatten()
83            {
84                ensure!(
85                    tool.get("namespace").is_none_or(Value::is_null)
86                        && tool
87                            .get("function")
88                            .and_then(|function| function.get("namespace"))
89                            .is_none_or(Value::is_null),
90                    "DeepSeek V4.1 native formatter does not support explicit tool namespaces; use a qualified function name"
91                );
92            }
93        }
94        ensure!(
95            message.get("content_blocks").is_none(),
96            "DeepSeek V4.1 expects OpenAI content blocks in content"
97        );
98        ensure!(
99            !message
100                .get("reasoning_content")
101                .and_then(Value::as_str)
102                .is_some_and(|text| text.contains(IMAGE_PLACEHOLDER)),
103            "Images must be supplied as image content blocks, not literal image placeholders"
104        );
105        if message.get("role").and_then(Value::as_str) == Some("developer") {
106            message["role"] = Value::String("system".into());
107        }
108        if let Some(content) = message.get("content") {
109            let text = match content {
110                Value::Null => String::new(),
111                Value::String(text) => validate_text(text)?.to_owned(),
112                Value::Array(blocks) => {
113                    let mut texts = Vec::with_capacity(blocks.len());
114                    for block in blocks {
115                        match block.get("type").and_then(Value::as_str) {
116                            Some("text") => texts.push(validate_text(
117                                block
118                                    .get("text")
119                                    .and_then(Value::as_str)
120                                    .context("Text block requires text")?,
121                            )?),
122                            Some("image_url") => {
123                                let source = &block["image_url"];
124                                let url = source.as_str().or_else(|| source["url"].as_str());
125                                ensure!(
126                                    url.is_some_and(|url| !url.is_empty())
127                                        || block["uuid"]
128                                            .as_str()
129                                            .is_some_and(|uuid| !uuid.is_empty()),
130                                    "Image block does not contain a valid source"
131                                );
132                                // The official encoder replaces each image in order.
133                                // Normalize only this rendering copy: Dynamo's media
134                                // collector still needs the original URLs/data URLs.
135                                texts.push(IMAGE_PLACEHOLDER);
136                            }
137                            _ => anyhow::bail!(
138                                "DeepSeek V4.1 native formatter supports text and image_url content only"
139                            ),
140                        }
141                    }
142                    texts.join("\n\n")
143                }
144                _ => anyhow::bail!("DeepSeek V4.1 message content must be text or content blocks"),
145            };
146            message["content"] = Value::String(text);
147        }
148    }
149    Ok(())
150}
151
152/// Encode text/image messages with the model's numeric reasoning effort (1–100).
153pub fn encode_messages(
154    messages: &[Value],
155    thinking_mode: ThinkingMode,
156    drop_thinking: bool,
157    reasoning_effort: u8,
158) -> Result<String> {
159    ensure!(
160        (1..=100).contains(&reasoning_effort),
161        "DeepSeek V4.1 reasoning effort must be within 1–100"
162    );
163    let mut messages = messages.to_vec();
164    normalize_content(&mut messages)?;
165    encode_messages_with_encoding(
166        &messages,
167        thinking_mode,
168        true,
169        drop_thinking,
170        Encoding::V41(reasoning_effort),
171    )
172}
173
174/// Native text/image formatter with OpenAI reasoning-effort names mapped as in the
175/// DeepSeek V4.1 reference encoder.
176#[derive(Debug, Default)]
177pub struct DeepSeekV41Formatter;
178
179impl crate::OAIPromptFormatter for DeepSeekV41Formatter {
180    fn supports_add_generation_prompt(&self) -> bool {
181        false
182    }
183
184    fn render(&self, req: &dyn crate::OAIChatLikeRequest) -> Result<String> {
185        let messages_value = req.messages();
186        let messages_json =
187            serde_json::to_value(&messages_value).context("Failed to convert messages to JSON")?;
188        crate::reject_unsupported_partial_assistant(&messages_json)?;
189        crate::reject_unsupported_message_tools(&messages_json, &["developer"])?;
190
191        let args = req.chat_template_args();
192        let effort = req
193            .reasoning_effort()
194            .map(|value| serde_json::to_value(value).context("Serialize reasoning effort"))
195            .transpose()?
196            .or_else(|| args.and_then(|args| args.get("reasoning_effort").cloned()));
197        let mut thinking_mode = resolve_thinking_mode(args, ThinkingMode::Thinking);
198        let budget = match effort.as_ref() {
199            None | Some(Value::Null) => 75,
200            Some(value) => match value.as_str() {
201                Some("none") => {
202                    thinking_mode = ThinkingMode::Chat;
203                    75
204                }
205                Some("low") => 50,
206                Some("high") => 75,
207                Some("max") => 100,
208                _ => value
209                    .as_u64()
210                    .filter(|v| (1..=100).contains(v))
211                    .context("DeepSeek V4.1 reasoning effort must be low, high, max, none, or an integer within 1–100")?
212                    as u8,
213            },
214        };
215        let drop_thinking = match args.and_then(|args| args.get("drop_thinking")) {
216            None => true,
217            Some(value) => value.as_bool().context("drop_thinking must be a boolean")?,
218        };
219        let mut messages: Vec<Value> = serde_json::from_value(messages_json)?;
220        let tools_enabled =
221            req.tool_choice().as_ref().and_then(|value| value.as_str()) != Some("none");
222        let tools = req
223            .tools()
224            .filter(|tools| tools_enabled && tools.len().is_some_and(|length| length > 0))
225            .map(serde_json::to_value)
226            .transpose()?;
227        let response_format = req
228            .response_format()
229            .map(serde_json::to_value)
230            .transpose()?;
231        if tools.is_some() || response_format.is_some() {
232            let developer_tools_would_be_overwritten = tools.is_some()
233                && messages.first().is_some_and(|message| {
234                    message.get("role").and_then(Value::as_str) == Some("developer")
235                        && message.get("tools").is_some_and(|tools| {
236                            !tools.is_null() && !tools.as_array().is_some_and(Vec::is_empty)
237                        })
238                });
239            if developer_tools_would_be_overwritten
240                || !matches!(
241                    messages
242                        .first()
243                        .and_then(|m| m.get("role"))
244                        .and_then(Value::as_str),
245                    Some("system" | "developer")
246                )
247            {
248                messages.insert(0, serde_json::json!({"role": "system", "content": ""}));
249            }
250            if let Some(tools) = tools {
251                messages[0]["tools"] = tools;
252            }
253            if let Some(response_format) = response_format {
254                messages[0]["response_format"] = response_format;
255            }
256        }
257        encode_messages(&messages, thinking_mode, drop_thinking, budget)
258    }
259}