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.iter().map(|(r, c)| Turn {
86 role: r.to_string(), content: c.to_string(), tool_calls: Vec::new(),
87 }).collect();
88 return apply_step35_template(&turns, add_generation_prompt, &[], None);
89 }
90 if template.is_some_and(|t| t.contains("<|turn>")) {
96 return apply_gemma4_template(messages, add_generation_prompt, false);
97 }
98 let qwen_think = template
100 .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
101 .unwrap_or(false);
102
103 let mut out = String::new();
104 for (i, (role, content)) in messages.iter().enumerate() {
105 let content = content.trim();
106 match *role {
107 "system" => {
108 let _ = i;
111 out.push_str("<|im_start|>system\n");
112 out.push_str(content);
113 out.push_str("<|im_end|>\n");
114 }
115 "user" => {
116 out.push_str("<|im_start|>user\n");
117 out.push_str(content);
118 out.push_str("<|im_end|>\n");
119 }
120 "assistant" => {
121 out.push_str("<|im_start|>assistant\n");
122 out.push_str(content);
123 out.push_str("<|im_end|>\n");
124 }
125 other => {
126 out.push_str("<|im_start|>");
128 out.push_str(other);
129 out.push('\n');
130 out.push_str(content);
131 out.push_str("<|im_end|>\n");
132 }
133 }
134 }
135
136 if add_generation_prompt {
137 out.push_str("<|im_start|>assistant\n");
138 if qwen_think {
139 out.push_str("<think>\n");
140 }
141 }
142
143 out
144}
145
146const QWEN_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
150following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
151<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
152This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
153</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
154format: an inner <function=...></function> block must be nested within <tool_call></tool_call> \
155XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for \
156your function call in natural language BEFORE the function call, but NOT after\n- If there is \
157no function call available, answer the question like normal with your current knowledge and do \
158not tell the user about function calls\n</IMPORTANT>";
159
160pub fn apply_chat_template_tools(
190 template: Option<&str>,
191 turns: &[Turn],
192 add_generation_prompt: bool,
193 tools_json: &[String],
194 think: ThinkMode,
195 reasoning_effort: Option<&str>,
196) -> Result<String, String> {
197 let has_tool_features = !tools_json.is_empty()
198 || turns.iter().any(|t| t.role == "tool" || !t.tool_calls.is_empty());
199 let tools_branch = template.is_some_and(|t| t.contains("<tools>"));
200 if has_tool_features && !tools_branch {
201 return Err("model chat template has no tools branch".into());
202 }
203 if template.is_some_and(|t| t.contains("render_message_content")) {
210 return Ok(apply_step35_template(turns, add_generation_prompt, tools_json,
211 reasoning_effort));
212 }
213 if template.is_some_and(|t| t.contains("hy_User") || t.contains("<|turn>")) {
214 if has_tool_features {
223 return Err("tools are not supported on this model's chat-template dialect".into());
224 }
225 let messages: Vec<(&str, &str)> =
226 turns.iter().map(|t| (t.role.as_str(), t.content.as_str())).collect();
227 if template.is_some_and(|t| t.contains("hy_User")) {
228 let effort = match (think, reasoning_effort) {
231 (ThinkMode::Think, Some("high")) => "high",
232 (ThinkMode::Think, _) => "low",
233 _ => "no_think",
234 };
235 return Ok(apply_hy3_template(&messages, add_generation_prompt, effort));
236 }
237 return Ok(apply_gemma4_template(&messages, add_generation_prompt,
238 think == ThinkMode::Think));
239 }
240 let qwen_think = template
241 .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
242 .unwrap_or(false);
243 let think_switch = template.is_some_and(|t| t.contains("enable_thinking"));
244
245 let mut out = String::new();
246 let mut skip_leading_system = false;
249 if !tools_json.is_empty() {
250 out.push_str("<|im_start|>system\n");
251 out.push_str("# Tools\n\nYou have access to the following functions:\n\n<tools>");
252 for tool in tools_json {
253 out.push('\n');
254 out.push_str(tool);
255 }
256 out.push_str("\n</tools>");
257 out.push_str(QWEN_TOOLS_INSTRUCTION);
258 if let Some(first) = turns.first() {
259 if first.role == "system" {
260 skip_leading_system = true;
261 let content = first.content.trim();
262 if !content.is_empty() {
263 out.push_str("\n\n");
264 out.push_str(content);
265 }
266 }
267 }
268 out.push_str("<|im_end|>\n");
269 }
270
271 for (i, turn) in turns.iter().enumerate() {
272 if i == 0 && skip_leading_system {
273 continue;
274 }
275 let content = turn.content.trim();
276 match turn.role.as_str() {
277 "system" => {
278 out.push_str("<|im_start|>system\n");
279 out.push_str(content);
280 out.push_str("<|im_end|>\n");
281 }
282 "user" => {
283 out.push_str("<|im_start|>user\n");
284 out.push_str(content);
285 out.push_str("<|im_end|>\n");
286 }
287 "assistant" => {
288 out.push_str("<|im_start|>assistant\n");
289 out.push_str(content);
290 for (k, call) in turn.tool_calls.iter().enumerate() {
291 if k == 0 {
292 if !content.is_empty() {
293 out.push_str("\n\n");
294 }
295 } else {
296 out.push('\n');
297 }
298 out.push_str("<tool_call>\n<function=");
299 out.push_str(&call.name);
300 out.push_str(">\n");
301 for (key, value) in &call.params {
302 out.push_str("<parameter=");
303 out.push_str(key);
304 out.push_str(">\n");
305 out.push_str(value);
306 out.push_str("\n</parameter>\n");
307 }
308 out.push_str("</function>\n</tool_call>");
309 }
310 out.push_str("<|im_end|>\n");
311 }
312 "tool" => {
313 if i == 0 || turns[i - 1].role != "tool" {
314 out.push_str("<|im_start|>user");
315 }
316 out.push_str("\n<tool_response>\n");
317 out.push_str(content);
318 out.push_str("\n</tool_response>");
319 if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
320 out.push_str("<|im_end|>\n");
321 }
322 }
323 other => {
324 out.push_str("<|im_start|>");
326 out.push_str(other);
327 out.push('\n');
328 out.push_str(content);
329 out.push_str("<|im_end|>\n");
330 }
331 }
332 }
333
334 if add_generation_prompt {
335 out.push_str("<|im_start|>assistant\n");
336 if qwen_think {
337 if think == ThinkMode::NoThink && think_switch {
338 out.push_str("<think>\n\n</think>\n\n");
339 } else {
340 out.push_str("<think>\n");
341 }
342 }
343 }
344 Ok(out)
345}
346
347const STEP35_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
356following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
357<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
358This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
359</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
360format: an inner <function=...>\n...\n</function> block must be nested within <tool_call>\n\
361...\n</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>";
362
363fn apply_step35_template(turns: &[Turn], add_generation_prompt: bool, tools_json: &[String],
408 reasoning_effort: Option<&str>) -> String {
409 let mut out = String::new();
410 let leading_system = turns.first().filter(|t| t.role == "system");
411
412 if !tools_json.is_empty() {
414 out.push_str("<|im_start|>system\n");
415 if let Some(effort) = reasoning_effort {
416 out.push_str("Reasoning: ");
417 out.push_str(effort);
418 out.push_str("\n\n");
419 }
420 if let Some(sys) = leading_system {
421 out.push_str(&sys.content);
423 out.push_str("\n\n");
424 }
425 out.push_str("# Tools\n\nYou have access to the following functions in JSONSchema \
426 format:\n\n<tools>");
427 for tool in tools_json {
428 out.push('\n');
429 out.push_str(tool);
430 }
431 out.push_str("\n</tools>");
432 out.push_str(STEP35_TOOLS_INSTRUCTION);
433 out.push_str("<|im_end|>\n");
434 } else if let Some(sys) = leading_system {
435 out.push_str("<|im_start|>system\n");
436 if let Some(effort) = reasoning_effort {
437 out.push_str("Reasoning: ");
438 out.push_str(effort);
439 out.push_str("\n\n");
440 }
441 out.push_str(&sys.content);
442 out.push_str("<|im_end|>\n");
443 } else if let Some(effort) = reasoning_effort {
444 out.push_str("<|im_start|>system\nReasoning: ");
445 out.push_str(effort);
446 out.push_str("\n\n<|im_end|>\n");
447 }
448
449 let last_query_index = turns.iter().enumerate().rev()
454 .find(|(_, t)| t.role == "user"
455 && !(t.content.starts_with("<tool_response>")
456 && t.content.ends_with("</tool_response>")))
457 .map(|(i, _)| i)
458 .unwrap_or(turns.len().saturating_sub(1));
459
460 for (i, turn) in turns.iter().enumerate() {
461 let content = &turn.content; match turn.role.as_str() {
463 "system" if i == 0 => {}
465 "system" | "user" => {
466 out.push_str("<|im_start|>");
467 out.push_str(&turn.role);
468 out.push('\n');
469 out.push_str(content);
470 out.push_str("<|im_end|>\n");
471 }
472 "assistant" => {
473 let (reasoning, body): (String, &str) = match content.find("</think>") {
479 Some(first) => {
480 let pre = content[..first].trim_end_matches('\n');
481 let pre = match pre.rfind("<think>") {
482 Some(o) => &pre[o + "<think>".len()..],
483 None => pre,
484 };
485 let last = content.rfind("</think>").unwrap();
486 (pre.trim_start_matches('\n').to_string(),
487 content[last + "</think>".len()..].trim_start_matches('\n'))
488 }
489 None => (String::new(), content.as_str()),
490 };
491 out.push_str("<|im_start|>assistant\n");
492 if i > last_query_index {
493 out.push_str("<think>\n");
494 out.push_str(&reasoning);
495 out.push_str("\n</think>\n");
496 }
497 out.push_str(body);
498 for call in &turn.tool_calls {
500 out.push_str("<tool_call>\n<function=");
501 out.push_str(&call.name);
502 out.push_str(">\n");
503 for (key, value) in &call.params {
504 out.push_str("<parameter=");
505 out.push_str(key);
506 out.push_str(">\n");
507 out.push_str(value);
508 out.push_str("\n</parameter>\n");
509 }
510 out.push_str("</function>\n</tool_call>");
511 }
512 out.push_str("<|im_end|>\n");
513 }
514 "tool" => {
515 if i == 0 || turns[i - 1].role != "tool" {
517 out.push_str("<|im_start|>tool_response\n");
518 }
519 out.push_str("<tool_response>");
520 out.push_str(content);
521 out.push_str("</tool_response>");
522 if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
523 out.push_str("<|im_end|>\n");
524 }
525 }
526 other => {
527 out.push_str("<|im_start|>");
529 out.push_str(other);
530 out.push('\n');
531 out.push_str(content);
532 out.push_str("<|im_end|>\n");
533 }
534 }
535 }
536
537 if add_generation_prompt {
538 out.push_str("<|im_start|>assistant\n<think>\n");
539 }
540 out
541}
542
543fn apply_hy3_template(messages: &[(&str, &str)], add_generation_prompt: bool,
559 effort: &str) -> String {
560 const BOS: &str = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>";
561 const USER: &str = "<\u{ff5c}hy_User:opensource\u{ff5c}>";
562 const ASSISTANT: &str = "<\u{ff5c}hy_Assistant:opensource\u{ff5c}>";
563 const EOS: &str = "<\u{ff5c}hy_eos:opensource\u{ff5c}>";
564 const REASONING: &str = "<\u{ff5c}reasoning_mode:opensource\u{ff5c}>";
565 const THINK_BEGIN: &str = "<think:opensource>";
566 const THINK_END: &str = "</think:opensource>";
567
568 debug_assert!(matches!(effort, "no_think" | "low" | "high"),
569 "hy3 reasoning_effort must be no_think|low|high, got {effort:?}");
570 let mut out = String::from(BOS);
571 for (role, content) in messages.iter().filter(|(r, _)| *r == "system") {
572 let _ = role;
573 out.push_str(content);
574 }
575 out.push_str(REASONING);
576 out.push_str("reasoning_effort:");
577 out.push_str(effort);
578
579 let mut last_is_assistant = false;
580 let n = messages.len();
581 for (i, (role, content)) in messages.iter().enumerate() {
582 last_is_assistant = false;
583 match *role {
584 "user" => { out.push_str(USER); out.push_str(content); }
585 "assistant" => {
586 out.push_str(ASSISTANT);
587 out.push_str(THINK_BEGIN);
588 out.push_str(THINK_END);
589 out.push_str(content);
590 if i + 1 < n { out.push_str(EOS); } last_is_assistant = true;
592 }
593 _ => {} }
595 }
596 if add_generation_prompt && !last_is_assistant {
597 out.push_str(ASSISTANT);
598 out.push_str(THINK_BEGIN);
599 if effort == "no_think" {
600 out.push_str(THINK_END); }
602 }
603 out
604}
605
606
607fn apply_gemma4_template(messages: &[(&str, &str)], add_generation_prompt: bool,
619 thinking: bool) -> String {
620 let mut out = String::new();
621 let mut msgs = messages;
622 let leading_system = msgs.first().filter(|(r, _)| *r == "system");
624 if thinking || leading_system.is_some() {
625 out.push_str("<|turn>system\n");
626 if thinking {
627 out.push_str("<|think|>\n");
628 }
629 if let Some((_, content)) = leading_system {
630 out.push_str(content.trim());
631 msgs = &msgs[1..];
632 }
633 out.push_str("<turn|>\n");
634 }
635 for (role, content) in msgs {
636 let role = if *role == "assistant" { "model" } else { role };
637 out.push_str("<|turn>");
638 out.push_str(role);
639 out.push('\n');
640 out.push_str(content.trim());
641 out.push_str("<turn|>\n");
642 }
643 if add_generation_prompt {
644 out.push_str("<|turn>model\n");
645 if !thinking {
646 out.push_str("<|channel>thought\n<channel|>");
647 }
648 }
649 out
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655
656 #[test]
657 fn plain_chatml() {
658 let s = apply_chat_template_str(
659 None,
660 &[("user", "Hello")],
661 true,
662 );
663 assert_eq!(s, "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n");
664 }
665
666 const QWEN_TOOLS_TMPL: &str =
669 "... <tools> ... add_generation_prompt ... enable_thinking ... '<think>\\n' ...";
670
671 #[test]
675 fn tools_renderer_matches_legacy_when_plain() {
676 let batteries: &[&[(&str, &str)]] = &[
677 &[("user", "Hello")],
678 &[("system", "You are helpful."), ("user", "Hi")],
679 &[("system", "rules"), ("user", "task"), ("assistant", "work"), ("user", "more")],
680 &[("user", " padded "), ("assistant", "reply\nwith lines")],
681 ];
682 for tmpl in [None, Some(QWEN_TOOLS_TMPL)] {
683 for msgs in batteries {
684 let legacy = apply_chat_template_str(tmpl, msgs, true);
685 let turns: Vec<Turn> = msgs.iter().map(|(r, c)| Turn {
686 role: r.to_string(), content: c.to_string(), tool_calls: Vec::new(),
687 }).collect();
688 let ext = apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
689 .unwrap();
690 assert_eq!(legacy, ext, "template={tmpl:?} msgs={msgs:?}");
691 }
692 }
693 }
694
695 #[test]
696 fn tools_header_and_tool_response_render_per_template_law() {
697 let tools = vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
698 let turns = vec![
699 Turn { role: "system".into(), content: "Be terse.".into(), tool_calls: Vec::new() },
700 Turn { role: "user".into(), content: "Weather in Paris?".into(), tool_calls: Vec::new() },
701 Turn { role: "assistant".into(), content: "".into(), tool_calls: vec![ToolCall {
702 name: "get_weather".into(),
703 params: vec![("city".into(), "Paris".into())],
704 }] },
705 Turn { role: "tool".into(), content: "{\"temp_c\": 21}".into(), tool_calls: Vec::new() },
706 ];
707 let s = apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &turns, true, &tools,
708 ThinkMode::Default, None).unwrap();
709 let expected = concat!(
710 "<|im_start|>system\n# Tools\n\nYou have access to the following functions:\n\n",
711 "<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n</tools>",
712 "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
713 "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
714 "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
715 "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
716 "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
717 "<function=...></function> block must be nested within <tool_call></tool_call> XML tags\n",
718 "- Required parameters MUST be specified\n- You may provide optional reasoning for your ",
719 "function call in natural language BEFORE the function call, but NOT after\n- If there is ",
720 "no function call available, answer the question like normal with your current knowledge ",
721 "and do not tell the user about function calls\n</IMPORTANT>",
722 "\n\nBe terse.<|im_end|>\n",
723 "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
724 "<|im_start|>assistant\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n",
725 "</parameter>\n</function>\n</tool_call><|im_end|>\n",
726 "<|im_start|>user\n<tool_response>\n{\"temp_c\": 21}\n</tool_response><|im_end|>\n",
727 "<|im_start|>assistant\n<think>\n",
728 );
729 assert_eq!(s, expected);
730 }
731
732 #[test]
733 fn assistant_content_plus_calls_and_consecutive_tool_turns_group() {
734 let turns = vec![
735 Turn { role: "user".into(), content: "both".into(), tool_calls: Vec::new() },
736 Turn { role: "assistant".into(), content: "checking".into(), tool_calls: vec![
737 ToolCall { name: "a".into(), params: vec![("x".into(), "1".into())] },
738 ToolCall { name: "b".into(), params: Vec::new() },
739 ] },
740 Turn { role: "tool".into(), content: "r1".into(), tool_calls: Vec::new() },
741 Turn { role: "tool".into(), content: "r2".into(), tool_calls: Vec::new() },
742 ];
743 let s = apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &turns, false, &[],
744 ThinkMode::Default, None).unwrap();
745 assert_eq!(s, concat!(
746 "<|im_start|>user\nboth<|im_end|>\n",
747 "<|im_start|>assistant\nchecking\n\n",
748 "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>\n",
749 "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
750 "<|im_start|>user\n<tool_response>\nr1\n</tool_response>",
751 "\n<tool_response>\nr2\n</tool_response><|im_end|>\n",
752 ));
753 }
754
755 #[test]
756 fn nothink_maps_to_enable_thinking_false_tail_and_degrades_gracefully() {
757 let turns = vec![Turn { role: "user".into(), content: "hi".into(), tool_calls: Vec::new() }];
758 let s = apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &turns, true, &[],
760 ThinkMode::NoThink, None).unwrap();
761 assert!(s.ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"), "{s:?}");
762 let tmpl_no_switch = "... add_generation_prompt ... '<think>\\n' ...";
764 let s = apply_chat_template_tools(Some(tmpl_no_switch), &turns, true, &[],
765 ThinkMode::NoThink, None).unwrap();
766 assert!(s.ends_with("<|im_start|>assistant\n<think>\n"), "{s:?}");
767 let s = apply_chat_template_tools(None, &turns, true, &[], ThinkMode::NoThink, None).unwrap();
769 assert!(s.ends_with("<|im_start|>assistant\n"), "{s:?}");
770 }
771
772 #[test]
773 fn tools_on_templates_without_tools_branch_error() {
774 let turns = vec![Turn { role: "user".into(), content: "hi".into(), tool_calls: Vec::new() }];
775 let tools = vec!["{}".to_string()];
776 for tmpl in [None, Some("... hy_User ..."), Some("... <|turn> ...")] {
777 let err = apply_chat_template_tools(tmpl, &turns, true, &tools, ThinkMode::Default, None);
778 assert!(err.is_err(), "template={tmpl:?}");
779 }
780 let tool_turns = vec![Turn { role: "tool".into(), content: "r".into(), tool_calls: Vec::new() }];
782 assert!(apply_chat_template_tools(None, &tool_turns, true, &[], ThinkMode::Default, None).is_err());
783 }
784
785 fn one_user() -> Vec<Turn> {
792 vec![turn("user", "Hi")]
793 }
794
795 #[test]
796 fn gemma4_thinking_maps_to_the_think_token_and_open_turn() {
797 let g = |think: ThinkMode| {
798 apply_chat_template_tools(Some("... <|turn> ..."), &one_user(), true, &[], think,
799 None).unwrap()
800 };
801 let closed = "<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>";
804 assert_eq!(g(ThinkMode::Default), closed);
805 assert_eq!(g(ThinkMode::NoThink), closed);
806 assert_eq!(apply_chat_template_str(Some("... <|turn> ..."), &[("user", "Hi")], true),
807 closed, "legacy renderer = the default arm");
808 assert_eq!(g(ThinkMode::Think),
811 "<|turn>system\n<|think|>\n<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n");
812 let turns = vec![turn("system", "Be terse."), turn("user", "Hi")];
814 let s = apply_chat_template_tools(Some("... <|turn> ..."), &turns, true, &[],
815 ThinkMode::Think, None).unwrap();
816 assert_eq!(s, "<|turn>system\n<|think|>\nBe terse.<turn|>\n\
817 <|turn>user\nHi<turn|>\n<|turn>model\n");
818 }
819
820 #[test]
821 fn hy3_thinking_maps_to_its_reasoning_effort_levels() {
822 const HY_TMPL: Option<&str> = Some("... hy_User ...");
823 let h = |think: ThinkMode, effort: Option<&str>| {
824 apply_chat_template_tools(HY_TMPL, &one_user(), true, &[], think, effort).unwrap()
825 };
826 let closed = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
829 <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:no_think\
830 <\u{ff5c}hy_User:opensource\u{ff5c}>Hi\
831 <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
832 <think:opensource></think:opensource>";
833 assert_eq!(h(ThinkMode::Default, None), closed);
834 assert_eq!(h(ThinkMode::NoThink, Some("low")), closed,
835 "NoThink wins over a level: thinking off IS no_think");
836 assert_eq!(apply_chat_template_str(HY_TMPL, &[("user", "Hi")], true), closed,
837 "legacy renderer = the default arm");
838 let low = h(ThinkMode::Think, Some("low"));
841 assert!(low.contains("reasoning_effort:low"), "{low:?}");
842 assert!(low.ends_with("<think:opensource>"), "{low:?}");
843 let high = h(ThinkMode::Think, Some("high"));
844 assert!(high.contains("reasoning_effort:high"), "{high:?}");
845 assert!(high.ends_with("<think:opensource>"), "{high:?}");
846 assert_eq!(h(ThinkMode::Think, Some("medium")), low);
849 assert_eq!(h(ThinkMode::Think, None), low);
850 let turns = vec![turn("user", "q"), turn("assistant", "a"), turn("user", "more")];
853 let s = apply_chat_template_tools(HY_TMPL, &turns, true, &[], ThinkMode::Think,
854 Some("low")).unwrap();
855 assert_eq!(s, "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
856 <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:low\
857 <\u{ff5c}hy_User:opensource\u{ff5c}>q\
858 <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
859 <think:opensource></think:opensource>a\
860 <\u{ff5c}hy_eos:opensource\u{ff5c}>\
861 <\u{ff5c}hy_User:opensource\u{ff5c}>more\
862 <\u{ff5c}hy_Assistant:opensource\u{ff5c}><think:opensource>");
863 }
864
865 #[test]
866 fn qwen_think_mode_covers_all_three_directions() {
867 let q = |think: ThinkMode| {
868 apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &one_user(), true, &[], think,
869 None).unwrap()
870 };
871 assert!(q(ThinkMode::Default).ends_with("<|im_start|>assistant\n<think>\n"));
873 assert_eq!(q(ThinkMode::Think), q(ThinkMode::Default));
874 assert!(q(ThinkMode::NoThink)
875 .ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
876 }
877
878 const STEP35_TMPL: &str =
890 "{% macro render_message_content(message) %}... <tools> ... add_generation_prompt ... '<think>\\n' ...";
891
892 fn s35(msgs: &[(&str, &str)], genp: bool) -> String {
893 apply_chat_template_str(Some(STEP35_TMPL), msgs, genp)
894 }
895
896 fn s35_turns(turns: Vec<Turn>, genp: bool, tools: &[String]) -> String {
897 apply_chat_template_tools(Some(STEP35_TMPL), &turns, genp, tools, ThinkMode::Default, None)
898 .unwrap()
899 }
900
901 fn turn(role: &str, content: &str) -> Turn {
902 Turn { role: role.into(), content: content.into(), tool_calls: Vec::new() }
903 }
904
905 #[test]
906 fn step35_plain_paths_match_the_shipped_jinja() {
907 assert_eq!(s35(&[("user", "Hello")], true),
908 "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think>\n");
909 assert_eq!(s35(&[("user", "Hello")], false), "<|im_start|>user\nHello<|im_end|>\n");
910 assert_eq!(s35(&[("system", "You are helpful."), ("user", "Hi")], true),
911 "<|im_start|>system\nYou are helpful.<|im_end|>\n\
912 <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n");
913 assert_eq!(
916 s35(&[("system", "rules"), ("user", "task"), ("assistant", "work"),
917 ("user", "more")], true),
918 "<|im_start|>system\nrules<|im_end|>\n<|im_start|>user\ntask<|im_end|>\n\
919 <|im_start|>assistant\nwork<|im_end|>\n<|im_start|>user\nmore<|im_end|>\n\
920 <|im_start|>assistant\n<think>\n");
921 assert_eq!(s35(&[("user", " padded ")], true),
923 "<|im_start|>user\n padded <|im_end|>\n<|im_start|>assistant\n<think>\n");
924 }
925
926 #[test]
927 fn step35_dispatch_beats_the_qwen_marker_arm() {
928 let qwen = apply_chat_template_str(Some(QWEN_TOOLS_TMPL), &[("user", " pad ")], true);
932 let step = s35(&[("user", " pad ")], true);
933 assert_eq!(qwen, "<|im_start|>user\npad<|im_end|>\n<|im_start|>assistant\n<think>\n");
934 assert_eq!(step, "<|im_start|>user\n pad <|im_end|>\n<|im_start|>assistant\n<think>\n");
935 assert_ne!(qwen, step);
936 }
937
938 #[test]
939 fn step35_reasoning_effort_renders_in_the_system_turn() {
940 assert_eq!(
941 apply_step35_template(&[turn("user", "Hi")], true, &[], Some("high")),
942 "<|im_start|>system\nReasoning: high\n\n<|im_end|>\n\
943 <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n");
944 assert_eq!(
945 apply_step35_template(&[turn("system", "Be terse."), turn("user", "Hi")], true, &[],
946 Some("low")),
947 "<|im_start|>system\nReasoning: low\n\nBe terse.<|im_end|>\n\
948 <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n");
949 let tools = vec![r#"{"type": "function", "function": {"name": "f"}}"#.to_string()];
951 let s = apply_step35_template(&[turn("system", "Be terse."), turn("user", "q")], true,
952 &tools, Some("medium"));
953 assert!(s.starts_with("<|im_start|>system\nReasoning: medium\n\nBe terse.\n\n# Tools\n"),
954 "{s:?}");
955 }
956
957 #[test]
958 fn reasoning_effort_reaches_step35_through_the_public_entry_and_only_step35() {
959 let turns = vec![turn("user", "Hi")];
962 let s = apply_chat_template_tools(Some(STEP35_TMPL), &turns, true, &[],
963 ThinkMode::Default, Some("high")).unwrap();
964 assert!(s.starts_with("<|im_start|>system\nReasoning: high\n\n<|im_end|>\n"), "{s:?}");
965 let s = apply_chat_template_tools(Some(STEP35_TMPL), &turns, true, &[],
967 ThinkMode::Default, None).unwrap();
968 assert!(!s.contains("Reasoning:"), "{s:?}");
969 for tmpl in [None, Some(QWEN_TOOLS_TMPL), Some("... hy_User ..."), Some("... <|turn> ...")] {
972 let with = apply_chat_template_tools(tmpl, &turns, true, &[],
973 ThinkMode::Default, Some("high")).unwrap();
974 let without = apply_chat_template_tools(tmpl, &turns, true, &[],
975 ThinkMode::Default, None).unwrap();
976 assert_eq!(with, without, "template={tmpl:?}");
977 }
978 }
979
980 #[test]
981 fn step35_tools_header_is_not_the_qwen_header() {
982 let tools = vec![
983 r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string(),
984 r#"{"type": "function", "function": {"name": "search"}}"#.to_string(),
985 ];
986 let s = s35_turns(vec![turn("system", "Be terse."), turn("user", "Weather in Paris?")],
987 true, &tools);
988 assert_eq!(s, concat!(
989 "<|im_start|>system\nBe terse.\n\n# Tools\n\n",
992 "You have access to the following functions in JSONSchema format:\n\n<tools>\n",
993 "{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n",
994 "{\"type\": \"function\", \"function\": {\"name\": \"search\"}}\n</tools>",
995 "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
996 "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
997 "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
998 "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
999 "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
1002 "<function=...>\n...\n</function> block must be nested within <tool_call>\n...\n",
1003 "</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>",
1004 "<|im_end|>\n",
1005 "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
1006 "<|im_start|>assistant\n<think>\n",
1007 ));
1008 assert!(!s.contains(QWEN_TOOLS_INSTRUCTION));
1010 }
1011
1012 #[test]
1013 fn step35_tool_results_take_their_own_role_and_group() {
1014 let tools = vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
1015 let turns = vec![
1016 turn("user", "both"),
1017 Turn { role: "assistant".into(), content: "checking".into(), tool_calls: vec![
1018 ToolCall { name: "a".into(), params: vec![("x".into(), "1".into())] },
1019 ToolCall { name: "b".into(), params: Vec::new() },
1020 ] },
1021 turn("tool", "r1"),
1022 turn("tool", "r2"),
1023 ];
1024 let s = s35_turns(turns, true, &tools);
1025 let body = s.split("<|im_end|>\n").skip(1).collect::<Vec<_>>().join("<|im_end|>\n");
1026 assert_eq!(body, concat!(
1027 "<|im_start|>user\nboth<|im_end|>\n",
1028 "<|im_start|>assistant\n<think>\n\n</think>\nchecking",
1031 "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>",
1033 "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
1034 "<|im_start|>tool_response\n<tool_response>r1</tool_response>",
1036 "<tool_response>r2</tool_response><|im_end|>\n",
1037 "<|im_start|>assistant\n<think>\n",
1038 ));
1039 }
1040
1041 #[test]
1042 fn step35_assistant_think_split_and_the_reasoning_boundary() {
1043 assert_eq!(
1045 s35(&[("user", "q"), ("assistant", "<think>\nreasoned\n</think>\nanswer")], false),
1046 "<|im_start|>user\nq<|im_end|>\n\
1047 <|im_start|>assistant\n<think>\nreasoned\n</think>\nanswer<|im_end|>\n");
1048 assert_eq!(
1050 s35(&[("user", "q"), ("assistant", "plain")], false),
1051 "<|im_start|>user\nq<|im_end|>\n\
1052 <|im_start|>assistant\n<think>\n\n</think>\nplain<|im_end|>\n");
1053 assert_eq!(
1056 s35(&[("user", "real question"), ("assistant", "thinking about it"),
1057 ("user", "<tool_response>r</tool_response>")], true),
1058 "<|im_start|>user\nreal question<|im_end|>\n\
1059 <|im_start|>assistant\n<think>\n\n</think>\nthinking about it<|im_end|>\n\
1060 <|im_start|>user\n<tool_response>r</tool_response><|im_end|>\n\
1061 <|im_start|>assistant\n<think>\n");
1062 }
1063
1064 #[test]
1065 fn step35_think_tail_is_unconditional_and_nothink_is_a_noop() {
1066 let turns = vec![turn("user", "hi")];
1070 for mode in [ThinkMode::Default, ThinkMode::NoThink] {
1071 let s = apply_chat_template_tools(Some(STEP35_TMPL), &turns, true, &[], mode, None).unwrap();
1072 assert!(s.ends_with("<|im_start|>assistant\n<think>\n"), "mode={mode:?} {s:?}");
1073 }
1074 }
1075
1076 #[test]
1077 fn step35_plain_path_is_identical_through_both_renderers() {
1078 let batteries: &[&[(&str, &str)]] = &[
1081 &[("user", "Hello")],
1082 &[("system", "You are helpful."), ("user", "Hi")],
1083 &[("system", "rules"), ("user", "task"), ("assistant", "work"), ("user", "more")],
1084 &[("user", " padded "), ("assistant", "reply\nwith lines")],
1085 ];
1086 for msgs in batteries {
1087 let legacy = s35(msgs, true);
1088 let ext = s35_turns(msgs.iter().map(|(r, c)| turn(r, c)).collect(), true, &[]);
1089 assert_eq!(legacy, ext, "msgs={msgs:?}");
1090 }
1091 }
1092
1093 #[test]
1094 fn qwen_think_tail() {
1095 let tmpl = "... add_generation_prompt ... '<think>\\n' ...";
1097 let s = apply_chat_template_str(
1098 Some(tmpl),
1099 &[("system", "You are helpful."), ("user", "Hi")],
1100 true,
1101 );
1102 assert_eq!(
1103 s,
1104 "<|im_start|>system\nYou are helpful.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
1105 );
1106 }
1107}