1use anyhow::{Context, Result};
11use serde_json::Value as JsonValue;
12use std::fmt::Write;
13
14use super::common::{
15 NormalizeNonText, REASONING_EFFORT_HIGH, REASONING_EFFORT_MAX, RESPONSE_FORMAT_TEMPLATE,
16 TOOL_CALLS_BLOCK_NAME, TOOLS_TEMPLATE, drop_thinking_messages, encode_arguments_to_dsml,
17 find_last_user_index, merge_tool_messages, normalize_message_contents, render_tools,
18 sort_tool_results_by_call_order, task_token, to_json,
19};
20pub use super::common::{ReasoningEffort, ThinkingMode, tokens};
21
22#[derive(Clone, Copy)]
23pub(super) enum Encoding {
24 V4(Option<ReasoningEffort>),
25 V41(u8),
26}
27
28impl Encoding {
29 fn is_v41(self) -> bool {
30 matches!(self, Self::V41(_))
31 }
32
33 fn tag(self, v4: &'static str, v41: &'static str) -> &'static str {
34 if self.is_v41() { v41 } else { v4 }
35 }
36
37 fn reasoning_prefix(self) -> String {
38 match self {
39 Self::V4(Some(ReasoningEffort::High)) => REASONING_EFFORT_HIGH.to_string(),
40 Self::V4(Some(ReasoningEffort::Max)) => REASONING_EFFORT_MAX.to_string(),
41 Self::V4(None) => String::new(),
42 Self::V41(effort) => format!(
43 "Reasoning Effort: {effort} (range 1-100, the higher the value, the more thorough the reasoning)\n\n"
44 ),
45 }
46 }
47
48 fn render_tools(self, tools: &[JsonValue]) -> String {
49 let template = if self.is_v41() {
50 TOOLS_TEMPLATE
51 .replace("{dsml_token}tool_calls", "{dsml_token} calls")
52 .replace("{dsml_token}invoke", "{dsml_token} invoke")
53 .replace("{dsml_token}parameter", "{dsml_token} parameter")
54 } else {
55 TOOLS_TEMPLATE.to_string()
56 };
57 render_tools(&template, tools)
58 }
59}
60
61fn render_message(
63 prompt: &mut String,
64 index: usize,
65 messages: &[JsonValue],
66 thinking_mode: ThinkingMode,
67 drop_thinking: bool,
68 encoding: Encoding,
69 last_user_idx: Option<usize>,
70) -> Result<()> {
71 let msg = &messages[index];
72
73 let role = msg
74 .get("role")
75 .and_then(|r| r.as_str())
76 .context("Missing 'role' field")?;
77
78 if encoding.is_v41()
79 && (role == "system" || (index == 0 && thinking_mode == ThinkingMode::Thinking))
80 {
81 prompt.push_str("<|System|>");
82 }
83 if index == 0 && thinking_mode == ThinkingMode::Thinking {
84 prompt.push_str(&encoding.reasoning_prefix());
85 }
86
87 match role {
88 "system" => {
89 let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
90 prompt.push_str(content);
91 if let Some(tools) = msg
93 .get("tools")
94 .and_then(|t| t.as_array())
95 .filter(|t| !t.is_empty())
96 {
97 prompt.push_str("\n\n");
98 prompt.push_str(&encoding.render_tools(tools));
99 }
100 if let Some(response_format) = msg.get("response_format") {
101 prompt.push_str("\n\n");
102 prompt.push_str(
103 &RESPONSE_FORMAT_TEMPLATE.replace("{schema}", &to_json(response_format)),
104 );
105 }
106 }
107
108 "developer" => {
109 let content = msg
110 .get("content")
111 .and_then(|c| c.as_str())
112 .filter(|s| !s.is_empty())
113 .context("Developer role requires content")?;
114
115 prompt.push_str(tokens::USER_START);
116 prompt.push_str(content);
117
118 if let Some(tools) = msg
119 .get("tools")
120 .and_then(|t| t.as_array())
121 .filter(|t| !t.is_empty())
122 {
123 prompt.push_str("\n\n");
124 prompt.push_str(&encoding.render_tools(tools));
125 }
126 if let Some(response_format) = msg.get("response_format") {
127 prompt.push_str("\n\n");
128 prompt.push_str(
129 &RESPONSE_FORMAT_TEMPLATE.replace("{schema}", &to_json(response_format)),
130 );
131 }
132 }
133
134 "user" => {
135 prompt.push_str(tokens::USER_START);
136 if let Some(blocks) = msg.get("content_blocks").and_then(|b| b.as_array()) {
137 for (block_idx, block) in blocks.iter().enumerate() {
138 if block_idx > 0 {
139 prompt.push_str("\n\n");
140 }
141 let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
142 match block_type {
143 "text" => {
144 let text = block.get("text").and_then(|v| v.as_str()).unwrap_or("");
145 prompt.push_str(text);
146 }
147 "tool_result" => {
148 prompt.push_str("<tool_result>");
149 render_tool_result_content(
150 prompt,
151 block.get("content").unwrap_or(&JsonValue::Null),
152 )?;
153 prompt.push_str("</tool_result>");
154 }
155 other => {
156 write!(prompt, "[Unsupported {}]", other)?;
157 }
158 }
159 }
160 }
161 }
162
163 "latest_reminder" => {
164 let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
165 prompt.push_str(tokens::LATEST_REMINDER);
166 prompt.push_str(content);
167 }
168
169 "tool" => {
170 anyhow::bail!(
171 "deepseek_v4 merges tool messages into user; preprocess with merge_tool_messages()"
172 );
173 }
174
175 "assistant" => {
176 let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
177 let reasoning = msg
178 .get("reasoning_content")
179 .and_then(|c| c.as_str())
180 .unwrap_or("");
181 let wo_eos = msg.get("wo_eos").and_then(|v| v.as_bool()).unwrap_or(false);
182
183 let prev_has_task = index > 0
184 && messages[index - 1]
185 .get("task")
186 .map(|v| !v.is_null())
187 .unwrap_or(false);
188
189 if thinking_mode == ThinkingMode::Thinking && !prev_has_task {
190 let render_thinking = !drop_thinking || last_user_idx.is_none_or(|u| index > u);
191 if render_thinking {
192 prompt.push_str(reasoning);
193 prompt.push_str(tokens::THINKING_END);
194 }
195 }
196
197 prompt.push_str(content);
198
199 if let Some(tool_calls) = msg.get("tool_calls").and_then(|t| t.as_array())
200 && !tool_calls.is_empty()
201 {
202 prompt.push_str("\n\n");
203 writeln!(
204 prompt,
205 "<{}{}>",
206 tokens::DSML_TOKEN,
207 encoding.tag(TOOL_CALLS_BLOCK_NAME, " calls")
208 )?;
209
210 for (call_idx, tc) in tool_calls.iter().enumerate() {
211 if call_idx > 0 {
212 prompt.push('\n');
213 }
214 let fn_obj = tc.get("function").unwrap_or(tc);
217 let name = fn_obj
218 .get("name")
219 .and_then(|n| n.as_str())
220 .context("Missing tool call name")?;
221 let arguments = if encoding.is_v41() {
222 super::v41::encode_arguments(fn_obj)?
223 } else {
224 encode_arguments_to_dsml(fn_obj)?
225 };
226 write!(
227 prompt,
228 "<{}{} name=\"{}\">\n{}\n</{}{}>",
229 tokens::DSML_TOKEN,
230 encoding.tag("invoke", " invoke"),
231 name,
232 arguments,
233 tokens::DSML_TOKEN,
234 encoding.tag("invoke", " invoke")
235 )?;
236 }
237 write!(
238 prompt,
239 "\n</{}{}>",
240 tokens::DSML_TOKEN,
241 encoding.tag(TOOL_CALLS_BLOCK_NAME, " calls")
242 )?;
243 }
244
245 if !wo_eos {
246 prompt.push_str(tokens::EOS);
247 }
248 }
249
250 other => anyhow::bail!("Unknown role: {}", other),
251 }
252
253 if index + 1 < messages.len() {
255 let next_role = messages[index + 1].get("role").and_then(|r| r.as_str());
256 if !matches!(next_role, Some("assistant") | Some("latest_reminder")) {
257 return Ok(());
258 }
259 }
260
261 let task = msg.get("task").and_then(|v| v.as_str());
263 if let Some(task) = task {
264 let sp = task_token(task).with_context(|| format!("Invalid task: '{}'", task))?;
265 if task != "action" {
266 prompt.push_str(sp);
267 } else {
268 prompt.push_str(tokens::ASSISTANT_START);
269 prompt.push_str(if thinking_mode != ThinkingMode::Thinking {
270 tokens::THINKING_END
271 } else {
272 tokens::THINKING_START
273 });
274 prompt.push_str(sp);
275 }
276 } else if matches!(role, "user" | "developer")
277 || (encoding.is_v41() && role == "system" && index > 0)
278 {
279 prompt.push_str(tokens::ASSISTANT_START);
280 let seed_thinking = thinking_mode == ThinkingMode::Thinking
281 && (!drop_thinking || last_user_idx.is_none_or(|u| index >= u));
282 prompt.push_str(if seed_thinking {
283 tokens::THINKING_START
284 } else {
285 tokens::THINKING_END
286 });
287 }
288
289 Ok(())
290}
291
292fn render_tool_result_content(prompt: &mut String, content: &JsonValue) -> Result<()> {
294 match content {
295 JsonValue::String(s) => prompt.push_str(s),
296 JsonValue::Array(items) => {
297 for (index, item) in items.iter().enumerate() {
298 if index > 0 {
299 prompt.push_str("\n\n");
300 }
301 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
302 if item_type == "text" {
303 prompt.push_str(item.get("text").and_then(|v| v.as_str()).unwrap_or(""));
304 } else {
305 write!(prompt, "[Unsupported {}]", item_type)?;
306 }
307 }
308 }
309 JsonValue::Null => {}
310 _ => prompt.push_str(&to_json(content)),
311 }
312 Ok(())
313}
314
315pub fn encode_messages(
319 messages: &[JsonValue],
320 thinking_mode: ThinkingMode,
321 add_bos_token: bool,
322) -> Result<String> {
323 encode_messages_with_options(messages, thinking_mode, add_bos_token, true, None)
324}
325
326pub fn encode_messages_with_options(
335 messages: &[JsonValue],
336 thinking_mode: ThinkingMode,
337 add_bos_token: bool,
338 drop_thinking: bool,
339 reasoning_effort: Option<ReasoningEffort>,
340) -> Result<String> {
341 encode_owned_messages(
342 messages.to_vec(),
343 thinking_mode,
344 add_bos_token,
345 drop_thinking,
346 Encoding::V4(reasoning_effort),
347 )
348}
349
350pub(super) fn encode_owned_messages(
351 messages: Vec<JsonValue>,
352 thinking_mode: ThinkingMode,
353 add_bos_token: bool,
354 drop_thinking: bool,
355 encoding: Encoding,
356) -> Result<String> {
357 let merged = merge_tool_messages(messages);
358 let mut full = if encoding.is_v41() {
360 merged
361 } else {
362 sort_tool_results_by_call_order(merged)
363 };
364
365 let mut prompt = String::new();
366 if add_bos_token {
367 prompt.push_str(tokens::BOS);
368 }
369
370 let has_tools = full.iter().any(|m| {
372 m.get("tools")
373 .map(|v| match v {
374 JsonValue::Array(a) => !a.is_empty(),
375 JsonValue::Null => false,
376 _ => true,
377 })
378 .unwrap_or(false)
379 });
380 let effective_drop_thinking = drop_thinking && !has_tools;
381
382 if thinking_mode == ThinkingMode::Thinking && effective_drop_thinking {
383 full = if encoding.is_v41() {
384 super::v41::drop_thinking_messages(full)
385 } else {
386 drop_thinking_messages(full)
387 };
388 }
389
390 let last_user_idx = if encoding.is_v41() {
391 super::v41::find_last_user_index(&full)
392 } else {
393 find_last_user_index(&full)
394 };
395 for idx in 0..full.len() {
396 render_message(
397 &mut prompt,
398 idx,
399 &full,
400 thinking_mode,
401 effective_drop_thinking,
402 encoding,
403 last_user_idx,
404 )?;
405 }
406
407 Ok(prompt)
408}
409
410#[derive(Debug)]
412pub struct DeepSeekV4Formatter {
413 thinking_mode: ThinkingMode,
414}
415
416impl DeepSeekV4Formatter {
417 pub fn new(thinking_mode: ThinkingMode) -> Self {
418 Self { thinking_mode }
419 }
420
421 pub fn new_thinking() -> Self {
423 Self::new(ThinkingMode::Thinking)
424 }
425
426 pub fn new_chat() -> Self {
428 Self::new(ThinkingMode::Chat)
429 }
430
431 fn resolve_reasoning_effort(v: Option<&JsonValue>) -> (bool, Option<ReasoningEffort>) {
432 match v.and_then(JsonValue::as_str) {
433 Some("none") => (true, None),
434 Some("max") => (false, Some(ReasoningEffort::Max)),
435 Some("high") | Some("medium") | Some("xhigh") => (false, Some(ReasoningEffort::High)),
436 Some("low") | Some("minimal") => (false, None),
437 None if v.is_none() => (false, Some(ReasoningEffort::High)),
438 _ => {
439 tracing::warn!(
440 value = ?v,
441 "reasoning_effort must be one of \"none\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", \"max\"; ignoring and using API default (high)"
442 );
443 (false, Some(ReasoningEffort::High))
444 }
445 }
446 }
447
448 fn resolve_drop_thinking(
449 args: Option<&std::collections::HashMap<String, serde_json::Value>>,
450 ) -> bool {
451 let Some(args) = args else { return true };
452 let Some(v) = args.get("drop_thinking") else {
453 return true;
454 };
455 if let Some(b) = v.as_bool() {
456 return b;
457 }
458 tracing::warn!(
459 value = ?v,
460 "chat_template_args.drop_thinking must be a bool; ignoring and using default (true)"
461 );
462 true
463 }
464}
465
466impl crate::OAIPromptFormatter for DeepSeekV4Formatter {
467 fn supports_add_generation_prompt(&self) -> bool {
468 true
469 }
470
471 fn render(&self, req: &dyn crate::OAIChatLikeRequest) -> Result<String> {
472 let args = req.chat_template_args();
473 let effort_value = req
474 .reasoning_effort()
475 .map(|value| serde_json::to_value(value).context("serialize reasoning_effort"))
476 .transpose()?
477 .or_else(|| args.and_then(|args| args.get("reasoning_effort").cloned()));
478 let (disable_thinking, reasoning_effort) =
479 Self::resolve_reasoning_effort(effort_value.as_ref());
480 let mut thinking_mode = super::common::resolve_thinking_mode(args, self.thinking_mode);
481 if disable_thinking {
482 thinking_mode = ThinkingMode::Chat;
483 }
484 let drop_thinking = Self::resolve_drop_thinking(args);
485
486 let messages_json = crate::messages_to_json(req)?;
487 crate::reject_unsupported_partial_assistant(&messages_json)?;
488 crate::reject_unsupported_message_tools(&messages_json, &["developer"])?;
489
490 let JsonValue::Array(mut messages_array) = messages_json else {
491 anyhow::bail!("Messages is not an array");
492 };
493
494 normalize_message_contents(&mut messages_array, NormalizeNonText::LeaveUntouched);
495
496 super::common::inject_tools_and_response_format(&mut messages_array, req)?;
497
498 encode_owned_messages(
499 messages_array,
500 thinking_mode,
501 true,
502 drop_thinking,
503 Encoding::V4(reasoning_effort),
504 )
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use serde_json::json;
512
513 #[test]
514 fn test_simple_conversation() {
515 let messages = json!([
516 {"role": "system", "content": "You are a helpful assistant."},
517 {"role": "user", "content": "Hello"},
518 {"role": "assistant", "reasoning_content": "greet", "content": "Hi!"},
519 {"role": "user", "content": "What is 2+2?"}
520 ]);
521 let out =
522 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
523 assert!(out.starts_with(tokens::BOS));
524 assert!(out.ends_with(&format!(
525 "{}{}",
526 tokens::ASSISTANT_START,
527 tokens::THINKING_START
528 )));
529 assert!(!out.contains("greet"));
531 }
532
533 #[test]
534 fn test_reasoning_effort_prefixes() {
535 let messages = json!([
536 {"role": "system", "content": "hi"},
537 {"role": "user", "content": "hello"}
538 ]);
539
540 let high = encode_messages_with_options(
541 messages.as_array().unwrap(),
542 ThinkingMode::Thinking,
543 true,
544 true,
545 Some(ReasoningEffort::High),
546 )
547 .unwrap();
548 let max = encode_messages_with_options(
549 messages.as_array().unwrap(),
550 ThinkingMode::Thinking,
551 true,
552 true,
553 Some(ReasoningEffort::Max),
554 )
555 .unwrap();
556 let low = encode_messages_with_options(
557 messages.as_array().unwrap(),
558 ThinkingMode::Thinking,
559 true,
560 true,
561 None,
562 )
563 .unwrap();
564
565 assert_eq!(
566 high,
567 concat!(
568 "<|begin▁of▁sentence|>Reasoning Effort: Absolute maximum with no shortcuts permitted.\n",
569 "You 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.\n",
570 "Explicitly 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",
571 "hi<|User|>hello<|Assistant|><think>"
572 )
573 );
574 assert_eq!(
575 max,
576 concat!(
577 "<|begin▁of▁sentence|>Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.\n",
578 "You MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.\n",
579 "Do not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.\n\n",
580 "hi<|User|>hello<|Assistant|><think>"
581 )
582 );
583 assert_eq!(
584 low,
585 "<|begin▁of▁sentence|>hi<|User|>hello<|Assistant|><think>"
586 );
587 }
588
589 #[test]
590 fn test_content_blocks_with_tool_result() {
591 let messages = json!([
597 {"role": "user", "content": "call tool"},
598 {"role": "assistant", "content": "", "tool_calls": [{
599 "id": "c1", "type": "function",
600 "function": {"name": "f", "arguments": "{}"}
601 }]},
602 {"role": "tool", "tool_call_id": "c1", "content": "RESULT"},
603 {"role": "user", "content": "thanks"}
604 ]);
605 let out = encode_messages(messages.as_array().unwrap(), ThinkingMode::Chat, true).unwrap();
606 assert!(
607 out.contains("<tool_result>RESULT</tool_result>\n\nthanks"),
608 "expected tool_result block followed by 'thanks' in the merged user turn, got:\n{}",
609 out
610 );
611 }
612
613 #[test]
614 fn test_empty_tool_lists_and_merged_user_task_match_reference() {
615 let call = |id: &str| json!({"id": id, "type": "function", "function": {"name": "search", "arguments": "{}"}});
616 let invoke = "<|DSML|invoke name=\"search\">\n\n</|DSML|invoke>";
617 let calls = format!(
618 "<|Assistant|></think>\n\n<|DSML|tool_calls>\n{invoke}\n{invoke}\n</|DSML|tool_calls><|end▁of▁sentence|>"
619 );
620 for (messages, expected) in [
622 (
623 json!([{"role": "system", "content": "S", "tools": []}, {"role": "user", "content": "Hi"}]),
624 "<|begin▁of▁sentence|>S<|User|>Hi<|Assistant|></think>".to_owned(),
625 ),
626 (
627 json!([
628 {"role": "user", "content": "q"},
629 {"role": "assistant", "content": "", "tool_calls": [call("a"), call("b")]},
630 {"role": "assistant", "content": "x", "tool_calls": []},
631 {"role": "tool", "tool_call_id": "b", "content": "B"},
632 {"role": "tool", "tool_call_id": "a", "content": "A"}
633 ]),
634 format!(
635 "<|begin▁of▁sentence|><|User|>q{calls}x<|end▁of▁sentence|><|User|><tool_result>A</tool_result>\n\n<tool_result>B</tool_result><|Assistant|></think>"
636 ),
637 ),
638 (
639 json!([
640 {"role": "user", "content": "q"},
641 {"role": "assistant", "content": "", "tool_calls": [call("a"), call("b")]},
642 {"role": "tool", "tool_call_id": "b", "content": "B"},
643 {"role": "user", "content": "Search", "task": "action"},
644 {"role": "user", "content": "More"},
645 {"role": "tool", "tool_call_id": "a", "content": "A"},
646 {"role": "assistant", "content": "OK"}
647 ]),
648 format!(
649 "<|begin▁of▁sentence|><|User|>q{calls}<|User|><tool_result>A</tool_result>\n\nSearch\n\nMore\n\n<tool_result>B</tool_result><|Assistant|></think>OK<|end▁of▁sentence|>"
650 ),
651 ),
652 ] {
653 let out =
654 encode_messages(messages.as_array().unwrap(), ThinkingMode::Chat, true).unwrap();
655 assert_eq!(out, expected);
656 }
657 }
658
659 #[test]
660 fn test_drop_thinking_auto_disable_when_tools_present() {
661 let messages = json!([
662 {"role": "system", "content": "s", "tools": [{
663 "type": "function",
664 "function": {"name": "f", "description": "", "parameters": {"type": "object", "properties": {}}}
665 }]},
666 {"role": "user", "content": "hi"},
667 {"role": "assistant", "reasoning_content": "PRIOR_REASONING", "content": "reply"},
668 {"role": "user", "content": "again"}
669 ]);
670 let out =
671 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
672 assert!(out.contains("PRIOR_REASONING"));
674 }
675
676 #[test]
687 fn test_assistant_reasoning_preserved_when_no_user_in_history() {
688 let messages = json!([
689 {"role": "system", "content": "sys"},
690 {"role": "assistant", "content": "hello", "reasoning_content": "REASONING_BLOCK"}
691 ]);
692 let out =
693 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
694 assert_eq!(
695 out, "<|begin▁of▁sentence|>sysREASONING_BLOCK</think>hello<|end▁of▁sentence|>",
696 "Output must match Python reference byte-for-byte when no user/developer in history"
697 );
698 }
699
700 #[test]
708 fn test_to_json_preserves_spacing_past_escaped_backslash() {
709 let v = json!({"path": "\\", "count": 5});
710 let got = to_json(&v);
711 assert_eq!(
712 got, r#"{"path": "\\", "count": 5}"#,
713 "to_json must match Python's json.dumps formatting past an escaped backslash"
714 );
715 }
716
717 #[test]
718 fn test_resolve_drop_thinking_warns_on_malformed_value() {
719 use std::collections::HashMap;
720 let mut args = HashMap::new();
722 args.insert(
723 "drop_thinking".to_string(),
724 serde_json::Value::String("false".to_string()),
725 );
726 assert!(DeepSeekV4Formatter::resolve_drop_thinking(Some(&args)));
727 let malformed = serde_json::Value::String("HIGH".to_string());
729 assert_eq!(
730 DeepSeekV4Formatter::resolve_reasoning_effort(Some(&malformed)),
731 (false, Some(ReasoningEffort::High))
732 );
733 }
734
735 #[test]
736 fn test_resolve_thinking_mode_honors_enable_thinking() {
737 use std::collections::HashMap;
738 let mut args = HashMap::new();
739 args.insert(
740 "enable_thinking".to_string(),
741 serde_json::Value::Bool(false),
742 );
743 assert_eq!(
744 super::super::common::resolve_thinking_mode(Some(&args), ThinkingMode::Thinking),
745 ThinkingMode::Chat
746 );
747 args.insert("enable_thinking".to_string(), serde_json::Value::Bool(true));
748 assert_eq!(
749 super::super::common::resolve_thinking_mode(Some(&args), ThinkingMode::Thinking),
750 ThinkingMode::Thinking
751 );
752 }
753
754 struct MockRequest {
755 messages: JsonValue,
756 typed: Option<Vec<dynamo_protocols::types::ChatCompletionRequestMessage>>,
757 chat_template_args: Option<std::collections::HashMap<String, JsonValue>>,
758 reasoning_effort: Option<JsonValue>,
759 tools: Option<JsonValue>,
760 tool_choice: Option<JsonValue>,
761 response_format: Option<JsonValue>,
762 }
763
764 impl MockRequest {
765 fn new(messages: JsonValue) -> Self {
766 Self {
767 messages,
768 typed: None,
769 chat_template_args: None,
770 reasoning_effort: None,
771 tools: None,
772 tool_choice: None,
773 response_format: None,
774 }
775 }
776
777 fn with_chat_template_args(
778 mut self,
779 args: std::collections::HashMap<String, JsonValue>,
780 ) -> Self {
781 self.chat_template_args = Some(args);
782 self
783 }
784
785 fn with_reasoning_effort(mut self, reasoning_effort: JsonValue) -> Self {
786 self.reasoning_effort = Some(reasoning_effort);
787 self
788 }
789
790 fn with_tools(mut self, tools: JsonValue) -> Self {
791 self.tools = Some(tools);
792 self
793 }
794
795 fn with_tool_choice(mut self, tool_choice: JsonValue) -> Self {
796 self.tool_choice = Some(tool_choice);
797 self
798 }
799
800 fn with_response_format(mut self, response_format: JsonValue) -> Self {
801 self.response_format = Some(response_format);
802 self
803 }
804 }
805
806 impl crate::OAIChatLikeRequest for MockRequest {
807 fn model(&self) -> String {
808 "deepseek-v4".to_string()
809 }
810
811 fn messages(&self) -> minijinja::value::Value {
812 assert!(
813 self.typed.is_none(),
814 "typed requests must skip MiniJinja conversion"
815 );
816 minijinja::value::Value::from_serialize(&self.messages)
817 }
818
819 fn typed_messages(
820 &self,
821 ) -> Option<&[dynamo_protocols::types::ChatCompletionRequestMessage]> {
822 self.typed.as_deref()
823 }
824
825 fn should_add_generation_prompt(&self) -> bool {
826 true
827 }
828
829 fn chat_template_args(
830 &self,
831 ) -> Option<&std::collections::HashMap<String, serde_json::Value>> {
832 self.chat_template_args.as_ref()
833 }
834
835 fn reasoning_effort(&self) -> Option<minijinja::value::Value> {
836 self.reasoning_effort
837 .as_ref()
838 .map(minijinja::value::Value::from_serialize)
839 }
840
841 fn tools(&self) -> Option<minijinja::value::Value> {
842 self.tools
843 .as_ref()
844 .map(minijinja::value::Value::from_serialize)
845 }
846
847 fn tool_choice(&self) -> Option<minijinja::value::Value> {
848 self.tool_choice
849 .as_ref()
850 .map(minijinja::value::Value::from_serialize)
851 }
852
853 fn response_format(&self) -> Option<minijinja::value::Value> {
854 self.response_format
855 .as_ref()
856 .map(minijinja::value::Value::from_serialize)
857 }
858 }
859
860 #[test]
861 fn typed_messages_match_value_messages() {
862 use crate::OAIPromptFormatter;
863 let messages = json!([
864 {"role": "system", "content": "Use tools. 中文 🦀"},
865 {"role": "user", "content": [{"type": "text", "text": "weather?"}]},
866 {"role": "assistant", "content": null, "reasoning_content": "check",
867 "tool_calls": [{"id": "call_1", "type": "function",
868 "function": {"name": "weather", "arguments": "{\"city\":\"東京\"}"}}]},
869 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"},
870 {"role": "user", "content": "explain"}
871 ]);
872 let mut typed = MockRequest::new(messages.clone());
873 typed.typed = Some(serde_json::from_value(messages.clone()).unwrap());
874 let value = MockRequest::new(messages);
875 for formatter in [
876 DeepSeekV4Formatter::new_thinking(),
877 DeepSeekV4Formatter::new_chat(),
878 ] {
879 assert_eq!(
880 formatter.render(&typed).unwrap(),
881 formatter.render(&value).unwrap()
882 );
883 }
884 }
885
886 fn weather_tool() -> JsonValue {
887 json!([{
888 "type": "function",
889 "function": {
890 "name": "get_current_weather",
891 "description": "Get the current weather in a given location",
892 "parameters": {
893 "type": "object",
894 "properties": {"location": {"type": "string"}},
895 "required": ["location"]
896 }
897 }
898 }])
899 }
900
901 #[test]
902 fn test_formatter_rejects_unsupported_partial_assistant() {
903 use crate::OAIPromptFormatter;
904
905 let request = MockRequest::new(json!([
906 {"role": "user", "content": "Continue"},
907 {"role": "assistant", "content": "prefix", "partial": true}
908 ]));
909 let error = DeepSeekV4Formatter::new_thinking()
910 .render(&request)
911 .unwrap_err();
912
913 assert!(matches!(
914 error.downcast_ref::<crate::PromptRenderError>(),
915 Some(crate::PromptRenderError::InvalidRequest(message))
916 if message.contains("`partial: true` is not supported")
917 ));
918 }
919
920 #[test]
921 fn test_formatter_rejects_system_tools_before_injection() {
922 use crate::OAIPromptFormatter;
923
924 let request = MockRequest::new(json!([
925 {"role": "system", "tools": [
926 {"type": "function", "function": {"name": "dynamic_tool"}}
927 ]},
928 {"role": "user", "content": "Use a tool"}
929 ]))
930 .with_tools(weather_tool());
931 let error = DeepSeekV4Formatter::new_thinking()
932 .render(&request)
933 .unwrap_err();
934
935 assert!(matches!(
936 error.downcast_ref::<crate::PromptRenderError>(),
937 Some(crate::PromptRenderError::InvalidRequest(message))
938 if message.contains("message-level `tools`") && message.contains("system")
939 ));
940 }
941
942 #[test]
943 fn test_formatter_preserves_developer_tools_with_top_level_tools() {
944 use crate::OAIPromptFormatter;
945
946 let request = MockRequest::new(json!([
947 {"role": "developer", "content": "Use a tool", "tools": [
948 {"type": "function", "function": {"name": "developer_tool"}}
949 ]}
950 ]))
951 .with_tools(weather_tool());
952 let rendered = DeepSeekV4Formatter::new_thinking()
953 .render(&request)
954 .unwrap();
955
956 assert!(rendered.contains("developer_tool"));
957 assert!(rendered.contains("get_current_weather"));
958 }
959
960 #[test]
961 fn test_render_tool_choice_none_strips_tools_keeps_response_format() {
962 use crate::OAIPromptFormatter;
963
964 let req = MockRequest::new(json!([
965 {"role": "system", "content": "sys"},
966 {"role": "user", "content": "weather in Boston?"}
967 ]))
968 .with_tools(weather_tool())
969 .with_tool_choice(json!("none"))
970 .with_response_format(json!({"type": "json_object"}));
971
972 let formatter = DeepSeekV4Formatter::new_chat();
973 let out = formatter.render(&req).unwrap();
974
975 assert!(
976 !out.contains("## Tools"),
977 "tool_choice=none must strip the tools block, got: {out}"
978 );
979 assert!(
980 !out.contains("get_current_weather"),
981 "tool schema leaked into prompt despite tool_choice=none: {out}"
982 );
983 assert!(
984 out.contains("## Response Format"),
985 "response_format must survive tool_choice=none: {out}"
986 );
987 }
988
989 #[test]
990 fn test_render_tool_choice_auto_keeps_tools() {
991 use crate::OAIPromptFormatter;
992
993 let req = MockRequest::new(json!([
994 {"role": "system", "content": "sys"},
995 {"role": "user", "content": "weather in Boston?"}
996 ]))
997 .with_tools(weather_tool())
998 .with_tool_choice(json!("auto"));
999
1000 let formatter = DeepSeekV4Formatter::new_chat();
1001 let out = formatter.render(&req).unwrap();
1002
1003 assert!(out.contains("## Tools"));
1004 assert!(out.contains("get_current_weather"));
1005 }
1006
1007 #[test]
1008 fn test_render_absent_tool_choice_keeps_tools() {
1009 use crate::OAIPromptFormatter;
1010
1011 let req = MockRequest::new(json!([
1012 {"role": "system", "content": "sys"},
1013 {"role": "user", "content": "weather in Boston?"}
1014 ]))
1015 .with_tools(weather_tool());
1016
1017 let formatter = DeepSeekV4Formatter::new_chat();
1018 let out = formatter.render(&req).unwrap();
1019
1020 assert!(out.contains("## Tools"));
1021 assert!(out.contains("get_current_weather"));
1022 }
1023
1024 #[test]
1025 fn test_resolve_reasoning_effort_accepts_full_range() {
1026 let effort = |v: &str| {
1027 let value = json!(v);
1028 DeepSeekV4Formatter::resolve_reasoning_effort(Some(&value))
1029 };
1030
1031 assert_eq!(effort("max"), (false, Some(ReasoningEffort::Max)));
1032 assert_eq!(effort("xhigh"), (false, Some(ReasoningEffort::High)));
1033 assert_eq!(effort("high"), (false, Some(ReasoningEffort::High)));
1034 assert_eq!(effort("minimal"), (false, None));
1035 assert_eq!(effort("low"), (false, None));
1036 assert_eq!(effort("medium"), (false, Some(ReasoningEffort::High)));
1037 assert_eq!(effort("none"), (true, None));
1038 assert_eq!(effort("bogus"), (false, Some(ReasoningEffort::High)));
1039 assert_eq!(
1040 DeepSeekV4Formatter::resolve_reasoning_effort(None),
1041 (false, Some(ReasoningEffort::High))
1042 );
1043 }
1044
1045 #[test]
1046 fn test_render_leaves_null_assistant_tool_content_empty() {
1047 use crate::OAIPromptFormatter;
1048
1049 let req = MockRequest::new(json!([
1050 {"role": "user", "content": "call tool"},
1051 {"role": "assistant", "content": null, "tool_calls": [{
1052 "id": "c1", "type": "function",
1053 "function": {"name": "f", "arguments": "{}"}
1054 }]}
1055 ]));
1056
1057 let formatter = DeepSeekV4Formatter::new_chat();
1058 let out = formatter.render(&req).unwrap();
1059
1060 assert!(out.contains(&format!(
1061 "<{}{}>",
1062 tokens::DSML_TOKEN,
1063 TOOL_CALLS_BLOCK_NAME
1064 )));
1065 assert!(!out.contains("null"));
1066 }
1067
1068 #[test]
1069 fn test_render_wires_reasoning_effort_from_chat_template_args() {
1070 use crate::OAIPromptFormatter;
1071 use std::collections::HashMap;
1072
1073 for (effort, expected) in [
1074 ("high", REASONING_EFFORT_HIGH),
1075 ("max", REASONING_EFFORT_MAX),
1076 ] {
1077 let mut args = HashMap::new();
1078 args.insert("reasoning_effort".to_string(), json!(effort));
1079
1080 let req = MockRequest::new(json!([
1081 {"role": "system", "content": "sys"},
1082 {"role": "user", "content": "hi"}
1083 ]))
1084 .with_chat_template_args(args);
1085
1086 let formatter = DeepSeekV4Formatter::new_thinking();
1087 let out = formatter.render(&req).unwrap();
1088
1089 assert!(out.starts_with(tokens::BOS));
1090 assert!(
1091 out[tokens::BOS.len()..].starts_with(expected),
1092 "{effort} preamble should appear after BOS, got:\n{out}"
1093 );
1094 }
1095 }
1096
1097 #[test]
1098 fn test_render_wires_top_level_reasoning_effort_and_none_disables_thinking() {
1099 use crate::OAIPromptFormatter;
1100
1101 let formatter = DeepSeekV4Formatter::new_thinking();
1102 for (effort, expected_prefix) in [
1103 ("high", "Reasoning Effort: Absolute maximum"),
1104 ("max", "Reasoning Effort: Beyond maximum"),
1105 ] {
1106 let req: dynamo_protocols::types::CreateChatCompletionRequest =
1107 serde_json::from_value(json!({
1108 "model": "deepseek-v4",
1109 "messages": [{"role": "user", "content": "hi"}],
1110 "reasoning_effort": effort
1111 }))
1112 .unwrap();
1113 let out = formatter.render(&req).unwrap();
1114
1115 assert!(
1116 out[tokens::BOS.len()..].starts_with(expected_prefix),
1117 "top-level {effort} did not select its prefix: {out}"
1118 );
1119 assert!(out.ends_with(tokens::THINKING_START));
1120 }
1121
1122 let req: dynamo_protocols::types::CreateChatCompletionRequest =
1123 serde_json::from_value(json!({
1124 "model": "deepseek-v4",
1125 "messages": [{"role": "user", "content": "hi"}],
1126 "reasoning_effort": "none"
1127 }))
1128 .unwrap();
1129 let out = formatter.render(&req).unwrap();
1130
1131 assert_eq!(
1132 out,
1133 "<|begin▁of▁sentence|><|User|>hi<|Assistant|></think>"
1134 );
1135 }
1136
1137 #[test]
1138 fn test_top_level_reasoning_effort_precedes_template_argument() {
1139 use crate::OAIPromptFormatter;
1140 use std::collections::HashMap;
1141
1142 let mut args = HashMap::new();
1143 args.insert("reasoning_effort".to_string(), json!("max"));
1144 let req = MockRequest::new(json!([{"role": "user", "content": "hi"}]))
1145 .with_chat_template_args(args)
1146 .with_reasoning_effort(json!("low"));
1147
1148 let out = DeepSeekV4Formatter::new_thinking().render(&req).unwrap();
1149
1150 assert_eq!(
1151 out,
1152 "<|begin▁of▁sentence|><|User|>hi<|Assistant|><think>"
1153 );
1154 }
1155
1156 #[test]
1157 fn test_render_drop_thinking_override_from_chat_template_args() {
1158 use crate::OAIPromptFormatter;
1159 use std::collections::HashMap;
1160
1161 let messages = json!([
1162 {"role": "user", "content": "first"},
1163 {"role": "assistant", "reasoning_content": "PRIOR", "content": "reply"},
1164 {"role": "user", "content": "again"}
1165 ]);
1166
1167 let req_default = MockRequest::new(messages.clone());
1169 let formatter = DeepSeekV4Formatter::new_thinking();
1170 let out_default = formatter.render(&req_default).unwrap();
1171 assert!(
1172 !out_default.contains("PRIOR"),
1173 "default drop_thinking=true should strip prior reasoning, got:\n{}",
1174 out_default
1175 );
1176
1177 let mut args = HashMap::new();
1179 args.insert("drop_thinking".to_string(), json!(false));
1180 let req_keep = MockRequest::new(messages).with_chat_template_args(args);
1181 let out_keep = formatter.render(&req_keep).unwrap();
1182 assert!(
1183 out_keep.contains("PRIOR"),
1184 "drop_thinking=false override should preserve prior reasoning, got:\n{}",
1185 out_keep
1186 );
1187 }
1188
1189 #[test]
1195 fn test_developer_only_conversation_renders_developer_content() {
1196 let messages = json!([
1197 {"role": "system", "content": "sys"},
1198 {"role": "developer", "content": "x"},
1199 {"role": "assistant", "reasoning_content": "R", "content": "ok"}
1200 ]);
1201 let out =
1202 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
1203 assert!(
1204 out.contains("x"),
1205 "developer content should appear in output, got:\n{}",
1206 out
1207 );
1208 }
1209
1210 #[test]
1211 fn test_developer_as_last_user_index_controls_reasoning_cutoff() {
1212 let messages = json!([
1217 {"role": "user", "content": "a"},
1218 {"role": "assistant", "reasoning_content": "FIRST", "content": "r1"},
1219 {"role": "developer", "content": "y"},
1220 {"role": "assistant", "reasoning_content": "SECOND", "content": "r2"}
1221 ]);
1222 let out =
1223 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
1224 assert!(
1225 !out.contains("FIRST"),
1226 "reasoning before last user/developer (idx 1 < 2) should be stripped, got:\n{}",
1227 out
1228 );
1229 assert!(
1230 out.contains("SECOND"),
1231 "reasoning at/after last user/developer (idx 3 > 2) should survive, got:\n{}",
1232 out
1233 );
1234 }
1235}