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