dynamo_renderer/deepseek/
v41.rs1use 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
82fn 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 if group_has_task {
107 flush(&mut order, &mut slots);
108 in_group = false;
109 }
110 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 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 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
245pub 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#[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 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}