dynamo_renderer/deepseek/
common.rs1use anyhow::{Context, Result};
7use serde_json::Value as JsonValue;
8
9pub mod tokens {
11 pub const BOS: &str = "<|begin▁of▁sentence|>";
12 pub const EOS: &str = "<|end▁of▁sentence|>";
13 pub const THINKING_START: &str = "<think>";
14 pub const THINKING_END: &str = "</think>";
15 pub const DSML_TOKEN: &str = "|DSML|";
16 pub const USER_START: &str = "<|User|>";
17 pub const ASSISTANT_START: &str = "<|Assistant|>";
18 pub const LATEST_REMINDER: &str = "<|latest_reminder|>";
19
20 pub const TASK_ACTION: &str = "<|action|>";
22 pub const TASK_QUERY: &str = "<|query|>";
23 pub const TASK_AUTHORITY: &str = "<|authority|>";
24 pub const TASK_DOMAIN: &str = "<|domain|>";
25 pub const TASK_TITLE: &str = "<|title|>";
26 pub const TASK_READ_URL: &str = "<|read_url|>";
27}
28
29pub(crate) const TOOL_CALLS_BLOCK_NAME: &str = "tool_calls";
30
31pub(crate) const RESPONSE_FORMAT_TEMPLATE: &str =
32 "## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n{schema}";
33
34pub(crate) const TOOLS_TEMPLATE: &str = r#"## Tools
35
36You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml_token}tool_calls>" block like the following:
37
38<{dsml_token}tool_calls>
39<{dsml_token}invoke name="$TOOL_NAME">
40<{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>
41...
42</{dsml_token}invoke>
43<{dsml_token}invoke name="$TOOL_NAME2">
44...
45</{dsml_token}invoke>
46</{dsml_token}tool_calls>
47
48String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
49
50If thinking_mode is enabled (triggered by {thinking_start_token}), you MUST output your complete reasoning inside {thinking_start_token}...{thinking_end_token} BEFORE any tool calls or final response.
51
52Otherwise, output directly after {thinking_end_token} with tool calls or final response.
53
54### Available Tool Schemas
55
56{tool_schemas}
57
58You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
59"#;
60
61pub(crate) const TOOLS_SYSTEM_TEMPLATE: &str = r#"## Tools
63
64You have access to a set of tools you can use to answer the user's question.
65You can invoke functions by writing a "<{dsml_token}function_calls>" block like the following as part of your reply to the user:
66<{dsml_token}function_calls>
67<{dsml_token}invoke name="$FUNCTION_NAME">
68<{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>
69...
70</{dsml_token}invoke>
71<{dsml_token}invoke name="$FUNCTION_NAME2">
72...
73</{dsml_token}invoke>
74</{dsml_token}function_calls>
75
76String and scalar parameters should be specified as is without any escaping or quotes, while lists and objects should use JSON format. The "string" attribute should be set to "true" for string type parameters and "false" for other types (numbers, booleans, arrays, objects).
77
78If the thinking_mode is enabled, then after function results you should strongly consider outputting a thinking block. Here is an example:
79
80<{dsml_token}function_calls>
81...
82</{dsml_token}function_calls>
83
84<function_results>
85...
86</function_results>
87
88{thinking_start_token}...thinking about results{thinking_end_token}
89
90Here are the functions available in JSONSchema format:
91<functions>
92{tool_schemas}
93</functions>
94"#;
95
96pub(crate) const TOOL_CALL_TEMPLATE: &str =
97 "<{dsml_token}invoke name=\"{name}\">\n{arguments}\n</{dsml_token}invoke>";
98
99pub(crate) const TOOL_OUTPUT_TEMPLATE: &str = "\n<result>{content}</result>";
100
101pub(crate) const REASONING_EFFORT_MAX: &str = "Reasoning Effort: Absolute maximum with no shortcuts permitted.\nYou MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\nExplicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.\n\n";
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum ThinkingMode {
106 Chat,
107 Thinking,
108}
109
110impl ThinkingMode {
111 pub fn as_str(&self) -> &'static str {
112 match self {
113 ThinkingMode::Chat => "chat",
114 ThinkingMode::Thinking => "thinking",
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum ReasoningEffort {
122 Max,
123 High,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub(crate) enum NormalizeNonText {
128 SerializeJson,
129 LeaveUntouched,
130}
131
132pub(crate) fn to_json(value: &JsonValue) -> String {
136 use serde::Serialize;
137 use serde_json::ser::Formatter;
138 use std::io;
139
140 struct PythonFormatter;
141
142 impl Formatter for PythonFormatter {
143 fn begin_array_value<W: ?Sized + io::Write>(
144 &mut self,
145 writer: &mut W,
146 first: bool,
147 ) -> io::Result<()> {
148 if first {
149 Ok(())
150 } else {
151 writer.write_all(b", ")
152 }
153 }
154
155 fn begin_object_key<W: ?Sized + io::Write>(
156 &mut self,
157 writer: &mut W,
158 first: bool,
159 ) -> io::Result<()> {
160 if first {
161 Ok(())
162 } else {
163 writer.write_all(b", ")
164 }
165 }
166
167 fn begin_object_value<W: ?Sized + io::Write>(&mut self, writer: &mut W) -> io::Result<()> {
168 writer.write_all(b": ")
169 }
170 }
171
172 let mut buf = Vec::with_capacity(64);
174 let mut ser = serde_json::Serializer::with_formatter(&mut buf, PythonFormatter);
175 value
176 .serialize(&mut ser)
177 .expect("JsonValue serialization to Vec<u8> is infallible");
178 String::from_utf8(buf).expect("serde_json output is always valid UTF-8")
179}
180
181pub(crate) fn render_tools(template: &str, tools: &[JsonValue]) -> String {
182 let tools_json: Vec<String> = tools
183 .iter()
184 .filter_map(|tool| tool.get("function"))
185 .map(to_json)
186 .collect();
187
188 template
191 .replace("{dsml_token}", tokens::DSML_TOKEN)
192 .replace("{thinking_start_token}", tokens::THINKING_START)
193 .replace("{thinking_end_token}", tokens::THINKING_END)
194 .replace("{tool_schemas}", &tools_json.join("\n"))
195}
196
197pub(crate) fn find_last_user_index(messages: &[JsonValue]) -> Option<usize> {
198 messages
199 .iter()
200 .enumerate()
201 .rev()
202 .find(|(_, msg)| {
203 msg.get("role")
204 .and_then(|r| r.as_str())
205 .map(|r| r == "user" || r == "developer")
206 .unwrap_or(false)
207 })
208 .map(|(idx, _)| idx)
209}
210
211pub(crate) fn extract_visible_text(content: &JsonValue) -> String {
212 match content {
213 JsonValue::String(text) => text.clone(),
214 JsonValue::Array(items) => items
215 .iter()
216 .filter_map(|item| {
217 if let Some(text) = item.as_str() {
218 return Some(text.to_string());
219 }
220 let item_type = item.get("type").and_then(|v| v.as_str());
221 if item_type == Some("text") {
222 return item
223 .get("text")
224 .and_then(|v| v.as_str())
225 .map(|text| text.to_string());
226 }
227 tracing::warn!(
228 chunk_type = item_type.unwrap_or("unknown"),
229 "DeepSeek formatter dropped non-text content chunk while normalizing message content",
230 );
231 None
232 })
233 .collect::<String>(),
234 _ => to_json(content),
235 }
236}
237
238pub(crate) fn normalize_message_contents(messages: &mut [JsonValue], non_text: NormalizeNonText) {
239 for msg in messages {
240 let Some(content) = msg.get("content") else {
241 continue;
242 };
243 if !content.is_string()
244 && !content.is_array()
245 && non_text == NormalizeNonText::LeaveUntouched
246 {
247 continue;
248 }
249 let normalized = extract_visible_text(content);
250 if let Some(obj) = msg.as_object_mut() {
251 obj.insert("content".to_string(), JsonValue::String(normalized));
252 }
253 }
254}
255
256pub(crate) fn encode_arguments_to_dsml(tool_call: &JsonValue) -> Result<String> {
257 let arguments_str = tool_call
258 .get("arguments")
259 .and_then(|a| a.as_str())
260 .context("Missing or invalid 'arguments' field")?;
261
262 let arguments: JsonValue = match serde_json::from_str(arguments_str) {
264 Ok(v) => v,
265 Err(_) => serde_json::json!({ "arguments": arguments_str }),
266 };
267
268 let arguments_obj = arguments
269 .as_object()
270 .context("Arguments must be a JSON object")?;
271
272 let mut params = Vec::new();
273 for (key, value) in arguments_obj {
274 let value_str = if let Some(vs) = value.as_str() {
275 vs.to_string()
276 } else {
277 to_json(value)
278 };
279 params.push(format!(
280 "<{}parameter name=\"{}\" string=\"{}\">{}</{}parameter>",
281 tokens::DSML_TOKEN,
282 key,
283 if value.is_string() { "true" } else { "false" },
284 value_str,
285 tokens::DSML_TOKEN
286 ));
287 }
288
289 Ok(params.join("\n"))
290}
291
292pub(crate) fn task_token(task: &str) -> Option<&'static str> {
293 match task {
294 "action" => Some(tokens::TASK_ACTION),
295 "query" => Some(tokens::TASK_QUERY),
296 "authority" => Some(tokens::TASK_AUTHORITY),
297 "domain" => Some(tokens::TASK_DOMAIN),
298 "title" => Some(tokens::TASK_TITLE),
299 "read_url" => Some(tokens::TASK_READ_URL),
300 _ => None,
301 }
302}
303
304const USER_FIELDS_TO_PRESERVE: [&str; 3] = ["task", "wo_eos", "mask"];
305
306fn preserve_user_fields(target: &mut JsonValue, source: &JsonValue) {
307 if let Some(obj) = target.as_object_mut() {
308 for key in USER_FIELDS_TO_PRESERVE {
309 if let Some(v) = source.get(key) {
310 obj.insert(key.to_string(), v.clone());
311 }
312 }
313 }
314}
315
316pub(crate) fn merge_tool_messages(messages: &[JsonValue]) -> Vec<JsonValue> {
319 let mut merged: Vec<JsonValue> = Vec::with_capacity(messages.len());
320
321 for msg in messages {
322 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
323
324 if role == "tool" {
325 let tool_block = serde_json::json!({
326 "type": "tool_result",
327 "tool_use_id": msg.get("tool_call_id").cloned().unwrap_or_else(|| JsonValue::String(String::new())),
328 "content": msg.get("content").cloned().unwrap_or_else(|| JsonValue::String(String::new())),
329 });
330
331 let can_merge = merged
332 .last()
333 .map(|m| {
334 m.get("role").and_then(|r| r.as_str()) == Some("user")
335 && m.get("content_blocks").is_some()
336 })
337 .unwrap_or(false);
338
339 if can_merge {
340 let last = merged.last_mut().unwrap();
341 if let Some(blocks) = last
342 .as_object_mut()
343 .and_then(|o| o.get_mut("content_blocks"))
344 .and_then(|v| v.as_array_mut())
345 {
346 blocks.push(tool_block);
347 }
348 } else {
349 merged.push(serde_json::json!({
350 "role": "user",
351 "content_blocks": [tool_block],
352 }));
353 }
354 } else if role == "user" {
355 let text = msg
356 .get("content")
357 .and_then(|c| c.as_str())
358 .unwrap_or("")
359 .to_string();
360 let text_block = serde_json::json!({ "type": "text", "text": text });
361
362 let can_merge = merged
363 .last()
364 .map(|m| {
365 m.get("role").and_then(|r| r.as_str()) == Some("user")
366 && m.get("content_blocks").is_some()
367 && m.get("task").map(|v| v.is_null()).unwrap_or(true)
368 })
369 .unwrap_or(false);
370
371 if can_merge {
372 let last = merged.last_mut().unwrap();
373 let appended = last
374 .as_object_mut()
375 .and_then(|o| o.get_mut("content_blocks"))
376 .and_then(|v| v.as_array_mut())
377 .map(|blocks| {
378 blocks.push(text_block);
379 })
380 .is_some();
381 if appended {
382 preserve_user_fields(last, msg);
383 }
384 } else {
385 let mut new_msg = serde_json::json!({
386 "role": "user",
387 "content": text,
388 "content_blocks": [text_block],
389 });
390 preserve_user_fields(&mut new_msg, msg);
391 merged.push(new_msg);
392 }
393 } else {
394 merged.push(msg.clone());
395 }
396 }
397
398 merged
399}
400
401pub(crate) fn sort_tool_results_by_call_order(mut messages: Vec<JsonValue>) -> Vec<JsonValue> {
404 use std::collections::HashMap;
405 let mut last_order: HashMap<String, usize> = HashMap::new();
406
407 for msg in &mut messages {
408 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
409 if role == "assistant" {
410 if let Some(tcs) = msg.get("tool_calls").and_then(|t| t.as_array()) {
411 last_order.clear();
412 for (idx, tc) in tcs.iter().enumerate() {
413 let id = tc
414 .get("id")
415 .and_then(|v| v.as_str())
416 .or_else(|| {
417 tc.get("function")
418 .and_then(|f| f.get("id"))
419 .and_then(|v| v.as_str())
420 })
421 .unwrap_or("");
422 if !id.is_empty() {
423 last_order.insert(id.to_string(), idx);
424 }
425 }
426 }
427 } else if role == "user" && !last_order.is_empty() {
428 let Some(blocks) = msg
429 .as_object_mut()
430 .and_then(|o| o.get_mut("content_blocks"))
431 .and_then(|v| v.as_array_mut())
432 else {
433 continue;
434 };
435
436 let tool_positions: Vec<usize> = blocks
438 .iter()
439 .enumerate()
440 .filter(|(_, b)| b.get("type").and_then(|v| v.as_str()) == Some("tool_result"))
441 .map(|(i, _)| i)
442 .collect();
443
444 if tool_positions.len() > 1 {
445 let start = *tool_positions
446 .first()
447 .expect("tool_positions has length > 1");
448 let end = *tool_positions
449 .last()
450 .expect("tool_positions has length > 1");
451 let is_contiguous = end - start + 1 == tool_positions.len();
452
453 if is_contiguous {
454 blocks[start..=end].sort_by_key(|b| {
456 let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
457 *last_order.get(id).unwrap_or(&0)
458 });
459 } else {
460 let mut tool_blocks: Vec<JsonValue> = tool_positions
462 .iter()
463 .map(|&i| std::mem::take(&mut blocks[i]))
464 .collect();
465
466 tool_blocks.sort_by_key(|b| {
467 let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
468 *last_order.get(id).unwrap_or(&0)
469 });
470
471 for (sorted_idx, &pos) in tool_positions.iter().enumerate() {
472 blocks[pos] = std::mem::take(&mut tool_blocks[sorted_idx]);
473 }
474 }
475 }
476 }
477 }
478
479 messages
480}
481
482pub(crate) fn drop_thinking_messages(messages: Vec<JsonValue>) -> Vec<JsonValue> {
484 let last_user_idx = find_last_user_index(&messages);
485 let mut out = Vec::with_capacity(messages.len());
486 const KEEP: &[&str] = &[
487 "user",
488 "system",
489 "tool",
490 "latest_reminder",
491 "direct_search_results",
492 ];
493
494 for (idx, mut msg) in messages.into_iter().enumerate() {
495 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
496 if KEEP.contains(&role) || last_user_idx.is_none_or(|u| idx >= u) {
497 out.push(msg);
498 } else if role == "assistant" {
499 if let Some(obj) = msg.as_object_mut() {
500 obj.remove("reasoning_content");
501 }
502 out.push(msg);
503 }
504 }
506 out
507}
508
509pub(crate) fn resolve_thinking_mode(
510 args: Option<&std::collections::HashMap<String, serde_json::Value>>,
511 default_mode: ThinkingMode,
512) -> ThinkingMode {
513 if let Some(enabled) = crate::thinking_bool_from_args(args) {
514 return if enabled {
515 ThinkingMode::Thinking
516 } else {
517 ThinkingMode::Chat
518 };
519 }
520 if let Some(args) = args
521 && let Some(mode) = args.get("thinking_mode").and_then(|v| v.as_str())
522 {
523 match mode {
524 "chat" => return ThinkingMode::Chat,
525 "thinking" => return ThinkingMode::Thinking,
526 _ => {}
527 }
528 }
529 default_mode
530}
531
532pub(crate) fn inject_tools_and_response_format(
533 messages_array: &mut Vec<JsonValue>,
534 req: &dyn crate::OAIChatLikeRequest,
535) -> Result<()> {
536 let tools_json = req
537 .tools()
538 .map(|t| serde_json::to_value(&t))
539 .transpose()
540 .context("Failed to convert tools to JSON")?;
541
542 let response_format_json = req
543 .response_format()
544 .map(|rf| serde_json::to_value(&rf))
545 .transpose()
546 .context("Failed to convert response_format to JSON")?;
547
548 if tools_json.is_some() || response_format_json.is_some() {
549 let system_idx = messages_array
550 .iter()
551 .position(|msg| msg.get("role").and_then(|r| r.as_str()) == Some("system"));
552
553 if let Some(idx) = system_idx {
554 if let Some(msg) = messages_array.get_mut(idx)
555 && let Some(obj) = msg.as_object_mut()
556 {
557 if let Some(tools) = tools_json {
558 obj.insert("tools".to_string(), tools);
559 }
560 if let Some(rf) = response_format_json {
561 obj.insert("response_format".to_string(), rf);
562 }
563 }
564 } else {
565 let mut system_msg = serde_json::json!({
566 "role": "system",
567 "content": ""
568 });
569 if let Some(obj) = system_msg.as_object_mut() {
570 if let Some(tools) = tools_json {
571 obj.insert("tools".to_string(), tools);
572 }
573 if let Some(rf) = response_format_json {
574 obj.insert("response_format".to_string(), rf);
575 }
576 }
577 messages_array.insert(0, system_msg);
578 }
579 }
580 Ok(())
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586 use serde_json::json;
587
588 #[test]
589 fn test_extract_visible_text_from_content_array() {
590 let content = json!([
591 {"type": "text", "text": "who "},
592 {"type": "text", "text": "are "},
593 {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
594 {"type": "text", "text": "you?"}
595 ]);
596 assert_eq!(extract_visible_text(&content), "who are you?");
597 }
598
599 #[test]
600 fn test_render_tools_preserves_placeholder_text_inside_tool_schema() {
601 let tools = json!([{
602 "type": "function",
603 "function": {
604 "name": "placeholder_tool",
605 "description": "literal {dsml_token} {thinking_start_token} {thinking_end_token}",
606 "parameters": {"type": "object", "properties": {}}
607 }
608 }]);
609 let rendered = render_tools(
610 "static {dsml_token} {thinking_start_token} {thinking_end_token}\n{tool_schemas}",
611 tools.as_array().unwrap(),
612 );
613
614 assert!(rendered.starts_with(&format!(
615 "static {} {} {}\n",
616 tokens::DSML_TOKEN,
617 tokens::THINKING_START,
618 tokens::THINKING_END
619 )));
620 assert!(
621 rendered.contains("literal {dsml_token} {thinking_start_token} {thinking_end_token}")
622 );
623 }
624}