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