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