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 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 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 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
232pub 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#[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 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}