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 crate::reject_unsupported_partial_assistant(&messages_json)?;
478 crate::reject_unsupported_message_tools(&messages_json, &["developer"])?;
479
480 let mut messages_array = messages_json
481 .as_array()
482 .context("Messages is not an array")?
483 .clone();
484
485 normalize_message_contents(&mut messages_array, NormalizeNonText::LeaveUntouched);
486
487 super::common::inject_tools_and_response_format(&mut messages_array, req)?;
488
489 encode_messages_with_options(
490 &messages_array,
491 thinking_mode,
492 true,
493 drop_thinking,
494 reasoning_effort,
495 )
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use serde_json::json;
503
504 #[test]
505 fn test_simple_conversation() {
506 let messages = json!([
507 {"role": "system", "content": "You are a helpful assistant."},
508 {"role": "user", "content": "Hello"},
509 {"role": "assistant", "reasoning_content": "greet", "content": "Hi!"},
510 {"role": "user", "content": "What is 2+2?"}
511 ]);
512 let out =
513 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
514 assert!(out.starts_with(tokens::BOS));
515 assert!(out.ends_with(&format!(
516 "{}{}",
517 tokens::ASSISTANT_START,
518 tokens::THINKING_START
519 )));
520 assert!(!out.contains("greet"));
522 }
523
524 #[test]
525 fn test_reasoning_effort_prefixes() {
526 let messages = json!([
527 {"role": "system", "content": "hi"},
528 {"role": "user", "content": "hello"}
529 ]);
530
531 let high = encode_messages_with_options(
532 messages.as_array().unwrap(),
533 ThinkingMode::Thinking,
534 true,
535 true,
536 Some(ReasoningEffort::High),
537 )
538 .unwrap();
539 let max = encode_messages_with_options(
540 messages.as_array().unwrap(),
541 ThinkingMode::Thinking,
542 true,
543 true,
544 Some(ReasoningEffort::Max),
545 )
546 .unwrap();
547 let low = encode_messages_with_options(
548 messages.as_array().unwrap(),
549 ThinkingMode::Thinking,
550 true,
551 true,
552 None,
553 )
554 .unwrap();
555
556 assert_eq!(
557 high,
558 concat!(
559 "<|begin▁of▁sentence|>Reasoning Effort: Absolute maximum with no shortcuts permitted.\n",
560 "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",
561 "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",
562 "hi<|User|>hello<|Assistant|><think>"
563 )
564 );
565 assert_eq!(
566 max,
567 concat!(
568 "<|begin▁of▁sentence|>Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.\n",
569 "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",
570 "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",
571 "hi<|User|>hello<|Assistant|><think>"
572 )
573 );
574 assert_eq!(
575 low,
576 "<|begin▁of▁sentence|>hi<|User|>hello<|Assistant|><think>"
577 );
578 }
579
580 #[test]
581 fn test_content_blocks_with_tool_result() {
582 let messages = json!([
588 {"role": "user", "content": "call tool"},
589 {"role": "assistant", "content": "", "tool_calls": [{
590 "id": "c1", "type": "function",
591 "function": {"name": "f", "arguments": "{}"}
592 }]},
593 {"role": "tool", "tool_call_id": "c1", "content": "RESULT"},
594 {"role": "user", "content": "thanks"}
595 ]);
596 let out = encode_messages(messages.as_array().unwrap(), ThinkingMode::Chat, true).unwrap();
597 assert!(
598 out.contains("<tool_result>RESULT</tool_result>\n\nthanks"),
599 "expected tool_result block followed by 'thanks' in the merged user turn, got:\n{}",
600 out
601 );
602 }
603
604 #[test]
605 fn test_user_task_preserved_when_merged_after_tool_result() {
606 let messages = json!([
607 {"role": "assistant", "content": "", "tool_calls": [{
608 "id": "c1", "type": "function",
609 "function": {"name": "search", "arguments": "{}"}
610 }]},
611 {"role": "tool", "tool_call_id": "c1", "content": "RESULT"},
612 {"role": "user", "content": "Search", "task": "action"},
613 {"role": "assistant", "content": "OK"}
614 ]);
615
616 let out = encode_messages(messages.as_array().unwrap(), ThinkingMode::Chat, true).unwrap();
617 assert!(
618 out.contains(&format!(
619 "{}Search{}{}{}OK",
620 "<tool_result>RESULT</tool_result>\n\n",
621 tokens::ASSISTANT_START,
622 tokens::THINKING_END,
623 tokens::TASK_ACTION
624 )),
625 "expected merged user text to keep the action task transition, got:\n{}",
626 out
627 );
628 }
629
630 #[test]
631 fn test_drop_thinking_auto_disable_when_tools_present() {
632 let messages = json!([
633 {"role": "system", "content": "s", "tools": [{
634 "type": "function",
635 "function": {"name": "f", "description": "", "parameters": {"type": "object", "properties": {}}}
636 }]},
637 {"role": "user", "content": "hi"},
638 {"role": "assistant", "reasoning_content": "PRIOR_REASONING", "content": "reply"},
639 {"role": "user", "content": "again"}
640 ]);
641 let out =
642 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
643 assert!(out.contains("PRIOR_REASONING"));
645 }
646
647 #[test]
658 fn test_assistant_reasoning_preserved_when_no_user_in_history() {
659 let messages = json!([
660 {"role": "system", "content": "sys"},
661 {"role": "assistant", "content": "hello", "reasoning_content": "REASONING_BLOCK"}
662 ]);
663 let out =
664 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
665 assert_eq!(
666 out, "<|begin▁of▁sentence|>sysREASONING_BLOCK</think>hello<|end▁of▁sentence|>",
667 "Output must match Python reference byte-for-byte when no user/developer in history"
668 );
669 }
670
671 #[test]
679 fn test_to_json_preserves_spacing_past_escaped_backslash() {
680 let v = json!({"path": "\\", "count": 5});
681 let got = to_json(&v);
682 assert_eq!(
683 got, r#"{"path": "\\", "count": 5}"#,
684 "to_json must match Python's json.dumps formatting past an escaped backslash"
685 );
686 }
687
688 #[test]
689 fn test_resolve_drop_thinking_warns_on_malformed_value() {
690 use std::collections::HashMap;
691 let mut args = HashMap::new();
693 args.insert(
694 "drop_thinking".to_string(),
695 serde_json::Value::String("false".to_string()),
696 );
697 assert!(DeepSeekV4Formatter::resolve_drop_thinking(Some(&args)));
698 let malformed = serde_json::Value::String("HIGH".to_string());
700 assert_eq!(
701 DeepSeekV4Formatter::resolve_reasoning_effort(Some(&malformed)),
702 (false, Some(ReasoningEffort::High))
703 );
704 }
705
706 #[test]
707 fn test_resolve_thinking_mode_honors_enable_thinking() {
708 use std::collections::HashMap;
709 let mut args = HashMap::new();
710 args.insert(
711 "enable_thinking".to_string(),
712 serde_json::Value::Bool(false),
713 );
714 assert_eq!(
715 super::super::common::resolve_thinking_mode(Some(&args), ThinkingMode::Thinking),
716 ThinkingMode::Chat
717 );
718 args.insert("enable_thinking".to_string(), serde_json::Value::Bool(true));
719 assert_eq!(
720 super::super::common::resolve_thinking_mode(Some(&args), ThinkingMode::Thinking),
721 ThinkingMode::Thinking
722 );
723 }
724
725 struct MockRequest {
726 messages: JsonValue,
727 chat_template_args: Option<std::collections::HashMap<String, JsonValue>>,
728 reasoning_effort: Option<JsonValue>,
729 tools: Option<JsonValue>,
730 tool_choice: Option<JsonValue>,
731 response_format: Option<JsonValue>,
732 }
733
734 impl MockRequest {
735 fn new(messages: JsonValue) -> Self {
736 Self {
737 messages,
738 chat_template_args: None,
739 reasoning_effort: None,
740 tools: None,
741 tool_choice: None,
742 response_format: None,
743 }
744 }
745
746 fn with_chat_template_args(
747 mut self,
748 args: std::collections::HashMap<String, JsonValue>,
749 ) -> Self {
750 self.chat_template_args = Some(args);
751 self
752 }
753
754 fn with_reasoning_effort(mut self, reasoning_effort: JsonValue) -> Self {
755 self.reasoning_effort = Some(reasoning_effort);
756 self
757 }
758
759 fn with_tools(mut self, tools: JsonValue) -> Self {
760 self.tools = Some(tools);
761 self
762 }
763
764 fn with_tool_choice(mut self, tool_choice: JsonValue) -> Self {
765 self.tool_choice = Some(tool_choice);
766 self
767 }
768
769 fn with_response_format(mut self, response_format: JsonValue) -> Self {
770 self.response_format = Some(response_format);
771 self
772 }
773 }
774
775 impl crate::OAIChatLikeRequest for MockRequest {
776 fn model(&self) -> String {
777 "deepseek-v4".to_string()
778 }
779
780 fn messages(&self) -> minijinja::value::Value {
781 minijinja::value::Value::from_serialize(&self.messages)
782 }
783
784 fn should_add_generation_prompt(&self) -> bool {
785 true
786 }
787
788 fn chat_template_args(
789 &self,
790 ) -> Option<&std::collections::HashMap<String, serde_json::Value>> {
791 self.chat_template_args.as_ref()
792 }
793
794 fn reasoning_effort(&self) -> Option<minijinja::value::Value> {
795 self.reasoning_effort
796 .as_ref()
797 .map(minijinja::value::Value::from_serialize)
798 }
799
800 fn tools(&self) -> Option<minijinja::value::Value> {
801 self.tools
802 .as_ref()
803 .map(minijinja::value::Value::from_serialize)
804 }
805
806 fn tool_choice(&self) -> Option<minijinja::value::Value> {
807 self.tool_choice
808 .as_ref()
809 .map(minijinja::value::Value::from_serialize)
810 }
811
812 fn response_format(&self) -> Option<minijinja::value::Value> {
813 self.response_format
814 .as_ref()
815 .map(minijinja::value::Value::from_serialize)
816 }
817 }
818
819 fn weather_tool() -> JsonValue {
820 json!([{
821 "type": "function",
822 "function": {
823 "name": "get_current_weather",
824 "description": "Get the current weather in a given location",
825 "parameters": {
826 "type": "object",
827 "properties": {"location": {"type": "string"}},
828 "required": ["location"]
829 }
830 }
831 }])
832 }
833
834 #[test]
835 fn test_formatter_rejects_unsupported_partial_assistant() {
836 use crate::OAIPromptFormatter;
837
838 let request = MockRequest::new(json!([
839 {"role": "user", "content": "Continue"},
840 {"role": "assistant", "content": "prefix", "partial": true}
841 ]));
842 let error = DeepSeekV4Formatter::new_thinking()
843 .render(&request)
844 .unwrap_err();
845
846 assert!(matches!(
847 error.downcast_ref::<crate::PromptRenderError>(),
848 Some(crate::PromptRenderError::InvalidRequest(message))
849 if message.contains("`partial: true` is not supported")
850 ));
851 }
852
853 #[test]
854 fn test_formatter_rejects_system_tools_before_injection() {
855 use crate::OAIPromptFormatter;
856
857 let request = MockRequest::new(json!([
858 {"role": "system", "tools": [
859 {"type": "function", "function": {"name": "dynamic_tool"}}
860 ]},
861 {"role": "user", "content": "Use a tool"}
862 ]))
863 .with_tools(weather_tool());
864 let error = DeepSeekV4Formatter::new_thinking()
865 .render(&request)
866 .unwrap_err();
867
868 assert!(matches!(
869 error.downcast_ref::<crate::PromptRenderError>(),
870 Some(crate::PromptRenderError::InvalidRequest(message))
871 if message.contains("message-level `tools`") && message.contains("system")
872 ));
873 }
874
875 #[test]
876 fn test_formatter_preserves_developer_tools_with_top_level_tools() {
877 use crate::OAIPromptFormatter;
878
879 let request = MockRequest::new(json!([
880 {"role": "developer", "content": "Use a tool", "tools": [
881 {"type": "function", "function": {"name": "developer_tool"}}
882 ]}
883 ]))
884 .with_tools(weather_tool());
885 let rendered = DeepSeekV4Formatter::new_thinking()
886 .render(&request)
887 .unwrap();
888
889 assert!(rendered.contains("developer_tool"));
890 assert!(rendered.contains("get_current_weather"));
891 }
892
893 #[test]
894 fn test_render_tool_choice_none_strips_tools_keeps_response_format() {
895 use crate::OAIPromptFormatter;
896
897 let req = MockRequest::new(json!([
898 {"role": "system", "content": "sys"},
899 {"role": "user", "content": "weather in Boston?"}
900 ]))
901 .with_tools(weather_tool())
902 .with_tool_choice(json!("none"))
903 .with_response_format(json!({"type": "json_object"}));
904
905 let formatter = DeepSeekV4Formatter::new_chat();
906 let out = formatter.render(&req).unwrap();
907
908 assert!(
909 !out.contains("## Tools"),
910 "tool_choice=none must strip the tools block, got: {out}"
911 );
912 assert!(
913 !out.contains("get_current_weather"),
914 "tool schema leaked into prompt despite tool_choice=none: {out}"
915 );
916 assert!(
917 out.contains("## Response Format"),
918 "response_format must survive tool_choice=none: {out}"
919 );
920 }
921
922 #[test]
923 fn test_render_tool_choice_auto_keeps_tools() {
924 use crate::OAIPromptFormatter;
925
926 let req = MockRequest::new(json!([
927 {"role": "system", "content": "sys"},
928 {"role": "user", "content": "weather in Boston?"}
929 ]))
930 .with_tools(weather_tool())
931 .with_tool_choice(json!("auto"));
932
933 let formatter = DeepSeekV4Formatter::new_chat();
934 let out = formatter.render(&req).unwrap();
935
936 assert!(out.contains("## Tools"));
937 assert!(out.contains("get_current_weather"));
938 }
939
940 #[test]
941 fn test_render_absent_tool_choice_keeps_tools() {
942 use crate::OAIPromptFormatter;
943
944 let req = MockRequest::new(json!([
945 {"role": "system", "content": "sys"},
946 {"role": "user", "content": "weather in Boston?"}
947 ]))
948 .with_tools(weather_tool());
949
950 let formatter = DeepSeekV4Formatter::new_chat();
951 let out = formatter.render(&req).unwrap();
952
953 assert!(out.contains("## Tools"));
954 assert!(out.contains("get_current_weather"));
955 }
956
957 #[test]
958 fn test_resolve_reasoning_effort_accepts_full_range() {
959 let effort = |v: &str| {
960 let value = json!(v);
961 DeepSeekV4Formatter::resolve_reasoning_effort(Some(&value))
962 };
963
964 assert_eq!(effort("max"), (false, Some(ReasoningEffort::Max)));
965 assert_eq!(effort("xhigh"), (false, Some(ReasoningEffort::High)));
966 assert_eq!(effort("high"), (false, Some(ReasoningEffort::High)));
967 assert_eq!(effort("minimal"), (false, None));
968 assert_eq!(effort("low"), (false, None));
969 assert_eq!(effort("medium"), (false, Some(ReasoningEffort::High)));
970 assert_eq!(effort("none"), (true, None));
971 assert_eq!(effort("bogus"), (false, Some(ReasoningEffort::High)));
972 assert_eq!(
973 DeepSeekV4Formatter::resolve_reasoning_effort(None),
974 (false, Some(ReasoningEffort::High))
975 );
976 }
977
978 #[test]
979 fn test_render_leaves_null_assistant_tool_content_empty() {
980 use crate::OAIPromptFormatter;
981
982 let req = MockRequest::new(json!([
983 {"role": "user", "content": "call tool"},
984 {"role": "assistant", "content": null, "tool_calls": [{
985 "id": "c1", "type": "function",
986 "function": {"name": "f", "arguments": "{}"}
987 }]}
988 ]));
989
990 let formatter = DeepSeekV4Formatter::new_chat();
991 let out = formatter.render(&req).unwrap();
992
993 assert!(out.contains(&format!(
994 "<{}{}>",
995 tokens::DSML_TOKEN,
996 TOOL_CALLS_BLOCK_NAME
997 )));
998 assert!(!out.contains("null"));
999 }
1000
1001 #[test]
1002 fn test_render_wires_reasoning_effort_from_chat_template_args() {
1003 use crate::OAIPromptFormatter;
1004 use std::collections::HashMap;
1005
1006 for (effort, expected) in [
1007 ("high", REASONING_EFFORT_HIGH),
1008 ("max", REASONING_EFFORT_MAX),
1009 ] {
1010 let mut args = HashMap::new();
1011 args.insert("reasoning_effort".to_string(), json!(effort));
1012
1013 let req = MockRequest::new(json!([
1014 {"role": "system", "content": "sys"},
1015 {"role": "user", "content": "hi"}
1016 ]))
1017 .with_chat_template_args(args);
1018
1019 let formatter = DeepSeekV4Formatter::new_thinking();
1020 let out = formatter.render(&req).unwrap();
1021
1022 assert!(out.starts_with(tokens::BOS));
1023 assert!(
1024 out[tokens::BOS.len()..].starts_with(expected),
1025 "{effort} preamble should appear after BOS, got:\n{out}"
1026 );
1027 }
1028 }
1029
1030 #[test]
1031 fn test_render_wires_top_level_reasoning_effort_and_none_disables_thinking() {
1032 use crate::OAIPromptFormatter;
1033
1034 let formatter = DeepSeekV4Formatter::new_thinking();
1035 for (effort, expected_prefix) in [
1036 ("high", "Reasoning Effort: Absolute maximum"),
1037 ("max", "Reasoning Effort: Beyond maximum"),
1038 ] {
1039 let req: dynamo_protocols::types::CreateChatCompletionRequest =
1040 serde_json::from_value(json!({
1041 "model": "deepseek-v4",
1042 "messages": [{"role": "user", "content": "hi"}],
1043 "reasoning_effort": effort
1044 }))
1045 .unwrap();
1046 let out = formatter.render(&req).unwrap();
1047
1048 assert!(
1049 out[tokens::BOS.len()..].starts_with(expected_prefix),
1050 "top-level {effort} did not select its prefix: {out}"
1051 );
1052 assert!(out.ends_with(tokens::THINKING_START));
1053 }
1054
1055 let req: dynamo_protocols::types::CreateChatCompletionRequest =
1056 serde_json::from_value(json!({
1057 "model": "deepseek-v4",
1058 "messages": [{"role": "user", "content": "hi"}],
1059 "reasoning_effort": "none"
1060 }))
1061 .unwrap();
1062 let out = formatter.render(&req).unwrap();
1063
1064 assert_eq!(
1065 out,
1066 "<|begin▁of▁sentence|><|User|>hi<|Assistant|></think>"
1067 );
1068 }
1069
1070 #[test]
1071 fn test_top_level_reasoning_effort_precedes_template_argument() {
1072 use crate::OAIPromptFormatter;
1073 use std::collections::HashMap;
1074
1075 let mut args = HashMap::new();
1076 args.insert("reasoning_effort".to_string(), json!("max"));
1077 let req = MockRequest::new(json!([{"role": "user", "content": "hi"}]))
1078 .with_chat_template_args(args)
1079 .with_reasoning_effort(json!("low"));
1080
1081 let out = DeepSeekV4Formatter::new_thinking().render(&req).unwrap();
1082
1083 assert_eq!(
1084 out,
1085 "<|begin▁of▁sentence|><|User|>hi<|Assistant|><think>"
1086 );
1087 }
1088
1089 #[test]
1090 fn test_render_drop_thinking_override_from_chat_template_args() {
1091 use crate::OAIPromptFormatter;
1092 use std::collections::HashMap;
1093
1094 let messages = json!([
1095 {"role": "user", "content": "first"},
1096 {"role": "assistant", "reasoning_content": "PRIOR", "content": "reply"},
1097 {"role": "user", "content": "again"}
1098 ]);
1099
1100 let req_default = MockRequest::new(messages.clone());
1102 let formatter = DeepSeekV4Formatter::new_thinking();
1103 let out_default = formatter.render(&req_default).unwrap();
1104 assert!(
1105 !out_default.contains("PRIOR"),
1106 "default drop_thinking=true should strip prior reasoning, got:\n{}",
1107 out_default
1108 );
1109
1110 let mut args = HashMap::new();
1112 args.insert("drop_thinking".to_string(), json!(false));
1113 let req_keep = MockRequest::new(messages).with_chat_template_args(args);
1114 let out_keep = formatter.render(&req_keep).unwrap();
1115 assert!(
1116 out_keep.contains("PRIOR"),
1117 "drop_thinking=false override should preserve prior reasoning, got:\n{}",
1118 out_keep
1119 );
1120 }
1121
1122 #[test]
1128 fn test_developer_only_conversation_renders_developer_content() {
1129 let messages = json!([
1130 {"role": "system", "content": "sys"},
1131 {"role": "developer", "content": "x"},
1132 {"role": "assistant", "reasoning_content": "R", "content": "ok"}
1133 ]);
1134 let out =
1135 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
1136 assert!(
1137 out.contains("x"),
1138 "developer content should appear in output, got:\n{}",
1139 out
1140 );
1141 }
1142
1143 #[test]
1144 fn test_developer_as_last_user_index_controls_reasoning_cutoff() {
1145 let messages = json!([
1150 {"role": "user", "content": "a"},
1151 {"role": "assistant", "reasoning_content": "FIRST", "content": "r1"},
1152 {"role": "developer", "content": "y"},
1153 {"role": "assistant", "reasoning_content": "SECOND", "content": "r2"}
1154 ]);
1155 let out =
1156 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
1157 assert!(
1158 !out.contains("FIRST"),
1159 "reasoning before last user/developer (idx 1 < 2) should be stripped, got:\n{}",
1160 out
1161 );
1162 assert!(
1163 out.contains("SECOND"),
1164 "reasoning at/after last user/developer (idx 3 > 2) should survive, got:\n{}",
1165 out
1166 );
1167 }
1168}