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_owned_messages as encode_v4_messages};
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
75enum OrderMessage<'a> {
76    Assistant(Option<Vec<&'a str>>),
77    User { has_task: bool },
78    Tool(&'a str),
79    Boundary,
80}
81
82// Both raw encoding and typed media collection use this order. Boundaries end
83// the sortable group but retain call ranks, matching the reference encoder.
84fn message_order<'a>(messages: impl Iterator<Item = OrderMessage<'a>>) -> Vec<usize> {
85    let mut order = Vec::new();
86    let mut calls = std::collections::HashMap::new();
87    let mut slots = Vec::new();
88    let mut group_has_task = false;
89    let mut in_group = false;
90    fn flush(order: &mut [usize], slots: &mut Vec<(usize, usize)>) {
91        let mut sorted = slots.clone();
92        sorted.sort_by_key(|&(_, rank)| rank);
93        for ((target, _), (source, _)) in slots.drain(..).zip(sorted) {
94            order[target] = source;
95        }
96    }
97    for (index, message) in messages.enumerate() {
98        order.push(index);
99        match message {
100            OrderMessage::Tool(id) => {
101                slots.push((index, *calls.get(id).unwrap_or(&0)));
102                in_group = true;
103            }
104            OrderMessage::User { has_task } => {
105                // A task stays on the merged group until the next user starts a new one.
106                if group_has_task {
107                    flush(&mut order, &mut slots);
108                    in_group = false;
109                }
110                // A user merged into the group drops its task, as in the reference.
111                if !in_group {
112                    group_has_task = has_task;
113                }
114                in_group = true;
115            }
116            boundary => {
117                flush(&mut order, &mut slots);
118                group_has_task = false;
119                in_group = false;
120                // As in the reference, an empty tool_calls list keeps the previous ranks.
121                if let OrderMessage::Assistant(Some(ids)) = boundary
122                    && !ids.is_empty()
123                {
124                    calls.clear();
125                    for (rank, id) in ids.into_iter().enumerate() {
126                        if !id.is_empty() {
127                            calls.insert(id, rank);
128                        }
129                    }
130                }
131            }
132        }
133    }
134    flush(&mut order, &mut slots);
135    order
136}
137
138fn raw_message_order(messages: &[Value]) -> Vec<usize> {
139    message_order(
140        messages
141            .iter()
142            .map(|message| match message["role"].as_str() {
143                Some("assistant") => {
144                    OrderMessage::Assistant(message["tool_calls"].as_array().map(|calls| {
145                        calls
146                            .iter()
147                            .map(|call| {
148                                call["id"]
149                                    .as_str()
150                                    .or_else(|| call["function"]["id"].as_str())
151                                    .unwrap_or("")
152                            })
153                            .collect()
154                    }))
155                }
156                Some("user") => OrderMessage::User {
157                    has_task: !message["task"].is_null(),
158                },
159                Some("tool") => OrderMessage::Tool(message["tool_call_id"].as_str().unwrap_or("")),
160                _ => OrderMessage::Boundary,
161            }),
162    )
163}
164
165fn normalize_content(messages: &mut [Value]) -> Result<()> {
166    for message in messages {
167        for field in ["tools", "tool_calls"] {
168            for tool in message
169                .get(field)
170                .and_then(Value::as_array)
171                .into_iter()
172                .flatten()
173            {
174                ensure!(
175                    tool.get("namespace").is_none_or(Value::is_null)
176                        && tool
177                            .get("function")
178                            .and_then(|function| function.get("namespace"))
179                            .is_none_or(Value::is_null),
180                    "DeepSeek V4.1 native formatter does not support explicit tool namespaces; use a qualified function name"
181                );
182            }
183        }
184        ensure!(
185            message.get("content_blocks").is_none(),
186            "DeepSeek V4.1 expects OpenAI content blocks in content"
187        );
188        ensure!(
189            !message
190                .get("reasoning_content")
191                .and_then(Value::as_str)
192                .is_some_and(|text| text.contains(IMAGE_PLACEHOLDER)),
193            "Images must be supplied as image content blocks, not literal image placeholders"
194        );
195        if message.get("role").and_then(Value::as_str) == Some("developer") {
196            message["role"] = Value::String("system".into());
197        }
198        if let Some(content) = message.get("content") {
199            let text = match content {
200                Value::Null => String::new(),
201                Value::String(text) => {
202                    validate_text(text)?;
203                    continue;
204                }
205                Value::Array(blocks) => {
206                    let mut texts = Vec::with_capacity(blocks.len());
207                    for block in blocks {
208                        match block.get("type").and_then(Value::as_str) {
209                            Some("text") => texts.push(validate_text(
210                                block
211                                    .get("text")
212                                    .and_then(Value::as_str)
213                                    .context("Text block requires text")?,
214                            )?),
215                            Some("image_url") => {
216                                let source = &block["image_url"];
217                                let url = source.as_str().or_else(|| source["url"].as_str());
218                                ensure!(
219                                    url.is_some_and(|url| !url.is_empty())
220                                        || block["uuid"]
221                                            .as_str()
222                                            .is_some_and(|uuid| !uuid.is_empty()),
223                                    "Image block does not contain a valid source"
224                                );
225                                // The official encoder replaces each image in order.
226                                // Normalize only this rendering copy: Dynamo's media
227                                // collector still needs the original URLs/data URLs.
228                                texts.push(IMAGE_PLACEHOLDER);
229                            }
230                            _ => anyhow::bail!(
231                                "DeepSeek V4.1 native formatter supports text and image_url content only"
232                            ),
233                        }
234                    }
235                    texts.join("\n\n")
236                }
237                _ => anyhow::bail!("DeepSeek V4.1 message content must be text or content blocks"),
238            };
239            message["content"] = Value::String(text);
240        }
241    }
242    Ok(())
243}
244
245/// Encode text/image messages with the model's numeric reasoning effort (1–100).
246pub fn encode_messages(
247    messages: &[Value],
248    thinking_mode: ThinkingMode,
249    drop_thinking: bool,
250    reasoning_effort: u8,
251) -> Result<String> {
252    ensure!(
253        (1..=100).contains(&reasoning_effort),
254        "DeepSeek V4.1 reasoning effort must be within 1–100"
255    );
256    encode_owned_messages(
257        messages.to_vec(),
258        thinking_mode,
259        drop_thinking,
260        reasoning_effort,
261    )
262}
263
264fn encode_owned_messages(
265    mut messages: Vec<Value>,
266    thinking_mode: ThinkingMode,
267    drop_thinking: bool,
268    reasoning_effort: u8,
269) -> Result<String> {
270    ensure!(
271        (1..=100).contains(&reasoning_effort),
272        "DeepSeek V4.1 reasoning effort must be within 1–100"
273    );
274    normalize_content(&mut messages)?;
275    if messages.iter().any(|message| message["role"] == "tool") {
276        let order = raw_message_order(&messages);
277        messages = order
278            .into_iter()
279            .map(|index| std::mem::take(&mut messages[index]))
280            .collect();
281    }
282    encode_v4_messages(
283        messages,
284        thinking_mode,
285        true,
286        drop_thinking,
287        Encoding::V41(reasoning_effort),
288    )
289}
290
291/// Native text/image formatter with OpenAI reasoning-effort names mapped as in the
292/// DeepSeek V4.1 reference encoder.
293#[derive(Debug, Default)]
294pub struct DeepSeekV41Formatter;
295
296impl crate::OAIPromptFormatter for DeepSeekV41Formatter {
297    fn supports_add_generation_prompt(&self) -> bool {
298        false
299    }
300
301    fn media_message_order(&self, request: &dyn crate::OAIChatLikeRequest) -> Option<Vec<usize>> {
302        use dynamo_protocols::types::ChatCompletionRequestMessage as Message;
303        let Some(messages) = request.typed_messages() else {
304            // Custom requests may expose only the same raw view used by render.
305            // Invalid raw messages are rejected by render before media collection.
306            let messages = serde_json::to_value(request.messages()).ok()?;
307            let messages = messages.as_array()?;
308            return messages
309                .iter()
310                .any(|message| message["role"] == "tool")
311                .then(|| raw_message_order(messages));
312        };
313        if !messages
314            .iter()
315            .any(|message| matches!(message, Message::Tool(_)))
316        {
317            return None;
318        }
319        Some(message_order(messages.iter().map(|message| {
320            match message {
321                Message::Assistant(assistant) => OrderMessage::Assistant(
322                    assistant
323                        .tool_calls
324                        .as_ref()
325                        .map(|calls| calls.iter().map(|call| call.id.as_str()).collect()),
326                ),
327                Message::User(_) => OrderMessage::User { has_task: false },
328                Message::Tool(tool) => OrderMessage::Tool(tool.tool_call_id.as_str()),
329                _ => OrderMessage::Boundary,
330            }
331        })))
332    }
333
334    fn render(&self, req: &dyn crate::OAIChatLikeRequest) -> Result<String> {
335        let messages_json = crate::messages_to_json(req)?;
336        crate::reject_unsupported_partial_assistant(&messages_json)?;
337        crate::reject_unsupported_message_tools(&messages_json, &["developer"])?;
338
339        let args = req.chat_template_args();
340        let effort = req
341            .reasoning_effort()
342            .map(|value| serde_json::to_value(value).context("Serialize reasoning effort"))
343            .transpose()?
344            .or_else(|| args.and_then(|args| args.get("reasoning_effort").cloned()));
345        let mut thinking_mode = resolve_thinking_mode(args, ThinkingMode::Thinking);
346        let budget = match effort.as_ref() {
347            None | Some(Value::Null) => 75,
348            Some(value) => match value.as_str() {
349                Some("none") => {
350                    thinking_mode = ThinkingMode::Chat;
351                    75
352                }
353                Some("low") => 50,
354                Some("high") => 75,
355                Some("max") => 100,
356                _ => value
357                    .as_u64()
358                    .filter(|v| (1..=100).contains(v))
359                    .context("DeepSeek V4.1 reasoning effort must be low, high, max, none, or an integer within 1–100")?
360                    as u8,
361            },
362        };
363        let drop_thinking = match args.and_then(|args| args.get("drop_thinking")) {
364            None => true,
365            Some(value) => value.as_bool().context("drop_thinking must be a boolean")?,
366        };
367        let Value::Array(mut messages) = messages_json else {
368            anyhow::bail!("Messages is not an array");
369        };
370        let tools_enabled =
371            req.tool_choice().as_ref().and_then(|value| value.as_str()) != Some("none");
372        let tools = req
373            .tools()
374            .filter(|tools| tools_enabled && tools.len().is_some_and(|length| length > 0))
375            .map(serde_json::to_value)
376            .transpose()?;
377        let response_format = req
378            .response_format()
379            .map(serde_json::to_value)
380            .transpose()?;
381        if tools.is_some() || response_format.is_some() {
382            let developer_tools_would_be_overwritten = tools.is_some()
383                && messages.first().is_some_and(|message| {
384                    message.get("role").and_then(Value::as_str) == Some("developer")
385                        && message.get("tools").is_some_and(|tools| {
386                            !tools.is_null() && !tools.as_array().is_some_and(Vec::is_empty)
387                        })
388                });
389            if developer_tools_would_be_overwritten
390                || !matches!(
391                    messages
392                        .first()
393                        .and_then(|m| m.get("role"))
394                        .and_then(Value::as_str),
395                    Some("system" | "developer")
396                )
397            {
398                messages.insert(0, serde_json::json!({"role": "system", "content": ""}));
399            }
400            if let Some(tools) = tools {
401                messages[0]["tools"] = tools;
402            }
403            if let Some(response_format) = response_format {
404                messages[0]["response_format"] = response_format;
405            }
406        }
407        encode_owned_messages(messages, thinking_mode, drop_thinking, budget)
408    }
409}