1use anyhow::{Context, Result};
11use serde_json::Value as JsonValue;
12
13use super::common::{
14 NormalizeNonText, REASONING_EFFORT_MAX, RESPONSE_FORMAT_TEMPLATE, TOOL_CALLS_BLOCK_NAME,
15 TOOLS_TEMPLATE, drop_thinking_messages, encode_arguments_to_dsml, find_last_user_index,
16 merge_tool_messages, normalize_message_contents, render_tools, sort_tool_results_by_call_order,
17 task_token, to_json,
18};
19pub use super::common::{ReasoningEffort, ThinkingMode, tokens};
20
21fn render_message(
23 index: usize,
24 messages: &[JsonValue],
25 thinking_mode: ThinkingMode,
26 drop_thinking: bool,
27 reasoning_effort: Option<ReasoningEffort>,
28 last_user_idx: Option<usize>,
29) -> Result<String> {
30 let msg = &messages[index];
31
32 let role = msg
33 .get("role")
34 .and_then(|r| r.as_str())
35 .context("Missing 'role' field")?;
36
37 let mut prompt = String::new();
38
39 if index == 0
41 && thinking_mode == ThinkingMode::Thinking
42 && reasoning_effort == Some(ReasoningEffort::Max)
43 {
44 prompt.push_str(REASONING_EFFORT_MAX);
45 }
46
47 match role {
48 "system" => {
49 let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
50 prompt.push_str(content);
51 if let Some(tools) = msg.get("tools").and_then(|t| t.as_array()) {
52 prompt.push_str("\n\n");
53 prompt.push_str(&render_tools(TOOLS_TEMPLATE, tools));
54 }
55 if let Some(response_format) = msg.get("response_format") {
56 prompt.push_str("\n\n");
57 prompt.push_str(
58 &RESPONSE_FORMAT_TEMPLATE.replace("{schema}", &to_json(response_format)),
59 );
60 }
61 }
62
63 "developer" => {
64 let content = msg
65 .get("content")
66 .and_then(|c| c.as_str())
67 .filter(|s| !s.is_empty())
68 .context("Developer role requires content")?;
69
70 let mut content_developer = String::from(tokens::USER_START);
71 content_developer.push_str(content);
72
73 if let Some(tools) = msg.get("tools").and_then(|t| t.as_array()) {
74 content_developer.push_str("\n\n");
75 content_developer.push_str(&render_tools(TOOLS_TEMPLATE, tools));
76 }
77 if let Some(response_format) = msg.get("response_format") {
78 content_developer.push_str("\n\n");
79 content_developer.push_str(
80 &RESPONSE_FORMAT_TEMPLATE.replace("{schema}", &to_json(response_format)),
81 );
82 }
83 prompt.push_str(&content_developer);
84 }
85
86 "user" => {
87 prompt.push_str(tokens::USER_START);
88 if let Some(blocks) = msg.get("content_blocks").and_then(|b| b.as_array()) {
89 let mut parts: Vec<String> = Vec::with_capacity(blocks.len());
90 for block in blocks {
91 let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
92 match block_type {
93 "text" => {
94 let text = block.get("text").and_then(|v| v.as_str()).unwrap_or("");
95 parts.push(text.to_string());
96 }
97 "tool_result" => {
98 let rendered = render_tool_result_content(
99 block.get("content").unwrap_or(&JsonValue::Null),
100 );
101 parts.push(format!("<tool_result>{}</tool_result>", rendered));
102 }
103 other => {
104 parts.push(format!("[Unsupported {}]", other));
105 }
106 }
107 }
108 prompt.push_str(&parts.join("\n\n"));
109 } else {
110 let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
111 prompt.push_str(content);
112 }
113 }
114
115 "latest_reminder" => {
116 let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
117 prompt.push_str(tokens::LATEST_REMINDER);
118 prompt.push_str(content);
119 }
120
121 "tool" => {
122 anyhow::bail!(
123 "deepseek_v4 merges tool messages into user; preprocess with merge_tool_messages()"
124 );
125 }
126
127 "assistant" => {
128 let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
129 let reasoning = msg
130 .get("reasoning_content")
131 .and_then(|c| c.as_str())
132 .unwrap_or("");
133 let wo_eos = msg.get("wo_eos").and_then(|v| v.as_bool()).unwrap_or(false);
134
135 let prev_has_task = index > 0
136 && messages[index - 1]
137 .get("task")
138 .map(|v| !v.is_null())
139 .unwrap_or(false);
140
141 let mut thinking_part = String::new();
142 if thinking_mode == ThinkingMode::Thinking && !prev_has_task {
143 let render_thinking = !drop_thinking || last_user_idx.is_none_or(|u| index > u);
144 if render_thinking {
145 thinking_part.push_str(reasoning);
146 thinking_part.push_str(tokens::THINKING_END);
147 }
148 }
149
150 prompt.push_str(&thinking_part);
151 prompt.push_str(content);
152
153 if let Some(tool_calls) = msg.get("tool_calls").and_then(|t| t.as_array())
154 && !tool_calls.is_empty()
155 {
156 prompt.push_str("\n\n");
157 prompt.push_str(&format!(
158 "<{}{}>\n",
159 tokens::DSML_TOKEN,
160 TOOL_CALLS_BLOCK_NAME
161 ));
162
163 let mut invocations = Vec::with_capacity(tool_calls.len());
164 for tc in tool_calls {
165 let fn_obj = tc.get("function").unwrap_or(tc);
168 let name = fn_obj
169 .get("name")
170 .and_then(|n| n.as_str())
171 .context("Missing tool call name")?;
172 let arguments = encode_arguments_to_dsml(fn_obj)?;
173 invocations.push(format!(
174 "<{}invoke name=\"{}\">\n{}\n</{}invoke>",
175 tokens::DSML_TOKEN,
176 name,
177 arguments,
178 tokens::DSML_TOKEN
179 ));
180 }
181 prompt.push_str(&invocations.join("\n"));
182 prompt.push_str(&format!(
183 "\n</{}{}>",
184 tokens::DSML_TOKEN,
185 TOOL_CALLS_BLOCK_NAME
186 ));
187 }
188
189 if !wo_eos {
190 prompt.push_str(tokens::EOS);
191 }
192 }
193
194 other => anyhow::bail!("Unknown role: {}", other),
195 }
196
197 if index + 1 < messages.len() {
199 let next_role = messages[index + 1].get("role").and_then(|r| r.as_str());
200 if !matches!(next_role, Some("assistant") | Some("latest_reminder")) {
201 return Ok(prompt);
202 }
203 }
204
205 let task = msg.get("task").and_then(|v| v.as_str());
207 if let Some(task) = task {
208 let sp = task_token(task).with_context(|| format!("Invalid task: '{}'", task))?;
209 if task != "action" {
210 prompt.push_str(sp);
211 } else {
212 prompt.push_str(tokens::ASSISTANT_START);
213 prompt.push_str(if thinking_mode != ThinkingMode::Thinking {
214 tokens::THINKING_END
215 } else {
216 tokens::THINKING_START
217 });
218 prompt.push_str(sp);
219 }
220 } else if matches!(role, "user" | "developer") {
221 prompt.push_str(tokens::ASSISTANT_START);
222 let seed_thinking = thinking_mode == ThinkingMode::Thinking
223 && (!drop_thinking || last_user_idx.is_none_or(|u| index >= u));
224 prompt.push_str(if seed_thinking {
225 tokens::THINKING_START
226 } else {
227 tokens::THINKING_END
228 });
229 }
230
231 Ok(prompt)
232}
233
234fn render_tool_result_content(content: &JsonValue) -> String {
236 match content {
237 JsonValue::String(s) => s.clone(),
238 JsonValue::Array(items) => {
239 let mut parts: Vec<String> = Vec::with_capacity(items.len());
240 for item in items {
241 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
242 if item_type == "text" {
243 parts.push(
244 item.get("text")
245 .and_then(|v| v.as_str())
246 .unwrap_or("")
247 .to_string(),
248 );
249 } else {
250 parts.push(format!("[Unsupported {}]", item_type));
251 }
252 }
253 parts.join("\n\n")
254 }
255 JsonValue::Null => String::new(),
256 _ => to_json(content),
257 }
258}
259
260pub fn encode_messages(
264 messages: &[JsonValue],
265 thinking_mode: ThinkingMode,
266 add_bos_token: bool,
267) -> Result<String> {
268 encode_messages_with_options(messages, thinking_mode, add_bos_token, true, None)
269}
270
271pub fn encode_messages_with_options(
280 messages: &[JsonValue],
281 thinking_mode: ThinkingMode,
282 add_bos_token: bool,
283 drop_thinking: bool,
284 reasoning_effort: Option<ReasoningEffort>,
285) -> Result<String> {
286 let merged = merge_tool_messages(messages);
287 let mut full = sort_tool_results_by_call_order(merged);
288
289 let mut prompt = String::new();
290 if add_bos_token {
291 prompt.push_str(tokens::BOS);
292 }
293
294 let has_tools = full.iter().any(|m| {
296 m.get("tools")
297 .map(|v| match v {
298 JsonValue::Array(a) => !a.is_empty(),
299 JsonValue::Null => false,
300 _ => true,
301 })
302 .unwrap_or(false)
303 });
304 let effective_drop_thinking = drop_thinking && !has_tools;
305
306 if thinking_mode == ThinkingMode::Thinking && effective_drop_thinking {
307 full = drop_thinking_messages(full);
308 }
309
310 let last_user_idx = find_last_user_index(&full);
311 for idx in 0..full.len() {
312 let part = render_message(
313 idx,
314 &full,
315 thinking_mode,
316 effective_drop_thinking,
317 reasoning_effort,
318 last_user_idx,
319 )?;
320 prompt.push_str(&part);
321 }
322
323 Ok(prompt)
324}
325
326#[derive(Debug)]
328pub struct DeepSeekV4Formatter {
329 thinking_mode: ThinkingMode,
330}
331
332impl DeepSeekV4Formatter {
333 pub fn new(thinking_mode: ThinkingMode) -> Self {
334 Self { thinking_mode }
335 }
336
337 pub fn new_thinking() -> Self {
339 Self::new(ThinkingMode::Thinking)
340 }
341
342 pub fn new_chat() -> Self {
344 Self::new(ThinkingMode::Chat)
345 }
346
347 fn resolve_reasoning_effort(
348 args: Option<&std::collections::HashMap<String, serde_json::Value>>,
349 ) -> Option<ReasoningEffort> {
350 let args = args?;
351 let v = args.get("reasoning_effort")?;
352 match v.as_str() {
353 Some("max") | Some("xhigh") => Some(ReasoningEffort::Max),
354 Some("high") | Some("minimal") | Some("low") | Some("medium") => {
358 Some(ReasoningEffort::High)
359 }
360 Some("none") => None,
361 _ => {
362 tracing::warn!(
363 value = ?v,
364 "chat_template_args.reasoning_effort must be one of \"none\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", \"max\"; ignoring and using default (none)"
365 );
366 None
367 }
368 }
369 }
370
371 fn resolve_drop_thinking(
372 args: Option<&std::collections::HashMap<String, serde_json::Value>>,
373 ) -> bool {
374 let Some(args) = args else { return true };
375 let Some(v) = args.get("drop_thinking") else {
376 return true;
377 };
378 if let Some(b) = v.as_bool() {
379 return b;
380 }
381 tracing::warn!(
382 value = ?v,
383 "chat_template_args.drop_thinking must be a bool; ignoring and using default (true)"
384 );
385 true
386 }
387}
388
389impl crate::OAIPromptFormatter for DeepSeekV4Formatter {
390 fn supports_add_generation_prompt(&self) -> bool {
391 true
392 }
393
394 fn render(&self, req: &dyn crate::OAIChatLikeRequest) -> Result<String> {
395 let args = req.chat_template_args();
396 let thinking_mode = super::common::resolve_thinking_mode(args, self.thinking_mode);
397 let reasoning_effort = Self::resolve_reasoning_effort(args);
398 let drop_thinking = Self::resolve_drop_thinking(args);
399
400 let messages_value = req.messages();
401 let messages_json =
402 serde_json::to_value(&messages_value).context("Failed to convert messages to JSON")?;
403
404 let mut messages_array = messages_json
405 .as_array()
406 .context("Messages is not an array")?
407 .clone();
408
409 normalize_message_contents(&mut messages_array, NormalizeNonText::LeaveUntouched);
410
411 super::common::inject_tools_and_response_format(&mut messages_array, req)?;
412
413 encode_messages_with_options(
414 &messages_array,
415 thinking_mode,
416 true,
417 drop_thinking,
418 reasoning_effort,
419 )
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use serde_json::json;
427
428 #[test]
429 fn test_simple_conversation() {
430 let messages = json!([
431 {"role": "system", "content": "You are a helpful assistant."},
432 {"role": "user", "content": "Hello"},
433 {"role": "assistant", "reasoning_content": "greet", "content": "Hi!"},
434 {"role": "user", "content": "What is 2+2?"}
435 ]);
436 let out =
437 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
438 assert!(out.starts_with(tokens::BOS));
439 assert!(out.ends_with(&format!(
440 "{}{}",
441 tokens::ASSISTANT_START,
442 tokens::THINKING_START
443 )));
444 assert!(!out.contains("greet"));
446 }
447
448 #[test]
449 fn test_reasoning_effort_max_prefix() {
450 let messages = json!([
451 {"role": "system", "content": "hi"},
452 {"role": "user", "content": "hello"}
453 ]);
454 let out = encode_messages_with_options(
455 messages.as_array().unwrap(),
456 ThinkingMode::Thinking,
457 true,
458 true,
459 Some(ReasoningEffort::Max),
460 )
461 .unwrap();
462 assert!(out.contains("Reasoning Effort: Absolute maximum"));
463 let after_bos = &out[tokens::BOS.len()..];
465 assert!(after_bos.starts_with("Reasoning Effort:"));
466
467 let out2 = encode_messages_with_options(
469 messages.as_array().unwrap(),
470 ThinkingMode::Thinking,
471 true,
472 true,
473 Some(ReasoningEffort::High),
474 )
475 .unwrap();
476 assert!(!out2.contains("Reasoning Effort: Absolute maximum"));
477 }
478
479 #[test]
480 fn test_content_blocks_with_tool_result() {
481 let messages = json!([
487 {"role": "user", "content": "call tool"},
488 {"role": "assistant", "content": "", "tool_calls": [{
489 "id": "c1", "type": "function",
490 "function": {"name": "f", "arguments": "{}"}
491 }]},
492 {"role": "tool", "tool_call_id": "c1", "content": "RESULT"},
493 {"role": "user", "content": "thanks"}
494 ]);
495 let out = encode_messages(messages.as_array().unwrap(), ThinkingMode::Chat, true).unwrap();
496 assert!(
497 out.contains("<tool_result>RESULT</tool_result>\n\nthanks"),
498 "expected tool_result block followed by 'thanks' in the merged user turn, got:\n{}",
499 out
500 );
501 }
502
503 #[test]
504 fn test_user_task_preserved_when_merged_after_tool_result() {
505 let messages = json!([
506 {"role": "assistant", "content": "", "tool_calls": [{
507 "id": "c1", "type": "function",
508 "function": {"name": "search", "arguments": "{}"}
509 }]},
510 {"role": "tool", "tool_call_id": "c1", "content": "RESULT"},
511 {"role": "user", "content": "Search", "task": "action"},
512 {"role": "assistant", "content": "OK"}
513 ]);
514
515 let out = encode_messages(messages.as_array().unwrap(), ThinkingMode::Chat, true).unwrap();
516 assert!(
517 out.contains(&format!(
518 "{}Search{}{}{}OK",
519 "<tool_result>RESULT</tool_result>\n\n",
520 tokens::ASSISTANT_START,
521 tokens::THINKING_END,
522 tokens::TASK_ACTION
523 )),
524 "expected merged user text to keep the action task transition, got:\n{}",
525 out
526 );
527 }
528
529 #[test]
530 fn test_drop_thinking_auto_disable_when_tools_present() {
531 let messages = json!([
532 {"role": "system", "content": "s", "tools": [{
533 "type": "function",
534 "function": {"name": "f", "description": "", "parameters": {"type": "object", "properties": {}}}
535 }]},
536 {"role": "user", "content": "hi"},
537 {"role": "assistant", "reasoning_content": "PRIOR_REASONING", "content": "reply"},
538 {"role": "user", "content": "again"}
539 ]);
540 let out =
541 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
542 assert!(out.contains("PRIOR_REASONING"));
544 }
545
546 #[test]
557 fn test_assistant_reasoning_preserved_when_no_user_in_history() {
558 let messages = json!([
559 {"role": "system", "content": "sys"},
560 {"role": "assistant", "content": "hello", "reasoning_content": "REASONING_BLOCK"}
561 ]);
562 let out =
563 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
564 assert_eq!(
565 out, "<|begin▁of▁sentence|>sysREASONING_BLOCK</think>hello<|end▁of▁sentence|>",
566 "Output must match Python reference byte-for-byte when no user/developer in history"
567 );
568 }
569
570 #[test]
578 fn test_to_json_preserves_spacing_past_escaped_backslash() {
579 let v = json!({"path": "\\", "count": 5});
580 let got = to_json(&v);
581 assert_eq!(
582 got, r#"{"path": "\\", "count": 5}"#,
583 "to_json must match Python's json.dumps formatting past an escaped backslash"
584 );
585 }
586
587 #[test]
588 fn test_resolve_drop_thinking_warns_on_malformed_value() {
589 use std::collections::HashMap;
590 let mut args = HashMap::new();
592 args.insert(
593 "drop_thinking".to_string(),
594 serde_json::Value::String("false".to_string()),
595 );
596 assert!(DeepSeekV4Formatter::resolve_drop_thinking(Some(&args)));
597 let mut args2 = HashMap::new();
599 args2.insert(
600 "reasoning_effort".to_string(),
601 serde_json::Value::String("HIGH".to_string()),
602 );
603 assert_eq!(
604 DeepSeekV4Formatter::resolve_reasoning_effort(Some(&args2)),
605 None
606 );
607 }
608
609 #[test]
610 fn test_resolve_thinking_mode_honors_enable_thinking() {
611 use std::collections::HashMap;
612 let mut args = HashMap::new();
613 args.insert(
614 "enable_thinking".to_string(),
615 serde_json::Value::Bool(false),
616 );
617 assert_eq!(
618 super::super::common::resolve_thinking_mode(Some(&args), ThinkingMode::Thinking),
619 ThinkingMode::Chat
620 );
621 args.insert("enable_thinking".to_string(), serde_json::Value::Bool(true));
622 assert_eq!(
623 super::super::common::resolve_thinking_mode(Some(&args), ThinkingMode::Thinking),
624 ThinkingMode::Thinking
625 );
626 }
627
628 struct MockRequest {
629 messages: JsonValue,
630 chat_template_args: Option<std::collections::HashMap<String, JsonValue>>,
631 tools: Option<JsonValue>,
632 tool_choice: Option<JsonValue>,
633 response_format: Option<JsonValue>,
634 }
635
636 impl MockRequest {
637 fn new(messages: JsonValue) -> Self {
638 Self {
639 messages,
640 chat_template_args: None,
641 tools: None,
642 tool_choice: None,
643 response_format: None,
644 }
645 }
646
647 fn with_chat_template_args(
648 mut self,
649 args: std::collections::HashMap<String, JsonValue>,
650 ) -> Self {
651 self.chat_template_args = Some(args);
652 self
653 }
654
655 fn with_tools(mut self, tools: JsonValue) -> Self {
656 self.tools = Some(tools);
657 self
658 }
659
660 fn with_tool_choice(mut self, tool_choice: JsonValue) -> Self {
661 self.tool_choice = Some(tool_choice);
662 self
663 }
664
665 fn with_response_format(mut self, response_format: JsonValue) -> Self {
666 self.response_format = Some(response_format);
667 self
668 }
669 }
670
671 impl crate::OAIChatLikeRequest for MockRequest {
672 fn model(&self) -> String {
673 "deepseek-v4".to_string()
674 }
675
676 fn messages(&self) -> minijinja::value::Value {
677 minijinja::value::Value::from_serialize(&self.messages)
678 }
679
680 fn should_add_generation_prompt(&self) -> bool {
681 true
682 }
683
684 fn chat_template_args(
685 &self,
686 ) -> Option<&std::collections::HashMap<String, serde_json::Value>> {
687 self.chat_template_args.as_ref()
688 }
689
690 fn tools(&self) -> Option<minijinja::value::Value> {
691 self.tools
692 .as_ref()
693 .map(minijinja::value::Value::from_serialize)
694 }
695
696 fn tool_choice(&self) -> Option<minijinja::value::Value> {
697 self.tool_choice
698 .as_ref()
699 .map(minijinja::value::Value::from_serialize)
700 }
701
702 fn response_format(&self) -> Option<minijinja::value::Value> {
703 self.response_format
704 .as_ref()
705 .map(minijinja::value::Value::from_serialize)
706 }
707 }
708
709 fn weather_tool() -> JsonValue {
710 json!([{
711 "type": "function",
712 "function": {
713 "name": "get_current_weather",
714 "description": "Get the current weather in a given location",
715 "parameters": {
716 "type": "object",
717 "properties": {"location": {"type": "string"}},
718 "required": ["location"]
719 }
720 }
721 }])
722 }
723
724 #[test]
725 fn test_render_tool_choice_none_strips_tools_keeps_response_format() {
726 use crate::OAIPromptFormatter;
727
728 let req = MockRequest::new(json!([
729 {"role": "system", "content": "sys"},
730 {"role": "user", "content": "weather in Boston?"}
731 ]))
732 .with_tools(weather_tool())
733 .with_tool_choice(json!("none"))
734 .with_response_format(json!({"type": "json_object"}));
735
736 let formatter = DeepSeekV4Formatter::new_chat();
737 let out = formatter.render(&req).unwrap();
738
739 assert!(
740 !out.contains("## Tools"),
741 "tool_choice=none must strip the tools block, got: {out}"
742 );
743 assert!(
744 !out.contains("get_current_weather"),
745 "tool schema leaked into prompt despite tool_choice=none: {out}"
746 );
747 assert!(
748 out.contains("## Response Format"),
749 "response_format must survive tool_choice=none: {out}"
750 );
751 }
752
753 #[test]
754 fn test_render_tool_choice_auto_keeps_tools() {
755 use crate::OAIPromptFormatter;
756
757 let req = MockRequest::new(json!([
758 {"role": "system", "content": "sys"},
759 {"role": "user", "content": "weather in Boston?"}
760 ]))
761 .with_tools(weather_tool())
762 .with_tool_choice(json!("auto"));
763
764 let formatter = DeepSeekV4Formatter::new_chat();
765 let out = formatter.render(&req).unwrap();
766
767 assert!(out.contains("## Tools"));
768 assert!(out.contains("get_current_weather"));
769 }
770
771 #[test]
772 fn test_render_absent_tool_choice_keeps_tools() {
773 use crate::OAIPromptFormatter;
774
775 let req = MockRequest::new(json!([
776 {"role": "system", "content": "sys"},
777 {"role": "user", "content": "weather in Boston?"}
778 ]))
779 .with_tools(weather_tool());
780
781 let formatter = DeepSeekV4Formatter::new_chat();
782 let out = formatter.render(&req).unwrap();
783
784 assert!(out.contains("## Tools"));
785 assert!(out.contains("get_current_weather"));
786 }
787
788 #[test]
789 fn test_resolve_reasoning_effort_accepts_full_range() {
790 use std::collections::HashMap;
791
792 let effort = |v: &str| {
793 let mut args = HashMap::new();
794 args.insert("reasoning_effort".to_string(), json!(v));
795 DeepSeekV4Formatter::resolve_reasoning_effort(Some(&args))
796 };
797
798 assert_eq!(effort("max"), Some(ReasoningEffort::Max));
799 assert_eq!(effort("xhigh"), Some(ReasoningEffort::Max));
800 assert_eq!(effort("high"), Some(ReasoningEffort::High));
801 assert_eq!(effort("minimal"), Some(ReasoningEffort::High));
802 assert_eq!(effort("low"), Some(ReasoningEffort::High));
803 assert_eq!(effort("medium"), Some(ReasoningEffort::High));
804 assert_eq!(effort("none"), None);
805 assert_eq!(effort("bogus"), None);
806 }
807
808 #[test]
809 fn test_render_leaves_null_assistant_tool_content_empty() {
810 use crate::OAIPromptFormatter;
811
812 let req = MockRequest::new(json!([
813 {"role": "user", "content": "call tool"},
814 {"role": "assistant", "content": null, "tool_calls": [{
815 "id": "c1", "type": "function",
816 "function": {"name": "f", "arguments": "{}"}
817 }]}
818 ]));
819
820 let formatter = DeepSeekV4Formatter::new_chat();
821 let out = formatter.render(&req).unwrap();
822
823 assert!(out.contains(&format!(
824 "<{}{}>",
825 tokens::DSML_TOKEN,
826 TOOL_CALLS_BLOCK_NAME
827 )));
828 assert!(!out.contains("null"));
829 }
830
831 #[test]
832 fn test_render_wires_reasoning_effort_max_from_chat_template_args() {
833 use crate::OAIPromptFormatter;
834 use std::collections::HashMap;
835
836 let mut args = HashMap::new();
837 args.insert("reasoning_effort".to_string(), json!("max"));
838
839 let req = MockRequest::new(json!([
840 {"role": "system", "content": "sys"},
841 {"role": "user", "content": "hi"}
842 ]))
843 .with_chat_template_args(args);
844
845 let formatter = DeepSeekV4Formatter::new_thinking();
846 let out = formatter.render(&req).unwrap();
847
848 assert!(out.starts_with(tokens::BOS));
849 let after_bos = &out[tokens::BOS.len()..];
850 assert!(
851 after_bos.starts_with("Reasoning Effort:"),
852 "REASONING_EFFORT_MAX preamble should appear at start (after BOS), got:\n{}",
853 out
854 );
855 }
856
857 #[test]
858 fn test_render_drop_thinking_override_from_chat_template_args() {
859 use crate::OAIPromptFormatter;
860 use std::collections::HashMap;
861
862 let messages = json!([
863 {"role": "user", "content": "first"},
864 {"role": "assistant", "reasoning_content": "PRIOR", "content": "reply"},
865 {"role": "user", "content": "again"}
866 ]);
867
868 let req_default = MockRequest::new(messages.clone());
870 let formatter = DeepSeekV4Formatter::new_thinking();
871 let out_default = formatter.render(&req_default).unwrap();
872 assert!(
873 !out_default.contains("PRIOR"),
874 "default drop_thinking=true should strip prior reasoning, got:\n{}",
875 out_default
876 );
877
878 let mut args = HashMap::new();
880 args.insert("drop_thinking".to_string(), json!(false));
881 let req_keep = MockRequest::new(messages).with_chat_template_args(args);
882 let out_keep = formatter.render(&req_keep).unwrap();
883 assert!(
884 out_keep.contains("PRIOR"),
885 "drop_thinking=false override should preserve prior reasoning, got:\n{}",
886 out_keep
887 );
888 }
889
890 #[test]
896 fn test_developer_only_conversation_renders_developer_content() {
897 let messages = json!([
898 {"role": "system", "content": "sys"},
899 {"role": "developer", "content": "x"},
900 {"role": "assistant", "reasoning_content": "R", "content": "ok"}
901 ]);
902 let out =
903 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
904 assert!(
905 out.contains("x"),
906 "developer content should appear in output, got:\n{}",
907 out
908 );
909 }
910
911 #[test]
912 fn test_developer_as_last_user_index_controls_reasoning_cutoff() {
913 let messages = json!([
918 {"role": "user", "content": "a"},
919 {"role": "assistant", "reasoning_content": "FIRST", "content": "r1"},
920 {"role": "developer", "content": "y"},
921 {"role": "assistant", "reasoning_content": "SECOND", "content": "r2"}
922 ]);
923 let out =
924 encode_messages(messages.as_array().unwrap(), ThinkingMode::Thinking, true).unwrap();
925 assert!(
926 !out.contains("FIRST"),
927 "reasoning before last user/developer (idx 1 < 2) should be stripped, got:\n{}",
928 out
929 );
930 assert!(
931 out.contains("SECOND"),
932 "reasoning at/after last user/developer (idx 3 > 2) should survive, got:\n{}",
933 out
934 );
935 }
936}