1#[derive(Debug, Clone, PartialEq)]
28pub struct ToolCall {
29 pub name: String,
30 pub params: Vec<(String, String)>,
31}
32
33#[derive(Debug, Clone, PartialEq)]
35pub struct Turn {
36 pub role: String,
37 pub content: String,
38 pub tool_calls: Vec<ToolCall>,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ThinkMode {
58 Default,
59 NoThink,
60 Think,
61}
62
63pub fn apply_chat_template_str(
69 template: Option<&str>,
70 messages: &[(&str, &str)],
71 add_generation_prompt: bool,
72) -> String {
73 if template.is_some_and(|t| t.contains("hy_User")) {
77 return apply_hy3_template(messages, add_generation_prompt, "no_think");
78 }
79 if template.is_some_and(|t| t.contains("render_message_content")) {
85 let turns: Vec<Turn> = messages
86 .iter()
87 .map(|(r, c)| Turn {
88 role: r.to_string(),
89 content: c.to_string(),
90 tool_calls: Vec::new(),
91 })
92 .collect();
93 return apply_step35_template(&turns, add_generation_prompt, &[], None);
94 }
95 if template.is_some_and(|t| t.contains("<|turn>")) {
101 return apply_gemma4_template(messages, add_generation_prompt, false);
102 }
103 let qwen_think = template
105 .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
106 .unwrap_or(false);
107
108 let mut out = String::new();
109 for (i, (role, content)) in messages.iter().enumerate() {
110 let content = content.trim();
111 match *role {
112 "system" => {
113 let _ = i;
116 out.push_str("<|im_start|>system\n");
117 out.push_str(content);
118 out.push_str("<|im_end|>\n");
119 }
120 "user" => {
121 out.push_str("<|im_start|>user\n");
122 out.push_str(content);
123 out.push_str("<|im_end|>\n");
124 }
125 "assistant" => {
126 out.push_str("<|im_start|>assistant\n");
127 out.push_str(content);
128 out.push_str("<|im_end|>\n");
129 }
130 other => {
131 out.push_str("<|im_start|>");
133 out.push_str(other);
134 out.push('\n');
135 out.push_str(content);
136 out.push_str("<|im_end|>\n");
137 }
138 }
139 }
140
141 if add_generation_prompt {
142 out.push_str("<|im_start|>assistant\n");
143 if qwen_think {
144 out.push_str("<think>\n");
145 }
146 }
147
148 out
149}
150
151const QWEN_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
155following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
156<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
157This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
158</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
159format: an inner <function=...></function> block must be nested within <tool_call></tool_call> \
160XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for \
161your function call in natural language BEFORE the function call, but NOT after\n- If there is \
162no function call available, answer the question like normal with your current knowledge and do \
163not tell the user about function calls\n</IMPORTANT>";
164
165pub fn apply_chat_template_tools(
195 template: Option<&str>,
196 turns: &[Turn],
197 add_generation_prompt: bool,
198 tools_json: &[String],
199 think: ThinkMode,
200 reasoning_effort: Option<&str>,
201) -> Result<String, String> {
202 let has_tool_features = !tools_json.is_empty()
203 || turns
204 .iter()
205 .any(|t| t.role == "tool" || !t.tool_calls.is_empty());
206 let tools_branch = template.is_some_and(|t| t.contains("<tools>"));
207 if has_tool_features && !tools_branch {
208 return Err("model chat template has no tools branch".into());
209 }
210 if template.is_some_and(|t| t.contains("render_message_content")) {
217 return Ok(apply_step35_template(
218 turns,
219 add_generation_prompt,
220 tools_json,
221 reasoning_effort,
222 ));
223 }
224 if template.is_some_and(|t| t.contains("hy_User") || t.contains("<|turn>")) {
225 if has_tool_features {
234 return Err("tools are not supported on this model's chat-template dialect".into());
235 }
236 let messages: Vec<(&str, &str)> = turns
237 .iter()
238 .map(|t| (t.role.as_str(), t.content.as_str()))
239 .collect();
240 if template.is_some_and(|t| t.contains("hy_User")) {
241 let effort = match (think, reasoning_effort) {
244 (ThinkMode::Think, Some("high")) => "high",
245 (ThinkMode::Think, _) => "low",
246 _ => "no_think",
247 };
248 return Ok(apply_hy3_template(&messages, add_generation_prompt, effort));
249 }
250 return Ok(apply_gemma4_template(
251 &messages,
252 add_generation_prompt,
253 think == ThinkMode::Think,
254 ));
255 }
256 let qwen_think = template
257 .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
258 .unwrap_or(false);
259 let think_switch = template.is_some_and(|t| t.contains("enable_thinking"));
260
261 let mut out = String::new();
262 let mut skip_leading_system = false;
265 if !tools_json.is_empty() {
266 out.push_str("<|im_start|>system\n");
267 out.push_str("# Tools\n\nYou have access to the following functions:\n\n<tools>");
268 for tool in tools_json {
269 out.push('\n');
270 out.push_str(tool);
271 }
272 out.push_str("\n</tools>");
273 out.push_str(QWEN_TOOLS_INSTRUCTION);
274 if let Some(first) = turns.first() {
275 if first.role == "system" {
276 skip_leading_system = true;
277 let content = first.content.trim();
278 if !content.is_empty() {
279 out.push_str("\n\n");
280 out.push_str(content);
281 }
282 }
283 }
284 out.push_str("<|im_end|>\n");
285 }
286
287 for (i, turn) in turns.iter().enumerate() {
288 if i == 0 && skip_leading_system {
289 continue;
290 }
291 let content = turn.content.trim();
292 match turn.role.as_str() {
293 "system" => {
294 out.push_str("<|im_start|>system\n");
295 out.push_str(content);
296 out.push_str("<|im_end|>\n");
297 }
298 "user" => {
299 out.push_str("<|im_start|>user\n");
300 out.push_str(content);
301 out.push_str("<|im_end|>\n");
302 }
303 "assistant" => {
304 out.push_str("<|im_start|>assistant\n");
305 out.push_str(content);
306 for (k, call) in turn.tool_calls.iter().enumerate() {
307 if k == 0 {
308 if !content.is_empty() {
309 out.push_str("\n\n");
310 }
311 } else {
312 out.push('\n');
313 }
314 out.push_str("<tool_call>\n<function=");
315 out.push_str(&call.name);
316 out.push_str(">\n");
317 for (key, value) in &call.params {
318 out.push_str("<parameter=");
319 out.push_str(key);
320 out.push_str(">\n");
321 out.push_str(value);
322 out.push_str("\n</parameter>\n");
323 }
324 out.push_str("</function>\n</tool_call>");
325 }
326 out.push_str("<|im_end|>\n");
327 }
328 "tool" => {
329 if i == 0 || turns[i - 1].role != "tool" {
330 out.push_str("<|im_start|>user");
331 }
332 out.push_str("\n<tool_response>\n");
333 out.push_str(content);
334 out.push_str("\n</tool_response>");
335 if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
336 out.push_str("<|im_end|>\n");
337 }
338 }
339 other => {
340 out.push_str("<|im_start|>");
342 out.push_str(other);
343 out.push('\n');
344 out.push_str(content);
345 out.push_str("<|im_end|>\n");
346 }
347 }
348 }
349
350 if add_generation_prompt {
351 out.push_str("<|im_start|>assistant\n");
352 if qwen_think {
353 if think == ThinkMode::NoThink && think_switch {
354 out.push_str("<think>\n\n</think>\n\n");
355 } else {
356 out.push_str("<think>\n");
357 }
358 }
359 }
360 Ok(out)
361}
362
363const STEP35_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
372following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
373<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
374This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
375</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
376format: an inner <function=...>\n...\n</function> block must be nested within <tool_call>\n\
377...\n</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>";
378
379fn apply_step35_template(
424 turns: &[Turn],
425 add_generation_prompt: bool,
426 tools_json: &[String],
427 reasoning_effort: Option<&str>,
428) -> String {
429 let mut out = String::new();
430 let leading_system = turns.first().filter(|t| t.role == "system");
431
432 if !tools_json.is_empty() {
434 out.push_str("<|im_start|>system\n");
435 if let Some(effort) = reasoning_effort {
436 out.push_str("Reasoning: ");
437 out.push_str(effort);
438 out.push_str("\n\n");
439 }
440 if let Some(sys) = leading_system {
441 out.push_str(&sys.content);
443 out.push_str("\n\n");
444 }
445 out.push_str(
446 "# Tools\n\nYou have access to the following functions in JSONSchema \
447 format:\n\n<tools>",
448 );
449 for tool in tools_json {
450 out.push('\n');
451 out.push_str(tool);
452 }
453 out.push_str("\n</tools>");
454 out.push_str(STEP35_TOOLS_INSTRUCTION);
455 out.push_str("<|im_end|>\n");
456 } else if let Some(sys) = leading_system {
457 out.push_str("<|im_start|>system\n");
458 if let Some(effort) = reasoning_effort {
459 out.push_str("Reasoning: ");
460 out.push_str(effort);
461 out.push_str("\n\n");
462 }
463 out.push_str(&sys.content);
464 out.push_str("<|im_end|>\n");
465 } else if let Some(effort) = reasoning_effort {
466 out.push_str("<|im_start|>system\nReasoning: ");
467 out.push_str(effort);
468 out.push_str("\n\n<|im_end|>\n");
469 }
470
471 let last_query_index = turns
476 .iter()
477 .enumerate()
478 .rev()
479 .find(|(_, t)| {
480 t.role == "user"
481 && !(t.content.starts_with("<tool_response>")
482 && t.content.ends_with("</tool_response>"))
483 })
484 .map(|(i, _)| i)
485 .unwrap_or(turns.len().saturating_sub(1));
486
487 for (i, turn) in turns.iter().enumerate() {
488 let content = &turn.content; match turn.role.as_str() {
490 "system" if i == 0 => {}
492 "system" | "user" => {
493 out.push_str("<|im_start|>");
494 out.push_str(&turn.role);
495 out.push('\n');
496 out.push_str(content);
497 out.push_str("<|im_end|>\n");
498 }
499 "assistant" => {
500 let (reasoning, body): (String, &str) = match content.find("</think>") {
506 Some(first) => {
507 let pre = content[..first].trim_end_matches('\n');
508 let pre = match pre.rfind("<think>") {
509 Some(o) => &pre[o + "<think>".len()..],
510 None => pre,
511 };
512 let last = content.rfind("</think>").unwrap();
513 (
514 pre.trim_start_matches('\n').to_string(),
515 content[last + "</think>".len()..].trim_start_matches('\n'),
516 )
517 }
518 None => (String::new(), content.as_str()),
519 };
520 out.push_str("<|im_start|>assistant\n");
521 if i > last_query_index {
522 out.push_str("<think>\n");
523 out.push_str(&reasoning);
524 out.push_str("\n</think>\n");
525 }
526 out.push_str(body);
527 for call in &turn.tool_calls {
529 out.push_str("<tool_call>\n<function=");
530 out.push_str(&call.name);
531 out.push_str(">\n");
532 for (key, value) in &call.params {
533 out.push_str("<parameter=");
534 out.push_str(key);
535 out.push_str(">\n");
536 out.push_str(value);
537 out.push_str("\n</parameter>\n");
538 }
539 out.push_str("</function>\n</tool_call>");
540 }
541 out.push_str("<|im_end|>\n");
542 }
543 "tool" => {
544 if i == 0 || turns[i - 1].role != "tool" {
546 out.push_str("<|im_start|>tool_response\n");
547 }
548 out.push_str("<tool_response>");
549 out.push_str(content);
550 out.push_str("</tool_response>");
551 if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
552 out.push_str("<|im_end|>\n");
553 }
554 }
555 other => {
556 out.push_str("<|im_start|>");
558 out.push_str(other);
559 out.push('\n');
560 out.push_str(content);
561 out.push_str("<|im_end|>\n");
562 }
563 }
564 }
565
566 if add_generation_prompt {
567 out.push_str("<|im_start|>assistant\n<think>\n");
568 }
569 out
570}
571
572fn apply_hy3_template(
588 messages: &[(&str, &str)],
589 add_generation_prompt: bool,
590 effort: &str,
591) -> String {
592 const BOS: &str = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>";
593 const USER: &str = "<\u{ff5c}hy_User:opensource\u{ff5c}>";
594 const ASSISTANT: &str = "<\u{ff5c}hy_Assistant:opensource\u{ff5c}>";
595 const EOS: &str = "<\u{ff5c}hy_eos:opensource\u{ff5c}>";
596 const REASONING: &str = "<\u{ff5c}reasoning_mode:opensource\u{ff5c}>";
597 const THINK_BEGIN: &str = "<think:opensource>";
598 const THINK_END: &str = "</think:opensource>";
599
600 debug_assert!(
601 matches!(effort, "no_think" | "low" | "high"),
602 "hy3 reasoning_effort must be no_think|low|high, got {effort:?}"
603 );
604 let mut out = String::from(BOS);
605 for (role, content) in messages.iter().filter(|(r, _)| *r == "system") {
606 let _ = role;
607 out.push_str(content);
608 }
609 out.push_str(REASONING);
610 out.push_str("reasoning_effort:");
611 out.push_str(effort);
612
613 let mut last_is_assistant = false;
614 let n = messages.len();
615 for (i, (role, content)) in messages.iter().enumerate() {
616 last_is_assistant = false;
617 match *role {
618 "user" => {
619 out.push_str(USER);
620 out.push_str(content);
621 }
622 "assistant" => {
623 out.push_str(ASSISTANT);
624 out.push_str(THINK_BEGIN);
625 out.push_str(THINK_END);
626 out.push_str(content);
627 if i + 1 < n {
628 out.push_str(EOS);
629 } last_is_assistant = true;
631 }
632 _ => {} }
634 }
635 if add_generation_prompt && !last_is_assistant {
636 out.push_str(ASSISTANT);
637 out.push_str(THINK_BEGIN);
638 if effort == "no_think" {
639 out.push_str(THINK_END); }
641 }
642 out
643}
644
645fn apply_gemma4_template(
657 messages: &[(&str, &str)],
658 add_generation_prompt: bool,
659 thinking: bool,
660) -> String {
661 let mut out = String::new();
662 let mut msgs = messages;
663 let leading_system = msgs.first().filter(|(r, _)| *r == "system");
665 if thinking || leading_system.is_some() {
666 out.push_str("<|turn>system\n");
667 if thinking {
668 out.push_str("<|think|>\n");
669 }
670 if let Some((_, content)) = leading_system {
671 out.push_str(content.trim());
672 msgs = &msgs[1..];
673 }
674 out.push_str("<turn|>\n");
675 }
676 for (role, content) in msgs {
677 let role = if *role == "assistant" { "model" } else { role };
678 out.push_str("<|turn>");
679 out.push_str(role);
680 out.push('\n');
681 out.push_str(content.trim());
682 out.push_str("<turn|>\n");
683 }
684 if add_generation_prompt {
685 out.push_str("<|turn>model\n");
686 if !thinking {
687 out.push_str("<|channel>thought\n<channel|>");
688 }
689 }
690 out
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 #[test]
698 fn plain_chatml() {
699 let s = apply_chat_template_str(None, &[("user", "Hello")], true);
700 assert_eq!(
701 s,
702 "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n"
703 );
704 }
705
706 const QWEN_TOOLS_TMPL: &str =
709 "... <tools> ... add_generation_prompt ... enable_thinking ... '<think>\\n' ...";
710
711 #[test]
715 fn tools_renderer_matches_legacy_when_plain() {
716 let batteries: &[&[(&str, &str)]] = &[
717 &[("user", "Hello")],
718 &[("system", "You are helpful."), ("user", "Hi")],
719 &[
720 ("system", "rules"),
721 ("user", "task"),
722 ("assistant", "work"),
723 ("user", "more"),
724 ],
725 &[("user", " padded "), ("assistant", "reply\nwith lines")],
726 ];
727 for tmpl in [None, Some(QWEN_TOOLS_TMPL)] {
728 for msgs in batteries {
729 let legacy = apply_chat_template_str(tmpl, msgs, true);
730 let turns: Vec<Turn> = msgs
731 .iter()
732 .map(|(r, c)| Turn {
733 role: r.to_string(),
734 content: c.to_string(),
735 tool_calls: Vec::new(),
736 })
737 .collect();
738 let ext =
739 apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
740 .unwrap();
741 assert_eq!(legacy, ext, "template={tmpl:?} msgs={msgs:?}");
742 }
743 }
744 }
745
746 #[test]
747 fn tools_header_and_tool_response_render_per_template_law() {
748 let tools =
749 vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
750 let turns = vec![
751 Turn {
752 role: "system".into(),
753 content: "Be terse.".into(),
754 tool_calls: Vec::new(),
755 },
756 Turn {
757 role: "user".into(),
758 content: "Weather in Paris?".into(),
759 tool_calls: Vec::new(),
760 },
761 Turn {
762 role: "assistant".into(),
763 content: "".into(),
764 tool_calls: vec![ToolCall {
765 name: "get_weather".into(),
766 params: vec![("city".into(), "Paris".into())],
767 }],
768 },
769 Turn {
770 role: "tool".into(),
771 content: "{\"temp_c\": 21}".into(),
772 tool_calls: Vec::new(),
773 },
774 ];
775 let s = apply_chat_template_tools(
776 Some(QWEN_TOOLS_TMPL),
777 &turns,
778 true,
779 &tools,
780 ThinkMode::Default,
781 None,
782 )
783 .unwrap();
784 let expected = concat!(
785 "<|im_start|>system\n# Tools\n\nYou have access to the following functions:\n\n",
786 "<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n</tools>",
787 "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
788 "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
789 "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
790 "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
791 "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
792 "<function=...></function> block must be nested within <tool_call></tool_call> XML tags\n",
793 "- Required parameters MUST be specified\n- You may provide optional reasoning for your ",
794 "function call in natural language BEFORE the function call, but NOT after\n- If there is ",
795 "no function call available, answer the question like normal with your current knowledge ",
796 "and do not tell the user about function calls\n</IMPORTANT>",
797 "\n\nBe terse.<|im_end|>\n",
798 "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
799 "<|im_start|>assistant\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n",
800 "</parameter>\n</function>\n</tool_call><|im_end|>\n",
801 "<|im_start|>user\n<tool_response>\n{\"temp_c\": 21}\n</tool_response><|im_end|>\n",
802 "<|im_start|>assistant\n<think>\n",
803 );
804 assert_eq!(s, expected);
805 }
806
807 #[test]
808 fn assistant_content_plus_calls_and_consecutive_tool_turns_group() {
809 let turns = vec![
810 Turn {
811 role: "user".into(),
812 content: "both".into(),
813 tool_calls: Vec::new(),
814 },
815 Turn {
816 role: "assistant".into(),
817 content: "checking".into(),
818 tool_calls: vec![
819 ToolCall {
820 name: "a".into(),
821 params: vec![("x".into(), "1".into())],
822 },
823 ToolCall {
824 name: "b".into(),
825 params: Vec::new(),
826 },
827 ],
828 },
829 Turn {
830 role: "tool".into(),
831 content: "r1".into(),
832 tool_calls: Vec::new(),
833 },
834 Turn {
835 role: "tool".into(),
836 content: "r2".into(),
837 tool_calls: Vec::new(),
838 },
839 ];
840 let s = apply_chat_template_tools(
841 Some(QWEN_TOOLS_TMPL),
842 &turns,
843 false,
844 &[],
845 ThinkMode::Default,
846 None,
847 )
848 .unwrap();
849 assert_eq!(
850 s,
851 concat!(
852 "<|im_start|>user\nboth<|im_end|>\n",
853 "<|im_start|>assistant\nchecking\n\n",
854 "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>\n",
855 "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
856 "<|im_start|>user\n<tool_response>\nr1\n</tool_response>",
857 "\n<tool_response>\nr2\n</tool_response><|im_end|>\n",
858 )
859 );
860 }
861
862 #[test]
863 fn nothink_maps_to_enable_thinking_false_tail_and_degrades_gracefully() {
864 let turns = vec![Turn {
865 role: "user".into(),
866 content: "hi".into(),
867 tool_calls: Vec::new(),
868 }];
869 let s = apply_chat_template_tools(
871 Some(QWEN_TOOLS_TMPL),
872 &turns,
873 true,
874 &[],
875 ThinkMode::NoThink,
876 None,
877 )
878 .unwrap();
879 assert!(
880 s.ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"),
881 "{s:?}"
882 );
883 let tmpl_no_switch = "... add_generation_prompt ... '<think>\\n' ...";
885 let s = apply_chat_template_tools(
886 Some(tmpl_no_switch),
887 &turns,
888 true,
889 &[],
890 ThinkMode::NoThink,
891 None,
892 )
893 .unwrap();
894 assert!(s.ends_with("<|im_start|>assistant\n<think>\n"), "{s:?}");
895 let s =
897 apply_chat_template_tools(None, &turns, true, &[], ThinkMode::NoThink, None).unwrap();
898 assert!(s.ends_with("<|im_start|>assistant\n"), "{s:?}");
899 }
900
901 #[test]
902 fn tools_on_templates_without_tools_branch_error() {
903 let turns = vec![Turn {
904 role: "user".into(),
905 content: "hi".into(),
906 tool_calls: Vec::new(),
907 }];
908 let tools = vec!["{}".to_string()];
909 for tmpl in [None, Some("... hy_User ..."), Some("... <|turn> ...")] {
910 let err =
911 apply_chat_template_tools(tmpl, &turns, true, &tools, ThinkMode::Default, None);
912 assert!(err.is_err(), "template={tmpl:?}");
913 }
914 let tool_turns = vec![Turn {
916 role: "tool".into(),
917 content: "r".into(),
918 tool_calls: Vec::new(),
919 }];
920 assert!(
921 apply_chat_template_tools(None, &tool_turns, true, &[], ThinkMode::Default, None)
922 .is_err()
923 );
924 }
925
926 fn one_user() -> Vec<Turn> {
933 vec![turn("user", "Hi")]
934 }
935
936 #[test]
937 fn gemma4_thinking_maps_to_the_think_token_and_open_turn() {
938 let g = |think: ThinkMode| {
939 apply_chat_template_tools(Some("... <|turn> ..."), &one_user(), true, &[], think, None)
940 .unwrap()
941 };
942 let closed = "<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>";
945 assert_eq!(g(ThinkMode::Default), closed);
946 assert_eq!(g(ThinkMode::NoThink), closed);
947 assert_eq!(
948 apply_chat_template_str(Some("... <|turn> ..."), &[("user", "Hi")], true),
949 closed,
950 "legacy renderer = the default arm"
951 );
952 assert_eq!(
955 g(ThinkMode::Think),
956 "<|turn>system\n<|think|>\n<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n"
957 );
958 let turns = vec![turn("system", "Be terse."), turn("user", "Hi")];
960 let s = apply_chat_template_tools(
961 Some("... <|turn> ..."),
962 &turns,
963 true,
964 &[],
965 ThinkMode::Think,
966 None,
967 )
968 .unwrap();
969 assert_eq!(
970 s,
971 "<|turn>system\n<|think|>\nBe terse.<turn|>\n\
972 <|turn>user\nHi<turn|>\n<|turn>model\n"
973 );
974 }
975
976 #[test]
977 fn hy3_thinking_maps_to_its_reasoning_effort_levels() {
978 const HY_TMPL: Option<&str> = Some("... hy_User ...");
979 let h = |think: ThinkMode, effort: Option<&str>| {
980 apply_chat_template_tools(HY_TMPL, &one_user(), true, &[], think, effort).unwrap()
981 };
982 let closed = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
985 <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:no_think\
986 <\u{ff5c}hy_User:opensource\u{ff5c}>Hi\
987 <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
988 <think:opensource></think:opensource>";
989 assert_eq!(h(ThinkMode::Default, None), closed);
990 assert_eq!(
991 h(ThinkMode::NoThink, Some("low")),
992 closed,
993 "NoThink wins over a level: thinking off IS no_think"
994 );
995 assert_eq!(
996 apply_chat_template_str(HY_TMPL, &[("user", "Hi")], true),
997 closed,
998 "legacy renderer = the default arm"
999 );
1000 let low = h(ThinkMode::Think, Some("low"));
1003 assert!(low.contains("reasoning_effort:low"), "{low:?}");
1004 assert!(low.ends_with("<think:opensource>"), "{low:?}");
1005 let high = h(ThinkMode::Think, Some("high"));
1006 assert!(high.contains("reasoning_effort:high"), "{high:?}");
1007 assert!(high.ends_with("<think:opensource>"), "{high:?}");
1008 assert_eq!(h(ThinkMode::Think, Some("medium")), low);
1011 assert_eq!(h(ThinkMode::Think, None), low);
1012 let turns = vec![
1015 turn("user", "q"),
1016 turn("assistant", "a"),
1017 turn("user", "more"),
1018 ];
1019 let s =
1020 apply_chat_template_tools(HY_TMPL, &turns, true, &[], ThinkMode::Think, Some("low"))
1021 .unwrap();
1022 assert_eq!(
1023 s,
1024 "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
1025 <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:low\
1026 <\u{ff5c}hy_User:opensource\u{ff5c}>q\
1027 <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
1028 <think:opensource></think:opensource>a\
1029 <\u{ff5c}hy_eos:opensource\u{ff5c}>\
1030 <\u{ff5c}hy_User:opensource\u{ff5c}>more\
1031 <\u{ff5c}hy_Assistant:opensource\u{ff5c}><think:opensource>"
1032 );
1033 }
1034
1035 #[test]
1036 fn qwen_think_mode_covers_all_three_directions() {
1037 let q = |think: ThinkMode| {
1038 apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &one_user(), true, &[], think, None)
1039 .unwrap()
1040 };
1041 assert!(q(ThinkMode::Default).ends_with("<|im_start|>assistant\n<think>\n"));
1043 assert_eq!(q(ThinkMode::Think), q(ThinkMode::Default));
1044 assert!(q(ThinkMode::NoThink).ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
1045 }
1046
1047 const STEP35_TMPL: &str = "{% macro render_message_content(message) %}... <tools> ... add_generation_prompt ... '<think>\\n' ...";
1059
1060 fn s35(msgs: &[(&str, &str)], genp: bool) -> String {
1061 apply_chat_template_str(Some(STEP35_TMPL), msgs, genp)
1062 }
1063
1064 fn s35_turns(turns: Vec<Turn>, genp: bool, tools: &[String]) -> String {
1065 apply_chat_template_tools(
1066 Some(STEP35_TMPL),
1067 &turns,
1068 genp,
1069 tools,
1070 ThinkMode::Default,
1071 None,
1072 )
1073 .unwrap()
1074 }
1075
1076 fn turn(role: &str, content: &str) -> Turn {
1077 Turn {
1078 role: role.into(),
1079 content: content.into(),
1080 tool_calls: Vec::new(),
1081 }
1082 }
1083
1084 #[test]
1085 fn step35_plain_paths_match_the_shipped_jinja() {
1086 assert_eq!(
1087 s35(&[("user", "Hello")], true),
1088 "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think>\n"
1089 );
1090 assert_eq!(
1091 s35(&[("user", "Hello")], false),
1092 "<|im_start|>user\nHello<|im_end|>\n"
1093 );
1094 assert_eq!(
1095 s35(&[("system", "You are helpful."), ("user", "Hi")], true),
1096 "<|im_start|>system\nYou are helpful.<|im_end|>\n\
1097 <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
1098 );
1099 assert_eq!(
1102 s35(
1103 &[
1104 ("system", "rules"),
1105 ("user", "task"),
1106 ("assistant", "work"),
1107 ("user", "more")
1108 ],
1109 true
1110 ),
1111 "<|im_start|>system\nrules<|im_end|>\n<|im_start|>user\ntask<|im_end|>\n\
1112 <|im_start|>assistant\nwork<|im_end|>\n<|im_start|>user\nmore<|im_end|>\n\
1113 <|im_start|>assistant\n<think>\n"
1114 );
1115 assert_eq!(
1117 s35(&[("user", " padded ")], true),
1118 "<|im_start|>user\n padded <|im_end|>\n<|im_start|>assistant\n<think>\n"
1119 );
1120 }
1121
1122 #[test]
1123 fn step35_dispatch_beats_the_qwen_marker_arm() {
1124 let qwen = apply_chat_template_str(Some(QWEN_TOOLS_TMPL), &[("user", " pad ")], true);
1128 let step = s35(&[("user", " pad ")], true);
1129 assert_eq!(
1130 qwen,
1131 "<|im_start|>user\npad<|im_end|>\n<|im_start|>assistant\n<think>\n"
1132 );
1133 assert_eq!(
1134 step,
1135 "<|im_start|>user\n pad <|im_end|>\n<|im_start|>assistant\n<think>\n"
1136 );
1137 assert_ne!(qwen, step);
1138 }
1139
1140 #[test]
1141 fn step35_reasoning_effort_renders_in_the_system_turn() {
1142 assert_eq!(
1143 apply_step35_template(&[turn("user", "Hi")], true, &[], Some("high")),
1144 "<|im_start|>system\nReasoning: high\n\n<|im_end|>\n\
1145 <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
1146 );
1147 assert_eq!(
1148 apply_step35_template(
1149 &[turn("system", "Be terse."), turn("user", "Hi")],
1150 true,
1151 &[],
1152 Some("low")
1153 ),
1154 "<|im_start|>system\nReasoning: low\n\nBe terse.<|im_end|>\n\
1155 <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
1156 );
1157 let tools = vec![r#"{"type": "function", "function": {"name": "f"}}"#.to_string()];
1159 let s = apply_step35_template(
1160 &[turn("system", "Be terse."), turn("user", "q")],
1161 true,
1162 &tools,
1163 Some("medium"),
1164 );
1165 assert!(
1166 s.starts_with("<|im_start|>system\nReasoning: medium\n\nBe terse.\n\n# Tools\n"),
1167 "{s:?}"
1168 );
1169 }
1170
1171 #[test]
1172 fn reasoning_effort_reaches_step35_through_the_public_entry_and_only_step35() {
1173 let turns = vec![turn("user", "Hi")];
1176 let s = apply_chat_template_tools(
1177 Some(STEP35_TMPL),
1178 &turns,
1179 true,
1180 &[],
1181 ThinkMode::Default,
1182 Some("high"),
1183 )
1184 .unwrap();
1185 assert!(
1186 s.starts_with("<|im_start|>system\nReasoning: high\n\n<|im_end|>\n"),
1187 "{s:?}"
1188 );
1189 let s = apply_chat_template_tools(
1191 Some(STEP35_TMPL),
1192 &turns,
1193 true,
1194 &[],
1195 ThinkMode::Default,
1196 None,
1197 )
1198 .unwrap();
1199 assert!(!s.contains("Reasoning:"), "{s:?}");
1200 for tmpl in [
1203 None,
1204 Some(QWEN_TOOLS_TMPL),
1205 Some("... hy_User ..."),
1206 Some("... <|turn> ..."),
1207 ] {
1208 let with = apply_chat_template_tools(
1209 tmpl,
1210 &turns,
1211 true,
1212 &[],
1213 ThinkMode::Default,
1214 Some("high"),
1215 )
1216 .unwrap();
1217 let without =
1218 apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
1219 .unwrap();
1220 assert_eq!(with, without, "template={tmpl:?}");
1221 }
1222 }
1223
1224 #[test]
1225 fn step35_tools_header_is_not_the_qwen_header() {
1226 let tools = vec![
1227 r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string(),
1228 r#"{"type": "function", "function": {"name": "search"}}"#.to_string(),
1229 ];
1230 let s = s35_turns(
1231 vec![
1232 turn("system", "Be terse."),
1233 turn("user", "Weather in Paris?"),
1234 ],
1235 true,
1236 &tools,
1237 );
1238 assert_eq!(
1239 s,
1240 concat!(
1241 "<|im_start|>system\nBe terse.\n\n# Tools\n\n",
1244 "You have access to the following functions in JSONSchema format:\n\n<tools>\n",
1245 "{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n",
1246 "{\"type\": \"function\", \"function\": {\"name\": \"search\"}}\n</tools>",
1247 "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
1248 "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
1249 "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
1250 "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
1251 "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
1254 "<function=...>\n...\n</function> block must be nested within <tool_call>\n...\n",
1255 "</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>",
1256 "<|im_end|>\n",
1257 "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
1258 "<|im_start|>assistant\n<think>\n",
1259 )
1260 );
1261 assert!(!s.contains(QWEN_TOOLS_INSTRUCTION));
1263 }
1264
1265 #[test]
1266 fn step35_tool_results_take_their_own_role_and_group() {
1267 let tools =
1268 vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
1269 let turns = vec![
1270 turn("user", "both"),
1271 Turn {
1272 role: "assistant".into(),
1273 content: "checking".into(),
1274 tool_calls: vec![
1275 ToolCall {
1276 name: "a".into(),
1277 params: vec![("x".into(), "1".into())],
1278 },
1279 ToolCall {
1280 name: "b".into(),
1281 params: Vec::new(),
1282 },
1283 ],
1284 },
1285 turn("tool", "r1"),
1286 turn("tool", "r2"),
1287 ];
1288 let s = s35_turns(turns, true, &tools);
1289 let body = s
1290 .split("<|im_end|>\n")
1291 .skip(1)
1292 .collect::<Vec<_>>()
1293 .join("<|im_end|>\n");
1294 assert_eq!(
1295 body,
1296 concat!(
1297 "<|im_start|>user\nboth<|im_end|>\n",
1298 "<|im_start|>assistant\n<think>\n\n</think>\nchecking",
1301 "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>",
1303 "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
1304 "<|im_start|>tool_response\n<tool_response>r1</tool_response>",
1306 "<tool_response>r2</tool_response><|im_end|>\n",
1307 "<|im_start|>assistant\n<think>\n",
1308 )
1309 );
1310 }
1311
1312 #[test]
1313 fn step35_assistant_think_split_and_the_reasoning_boundary() {
1314 assert_eq!(
1316 s35(
1317 &[
1318 ("user", "q"),
1319 ("assistant", "<think>\nreasoned\n</think>\nanswer")
1320 ],
1321 false
1322 ),
1323 "<|im_start|>user\nq<|im_end|>\n\
1324 <|im_start|>assistant\n<think>\nreasoned\n</think>\nanswer<|im_end|>\n"
1325 );
1326 assert_eq!(
1328 s35(&[("user", "q"), ("assistant", "plain")], false),
1329 "<|im_start|>user\nq<|im_end|>\n\
1330 <|im_start|>assistant\n<think>\n\n</think>\nplain<|im_end|>\n"
1331 );
1332 assert_eq!(
1335 s35(
1336 &[
1337 ("user", "real question"),
1338 ("assistant", "thinking about it"),
1339 ("user", "<tool_response>r</tool_response>")
1340 ],
1341 true
1342 ),
1343 "<|im_start|>user\nreal question<|im_end|>\n\
1344 <|im_start|>assistant\n<think>\n\n</think>\nthinking about it<|im_end|>\n\
1345 <|im_start|>user\n<tool_response>r</tool_response><|im_end|>\n\
1346 <|im_start|>assistant\n<think>\n"
1347 );
1348 }
1349
1350 #[test]
1351 fn step35_think_tail_is_unconditional_and_nothink_is_a_noop() {
1352 let turns = vec![turn("user", "hi")];
1356 for mode in [ThinkMode::Default, ThinkMode::NoThink] {
1357 let s = apply_chat_template_tools(Some(STEP35_TMPL), &turns, true, &[], mode, None)
1358 .unwrap();
1359 assert!(
1360 s.ends_with("<|im_start|>assistant\n<think>\n"),
1361 "mode={mode:?} {s:?}"
1362 );
1363 }
1364 }
1365
1366 #[test]
1367 fn step35_plain_path_is_identical_through_both_renderers() {
1368 let batteries: &[&[(&str, &str)]] = &[
1371 &[("user", "Hello")],
1372 &[("system", "You are helpful."), ("user", "Hi")],
1373 &[
1374 ("system", "rules"),
1375 ("user", "task"),
1376 ("assistant", "work"),
1377 ("user", "more"),
1378 ],
1379 &[("user", " padded "), ("assistant", "reply\nwith lines")],
1380 ];
1381 for msgs in batteries {
1382 let legacy = s35(msgs, true);
1383 let ext = s35_turns(msgs.iter().map(|(r, c)| turn(r, c)).collect(), true, &[]);
1384 assert_eq!(legacy, ext, "msgs={msgs:?}");
1385 }
1386 }
1387
1388 #[test]
1389 fn qwen_think_tail() {
1390 let tmpl = "... add_generation_prompt ... '<think>\\n' ...";
1392 let s = apply_chat_template_str(
1393 Some(tmpl),
1394 &[("system", "You are helpful."), ("user", "Hi")],
1395 true,
1396 );
1397 assert_eq!(
1398 s,
1399 "<|im_start|>system\nYou are helpful.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
1400 );
1401 }
1402}