1#[derive(Debug, Clone, PartialEq)]
32pub enum Val {
33 Null,
34 Bool(bool),
35 Num(String),
36 Str(String),
37 Arr(Vec<Val>),
38 Obj(Vec<(String, Val)>),
41}
42
43#[derive(Debug, Clone, Default, PartialEq)]
48pub struct ToolCall {
49 pub name: String,
50 pub params: Vec<(String, String)>,
51 pub args: Vec<(String, Val)>,
53 pub id: Option<String>,
55}
56
57#[derive(Debug, Clone, Default, PartialEq)]
61pub struct Turn {
62 pub role: String,
63 pub content: String,
64 pub tool_calls: Vec<ToolCall>,
65 pub reasoning: Option<String>,
68 pub tool_call_id: Option<String>,
71 pub tool_name: Option<String>,
74 pub tool_responses: Vec<(String, Val)>,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum ThinkMode {
96 Default,
97 NoThink,
98 Think,
99}
100
101pub fn apply_chat_template_str(
107 template: Option<&str>,
108 messages: &[(&str, &str)],
109 add_generation_prompt: bool,
110) -> String {
111 if template.is_some_and(|t| t.contains("hy_User")) {
115 return apply_hy3_template(messages, add_generation_prompt, "no_think");
116 }
117 if template.is_some_and(|t| t.contains("render_message_content")) {
123 let turns: Vec<Turn> = messages
124 .iter()
125 .map(|(r, c)| Turn {
126 role: r.to_string(),
127 content: c.to_string(),
128 tool_calls: Vec::new(),
129 ..Default::default()
130 })
131 .collect();
132 return apply_step35_template(&turns, add_generation_prompt, &[], None);
133 }
134 if template.is_some_and(|t| t.contains("<|turn>")) {
140 return apply_gemma4_template(messages, add_generation_prompt, false);
141 }
142 let qwen_think = template
144 .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
145 .unwrap_or(false);
146
147 let mut out = String::new();
148 for (i, (role, content)) in messages.iter().enumerate() {
149 let content = content.trim();
150 match *role {
151 "system" => {
152 let _ = i;
155 out.push_str("<|im_start|>system\n");
156 out.push_str(content);
157 out.push_str("<|im_end|>\n");
158 }
159 "user" => {
160 out.push_str("<|im_start|>user\n");
161 out.push_str(content);
162 out.push_str("<|im_end|>\n");
163 }
164 "assistant" => {
165 out.push_str("<|im_start|>assistant\n");
166 out.push_str(content);
167 out.push_str("<|im_end|>\n");
168 }
169 other => {
170 out.push_str("<|im_start|>");
172 out.push_str(other);
173 out.push('\n');
174 out.push_str(content);
175 out.push_str("<|im_end|>\n");
176 }
177 }
178 }
179
180 if add_generation_prompt {
181 out.push_str("<|im_start|>assistant\n");
182 if qwen_think {
183 out.push_str("<think>\n");
184 }
185 }
186
187 out
188}
189
190const QWEN_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
194following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
195<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
196This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
197</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
198format: an inner <function=...></function> block must be nested within <tool_call></tool_call> \
199XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for \
200your function call in natural language BEFORE the function call, but NOT after\n- If there is \
201no function call available, answer the question like normal with your current knowledge and do \
202not tell the user about function calls\n</IMPORTANT>";
203
204pub fn apply_chat_template_tools(
234 template: Option<&str>,
235 turns: &[Turn],
236 add_generation_prompt: bool,
237 tools_json: &[String],
238 think: ThinkMode,
239 reasoning_effort: Option<&str>,
240) -> Result<String, String> {
241 apply_chat_template_tools_ex(
244 template,
245 turns,
246 add_generation_prompt,
247 tools_json,
248 &[],
249 think,
250 reasoning_effort,
251 )
252}
253
254#[allow(clippy::too_many_arguments)]
257pub fn apply_chat_template_tools_ex(
258 template: Option<&str>,
259 turns: &[Turn],
260 add_generation_prompt: bool,
261 tools_json: &[String],
262 tools_struct: &[Val],
263 think: ThinkMode,
264 reasoning_effort: Option<&str>,
265) -> Result<String, String> {
266 let has_tool_features = !tools_json.is_empty()
267 || turns
268 .iter()
269 .any(|t| t.role == "tool" || !t.tool_calls.is_empty());
270 let tools_branch = template.is_some_and(template_has_tools_branch);
273 if has_tool_features && !tools_branch {
274 return Err("model chat template has no tools branch".into());
275 }
276 if template.is_some_and(|t| t.contains("render_message_content")) {
283 return Ok(apply_step35_template(
284 turns,
285 add_generation_prompt,
286 tools_json,
287 reasoning_effort,
288 ));
289 }
290 if template.is_some_and(|t| t.contains("<|turn>") && t.contains("<|tool>")) {
298 let closed_tail = template.is_some_and(|t| t.contains("<|channel>thought\\n<channel|>"));
303 return Ok(apply_gemma4_tools_template(
304 turns,
305 add_generation_prompt,
306 tools_struct,
307 think == ThinkMode::Think,
308 closed_tail,
309 ));
310 }
311 if template.is_some_and(|t| t.contains("hy_User") || t.contains("<|turn>")) {
312 if has_tool_features {
321 return Err("tools are not supported on this model's chat-template dialect".into());
322 }
323 let messages: Vec<(&str, &str)> = turns
324 .iter()
325 .map(|t| (t.role.as_str(), t.content.as_str()))
326 .collect();
327 if template.is_some_and(|t| t.contains("hy_User")) {
328 let effort = match (think, reasoning_effort) {
331 (ThinkMode::Think, Some("high")) => "high",
332 (ThinkMode::Think, _) => "low",
333 _ => "no_think",
334 };
335 return Ok(apply_hy3_template(&messages, add_generation_prompt, effort));
336 }
337 return Ok(apply_gemma4_template(
338 &messages,
339 add_generation_prompt,
340 think == ThinkMode::Think,
341 ));
342 }
343 let qwen_think = template
344 .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
345 .unwrap_or(false);
346 let think_switch = template.is_some_and(|t| t.contains("enable_thinking"));
347
348 let mut out = String::new();
349 let mut skip_leading_system = false;
352 if !tools_json.is_empty() {
353 out.push_str("<|im_start|>system\n");
354 out.push_str("# Tools\n\nYou have access to the following functions:\n\n<tools>");
355 for tool in tools_json {
356 out.push('\n');
357 out.push_str(tool);
358 }
359 out.push_str("\n</tools>");
360 out.push_str(QWEN_TOOLS_INSTRUCTION);
361 if let Some(first) = turns.first() {
362 if first.role == "system" {
363 skip_leading_system = true;
364 let content = first.content.trim();
365 if !content.is_empty() {
366 out.push_str("\n\n");
367 out.push_str(content);
368 }
369 }
370 }
371 out.push_str("<|im_end|>\n");
372 }
373
374 for (i, turn) in turns.iter().enumerate() {
375 if i == 0 && skip_leading_system {
376 continue;
377 }
378 let content = turn.content.trim();
379 match turn.role.as_str() {
380 "system" => {
381 out.push_str("<|im_start|>system\n");
382 out.push_str(content);
383 out.push_str("<|im_end|>\n");
384 }
385 "user" => {
386 out.push_str("<|im_start|>user\n");
387 out.push_str(content);
388 out.push_str("<|im_end|>\n");
389 }
390 "assistant" => {
391 out.push_str("<|im_start|>assistant\n");
392 out.push_str(content);
393 for (k, call) in turn.tool_calls.iter().enumerate() {
394 if k == 0 {
395 if !content.is_empty() {
396 out.push_str("\n\n");
397 }
398 } else {
399 out.push('\n');
400 }
401 out.push_str("<tool_call>\n<function=");
402 out.push_str(&call.name);
403 out.push_str(">\n");
404 for (key, value) in &call.params {
405 out.push_str("<parameter=");
406 out.push_str(key);
407 out.push_str(">\n");
408 out.push_str(value);
409 out.push_str("\n</parameter>\n");
410 }
411 out.push_str("</function>\n</tool_call>");
412 }
413 out.push_str("<|im_end|>\n");
414 }
415 "tool" => {
416 if i == 0 || turns[i - 1].role != "tool" {
417 out.push_str("<|im_start|>user");
418 }
419 out.push_str("\n<tool_response>\n");
420 out.push_str(content);
421 out.push_str("\n</tool_response>");
422 if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
423 out.push_str("<|im_end|>\n");
424 }
425 }
426 other => {
427 out.push_str("<|im_start|>");
429 out.push_str(other);
430 out.push('\n');
431 out.push_str(content);
432 out.push_str("<|im_end|>\n");
433 }
434 }
435 }
436
437 if add_generation_prompt {
438 out.push_str("<|im_start|>assistant\n");
439 if qwen_think {
440 if think == ThinkMode::NoThink && think_switch {
441 out.push_str("<think>\n\n</think>\n\n");
442 } else {
443 out.push_str("<think>\n");
444 }
445 }
446 }
447 Ok(out)
448}
449
450const STEP35_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
459following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
460<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
461This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
462</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
463format: an inner <function=...>\n...\n</function> block must be nested within <tool_call>\n\
464...\n</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>";
465
466fn apply_step35_template(
511 turns: &[Turn],
512 add_generation_prompt: bool,
513 tools_json: &[String],
514 reasoning_effort: Option<&str>,
515) -> String {
516 let mut out = String::new();
517 let leading_system = turns.first().filter(|t| t.role == "system");
518
519 if !tools_json.is_empty() {
521 out.push_str("<|im_start|>system\n");
522 if let Some(effort) = reasoning_effort {
523 out.push_str("Reasoning: ");
524 out.push_str(effort);
525 out.push_str("\n\n");
526 }
527 if let Some(sys) = leading_system {
528 out.push_str(&sys.content);
530 out.push_str("\n\n");
531 }
532 out.push_str(
533 "# Tools\n\nYou have access to the following functions in JSONSchema \
534 format:\n\n<tools>",
535 );
536 for tool in tools_json {
537 out.push('\n');
538 out.push_str(tool);
539 }
540 out.push_str("\n</tools>");
541 out.push_str(STEP35_TOOLS_INSTRUCTION);
542 out.push_str("<|im_end|>\n");
543 } else if let Some(sys) = leading_system {
544 out.push_str("<|im_start|>system\n");
545 if let Some(effort) = reasoning_effort {
546 out.push_str("Reasoning: ");
547 out.push_str(effort);
548 out.push_str("\n\n");
549 }
550 out.push_str(&sys.content);
551 out.push_str("<|im_end|>\n");
552 } else if let Some(effort) = reasoning_effort {
553 out.push_str("<|im_start|>system\nReasoning: ");
554 out.push_str(effort);
555 out.push_str("\n\n<|im_end|>\n");
556 }
557
558 let last_query_index = turns
563 .iter()
564 .enumerate()
565 .rev()
566 .find(|(_, t)| {
567 t.role == "user"
568 && !(t.content.starts_with("<tool_response>")
569 && t.content.ends_with("</tool_response>"))
570 })
571 .map(|(i, _)| i)
572 .unwrap_or(turns.len().saturating_sub(1));
573
574 for (i, turn) in turns.iter().enumerate() {
575 let content = &turn.content; match turn.role.as_str() {
577 "system" if i == 0 => {}
579 "system" | "user" => {
580 out.push_str("<|im_start|>");
581 out.push_str(&turn.role);
582 out.push('\n');
583 out.push_str(content);
584 out.push_str("<|im_end|>\n");
585 }
586 "assistant" => {
587 let (reasoning, body): (String, &str) = match content.find("</think>") {
593 Some(first) => {
594 let pre = content[..first].trim_end_matches('\n');
595 let pre = match pre.rfind("<think>") {
596 Some(o) => &pre[o + "<think>".len()..],
597 None => pre,
598 };
599 let last = content.rfind("</think>").unwrap();
600 (
601 pre.trim_start_matches('\n').to_string(),
602 content[last + "</think>".len()..].trim_start_matches('\n'),
603 )
604 }
605 None => (String::new(), content.as_str()),
606 };
607 out.push_str("<|im_start|>assistant\n");
608 if i > last_query_index {
609 out.push_str("<think>\n");
610 out.push_str(&reasoning);
611 out.push_str("\n</think>\n");
612 }
613 out.push_str(body);
614 for call in &turn.tool_calls {
616 out.push_str("<tool_call>\n<function=");
617 out.push_str(&call.name);
618 out.push_str(">\n");
619 for (key, value) in &call.params {
620 out.push_str("<parameter=");
621 out.push_str(key);
622 out.push_str(">\n");
623 out.push_str(value);
624 out.push_str("\n</parameter>\n");
625 }
626 out.push_str("</function>\n</tool_call>");
627 }
628 out.push_str("<|im_end|>\n");
629 }
630 "tool" => {
631 if i == 0 || turns[i - 1].role != "tool" {
633 out.push_str("<|im_start|>tool_response\n");
634 }
635 out.push_str("<tool_response>");
636 out.push_str(content);
637 out.push_str("</tool_response>");
638 if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
639 out.push_str("<|im_end|>\n");
640 }
641 }
642 other => {
643 out.push_str("<|im_start|>");
645 out.push_str(other);
646 out.push('\n');
647 out.push_str(content);
648 out.push_str("<|im_end|>\n");
649 }
650 }
651 }
652
653 if add_generation_prompt {
654 out.push_str("<|im_start|>assistant\n<think>\n");
655 }
656 out
657}
658
659fn apply_hy3_template(
675 messages: &[(&str, &str)],
676 add_generation_prompt: bool,
677 effort: &str,
678) -> String {
679 const BOS: &str = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>";
680 const USER: &str = "<\u{ff5c}hy_User:opensource\u{ff5c}>";
681 const ASSISTANT: &str = "<\u{ff5c}hy_Assistant:opensource\u{ff5c}>";
682 const EOS: &str = "<\u{ff5c}hy_eos:opensource\u{ff5c}>";
683 const REASONING: &str = "<\u{ff5c}reasoning_mode:opensource\u{ff5c}>";
684 const THINK_BEGIN: &str = "<think:opensource>";
685 const THINK_END: &str = "</think:opensource>";
686
687 debug_assert!(
688 matches!(effort, "no_think" | "low" | "high"),
689 "hy3 reasoning_effort must be no_think|low|high, got {effort:?}"
690 );
691 let mut out = String::from(BOS);
692 for (role, content) in messages.iter().filter(|(r, _)| *r == "system") {
693 let _ = role;
694 out.push_str(content);
695 }
696 out.push_str(REASONING);
697 out.push_str("reasoning_effort:");
698 out.push_str(effort);
699
700 let mut last_is_assistant = false;
701 let n = messages.len();
702 for (i, (role, content)) in messages.iter().enumerate() {
703 last_is_assistant = false;
704 match *role {
705 "user" => {
706 out.push_str(USER);
707 out.push_str(content);
708 }
709 "assistant" => {
710 out.push_str(ASSISTANT);
711 out.push_str(THINK_BEGIN);
712 out.push_str(THINK_END);
713 out.push_str(content);
714 if i + 1 < n {
715 out.push_str(EOS);
716 } last_is_assistant = true;
718 }
719 _ => {} }
721 }
722 if add_generation_prompt && !last_is_assistant {
723 out.push_str(ASSISTANT);
724 out.push_str(THINK_BEGIN);
725 if effort == "no_think" {
726 out.push_str(THINK_END); }
728 }
729 out
730}
731
732fn apply_gemma4_template(
744 messages: &[(&str, &str)],
745 add_generation_prompt: bool,
746 thinking: bool,
747) -> String {
748 let mut out = String::new();
749 let mut msgs = messages;
750 let leading_system = msgs.first().filter(|(r, _)| *r == "system");
752 if thinking || leading_system.is_some() {
753 out.push_str("<|turn>system\n");
754 if thinking {
755 out.push_str("<|think|>\n");
756 }
757 if let Some((_, content)) = leading_system {
758 out.push_str(content.trim());
759 msgs = &msgs[1..];
760 }
761 out.push_str("<turn|>\n");
762 }
763 for (role, content) in msgs {
764 let role = if *role == "assistant" { "model" } else { role };
765 out.push_str("<|turn>");
766 out.push_str(role);
767 out.push('\n');
768 out.push_str(content.trim());
769 out.push_str("<turn|>\n");
770 }
771 if add_generation_prompt {
772 out.push_str("<|turn>model\n");
773 if !thinking {
774 out.push_str("<|channel>thought\n<channel|>");
775 }
776 }
777 out
778}
779
780pub fn template_has_tools_branch(t: &str) -> bool {
784 if t.contains("hy_User") {
785 return false;
786 }
787 t.contains("<tools>") || (t.contains("<|turn>") && t.contains("<|tool>"))
788}
789
790fn dictsort(pairs: &[(String, Val)]) -> Vec<&(String, Val)> {
801 let mut v: Vec<&(String, Val)> = pairs.iter().collect();
802 v.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
803 v
804}
805
806fn format_argument(v: &Val, escape_keys: bool) -> String {
810 match v {
811 Val::Str(s) => format!("<|\"|>{s}<|\"|>"),
812 Val::Bool(b) => if *b { "true" } else { "false" }.to_string(),
813 Val::Obj(pairs) => {
814 let mut out = String::from("{");
815 for (i, (k, val)) in dictsort(pairs).iter().map(|p| (&p.0, &p.1)).enumerate() {
816 if i > 0 {
817 out.push(',');
818 }
819 if escape_keys {
820 out.push_str(&format!("<|\"|>{k}<|\"|>"));
821 } else {
822 out.push_str(k);
823 }
824 out.push(':');
825 out.push_str(&format_argument(val, escape_keys));
826 }
827 out.push('}');
828 out
829 }
830 Val::Arr(items) => {
831 let mut out = String::from("[");
832 for (i, item) in items.iter().enumerate() {
833 if i > 0 {
834 out.push(',');
835 }
836 out.push_str(&format_argument(item, escape_keys));
837 }
838 out.push(']');
839 out
840 }
841 Val::Null => "None".to_string(),
842 Val::Num(s) => s.clone(),
843 }
844}
845
846fn strip_thinking(text: &str) -> String {
850 let mut result = String::new();
851 for part in text.split("<channel|>") {
852 match part.find("<|channel>") {
853 Some(o) => result.push_str(&part[..o]),
854 None => result.push_str(part),
855 }
856 }
857 result.trim().to_string()
858}
859
860fn val_get<'a>(obj: &'a [(String, Val)], key: &str) -> Option<&'a Val> {
861 obj.iter().find(|(k, _)| k == key).map(|(_, v)| v)
862}
863fn as_obj(v: &Val) -> Option<&[(String, Val)]> {
864 match v {
865 Val::Obj(p) => Some(p),
866 _ => None,
867 }
868}
869fn as_str(v: &Val) -> Option<&str> {
870 match v {
871 Val::Str(s) => Some(s),
872 _ => None,
873 }
874}
875fn truthy(v: &Val) -> bool {
877 match v {
878 Val::Null => false,
879 Val::Bool(b) => *b,
880 Val::Str(s) => !s.is_empty(),
881 Val::Num(s) => s != "0" && s != "0.0",
882 Val::Arr(a) => !a.is_empty(),
883 Val::Obj(o) => !o.is_empty(),
884 }
885}
886
887fn comma(out: &mut String, add: &mut bool) {
890 if *add {
891 out.push(',');
892 } else {
893 *add = true;
894 }
895}
896
897fn format_parameters(out: &mut String, props: &[(String, Val)], filter_keys: bool) {
900 const STANDARD: [&str; 5] = ["description", "type", "properties", "required", "nullable"];
901 let mut found_first = false;
902 for (key, value) in dictsort(props).iter().map(|p| (&p.0, &p.1)) {
903 if filter_keys && STANDARD.contains(&key.as_str()) {
904 continue;
905 }
906 if found_first {
907 out.push(',');
908 }
909 found_first = true;
910 out.push_str(key);
911 out.push_str(":{");
912 let vobj = as_obj(value);
913 let mut add = false;
914 if let Some(d) = vobj
916 .and_then(|o| val_get(o, "description"))
917 .filter(|d| truthy(d))
918 {
919 out.push_str("description:<|\"|>");
920 out.push_str(as_str(d).unwrap_or(""));
921 out.push_str("<|\"|>");
922 add = true;
923 }
924 let ty_up = vobj
925 .and_then(|o| val_get(o, "type"))
926 .and_then(as_str)
927 .map(|s| s.to_uppercase());
928 match ty_up.as_deref() {
929 Some("STRING") => {
930 if let Some(en) = vobj.and_then(|o| val_get(o, "enum")).filter(|e| truthy(e)) {
931 comma(out, &mut add);
932 out.push_str("enum:");
933 out.push_str(&format_argument(en, true));
934 }
935 }
936 Some("ARRAY") => {
937 if let Some(items) = vobj
938 .and_then(|o| val_get(o, "items"))
939 .filter(|it| matches!(it, Val::Obj(o) if !o.is_empty()))
940 {
941 comma(out, &mut add);
942 out.push_str("items:{");
943 format_items(out, as_obj(items).unwrap());
944 out.push('}');
945 }
946 }
947 _ => {}
948 }
949 if vobj
951 .and_then(|o| val_get(o, "nullable"))
952 .is_some_and(truthy)
953 {
954 comma(out, &mut add);
955 out.push_str("nullable:true");
956 }
957 if ty_up.as_deref() == Some("OBJECT") {
959 if let Some(sub) = vobj.and_then(|o| val_get(o, "properties")).and_then(as_obj) {
960 comma(out, &mut add);
961 out.push_str("properties:{");
962 format_parameters(out, sub, false);
963 out.push('}');
964 } else if let Some(o) = vobj {
965 comma(out, &mut add);
968 out.push_str("properties:{");
969 format_parameters(out, o, true);
970 out.push('}');
971 }
972 if let Some(req) = vobj
973 .and_then(|o| val_get(o, "required"))
974 .filter(|r| truthy(r))
975 {
976 comma(out, &mut add);
977 out.push_str("required:[");
978 push_str_list(out, req);
979 out.push(']');
980 }
981 }
982 comma(out, &mut add);
984 out.push_str("type:<|\"|>");
985 out.push_str(ty_up.as_deref().unwrap_or(""));
986 out.push_str("<|\"|>}");
987 }
988}
989
990fn format_items(out: &mut String, items: &[(String, Val)]) {
993 let mut found_first = false;
994 for (k, v) in dictsort(items).iter().map(|p| (&p.0, &p.1)) {
995 if matches!(v, Val::Null) {
996 continue;
997 }
998 if found_first {
999 out.push(',');
1000 }
1001 found_first = true;
1002 match k.as_str() {
1003 "properties" => {
1004 out.push_str("properties:{");
1005 if let Some(o) = as_obj(v) {
1006 format_parameters(out, o, false);
1007 }
1008 out.push('}');
1009 }
1010 "required" => {
1011 out.push_str("required:[");
1012 push_str_list(out, v);
1013 out.push(']');
1014 }
1015 "type" => {
1016 out.push_str("type:");
1017 match v {
1018 Val::Str(s) => {
1019 out.push_str(&format_argument(&Val::Str(s.to_uppercase()), true))
1020 }
1021 Val::Arr(a) => {
1022 let upper: Vec<Val> = a
1023 .iter()
1024 .map(|x| Val::Str(as_str(x).unwrap_or("").to_uppercase()))
1025 .collect();
1026 out.push_str(&format_argument(&Val::Arr(upper), true));
1027 }
1028 other => out.push_str(&format_argument(other, true)),
1029 }
1030 }
1031 _ => {
1032 out.push_str(k);
1033 out.push(':');
1034 out.push_str(&format_argument(v, true));
1035 }
1036 }
1037 }
1038}
1039
1040fn push_str_list(out: &mut String, v: &Val) {
1042 if let Val::Arr(items) = v {
1043 for (i, item) in items.iter().enumerate() {
1044 if i > 0 {
1045 out.push(',');
1046 }
1047 out.push_str("<|\"|>");
1048 out.push_str(as_str(item).unwrap_or(""));
1049 out.push_str("<|\"|>");
1050 }
1051 }
1052}
1053
1054fn format_function_declaration(func: &[(String, Val)]) -> String {
1056 let mut out = String::new();
1057 out.push_str("declaration:");
1058 out.push_str(val_get(func, "name").and_then(as_str).unwrap_or(""));
1059 out.push_str("{description:<|\"|>");
1060 out.push_str(val_get(func, "description").and_then(as_str).unwrap_or(""));
1061 out.push_str("<|\"|>");
1062 if let Some(params) = val_get(func, "parameters").filter(|p| truthy(p)) {
1063 let pobj = as_obj(params);
1064 out.push_str(",parameters:{");
1065 if let Some(props) = pobj
1066 .and_then(|o| val_get(o, "properties"))
1067 .filter(|p| truthy(p))
1068 .and_then(as_obj)
1069 {
1070 out.push_str("properties:{");
1071 format_parameters(&mut out, props, false);
1072 out.push_str("},");
1073 }
1074 if let Some(req) = pobj
1075 .and_then(|o| val_get(o, "required"))
1076 .filter(|r| truthy(r))
1077 {
1078 out.push_str("required:[");
1079 push_str_list(&mut out, req);
1080 out.push_str("],");
1081 }
1082 if let Some(ty) = pobj.and_then(|o| val_get(o, "type")).filter(|t| truthy(t)) {
1083 out.push_str("type:<|\"|>");
1084 out.push_str(&as_str(ty).unwrap_or("").to_uppercase());
1085 out.push_str("<|\"|>}");
1086 }
1087 }
1088 if let Some(resp) = val_get(func, "response").and_then(as_obj) {
1089 out.push_str(",response:{");
1090 if let Some(d) = val_get(resp, "description").filter(|d| truthy(d)) {
1091 out.push_str("description:<|\"|>");
1092 out.push_str(as_str(d).unwrap_or(""));
1093 out.push_str("<|\"|>,");
1094 }
1095 if val_get(resp, "type")
1096 .and_then(as_str)
1097 .map(|s| s.to_uppercase())
1098 == Some("OBJECT".into())
1099 {
1100 out.push_str("type:<|\"|>OBJECT<|\"|>}");
1101 }
1102 }
1103 out.push('}');
1104 out
1105}
1106
1107fn format_tool_response_block(name: &str, response: &Val) -> String {
1109 let mut out = String::from("<|tool_response>");
1110 match response {
1111 Val::Obj(pairs) => {
1112 out.push_str("response:");
1113 out.push_str(name);
1114 out.push('{');
1115 for (i, (k, v)) in dictsort(pairs).iter().map(|p| (&p.0, &p.1)).enumerate() {
1116 if i > 0 {
1117 out.push(',');
1118 }
1119 out.push_str(k);
1120 out.push(':');
1121 out.push_str(&format_argument(v, false));
1122 }
1123 out.push('}');
1124 }
1125 other => {
1126 out.push_str("response:");
1127 out.push_str(name);
1128 out.push_str("{value:");
1129 out.push_str(&format_argument(other, false));
1130 out.push('}');
1131 }
1132 }
1133 out.push_str("<tool_response|>");
1134 out
1135}
1136
1137fn apply_gemma4_tools_template(
1142 turns: &[Turn],
1143 add_generation_prompt: bool,
1144 tools: &[Val],
1145 thinking: bool,
1146 closed_tail: bool,
1147) -> String {
1148 let mut out = String::new();
1149 let mut prev: Option<&str> = None;
1150 let mut msgs = turns;
1151 let is_sys = |r: &str| r == "system" || r == "developer";
1152
1153 let leading_system = msgs.first().filter(|t| is_sys(&t.role));
1154 if thinking || !tools.is_empty() || leading_system.is_some() {
1155 out.push_str("<|turn>system\n");
1156 if thinking {
1157 out.push_str("<|think|>\n");
1158 prev = Some("think");
1159 }
1160 if let Some(sys) = leading_system {
1161 out.push_str(sys.content.trim());
1162 msgs = &msgs[1..];
1163 }
1164 for tool in tools {
1165 out.push_str("<|tool>");
1166 if let Some(func) = as_obj(tool) {
1167 out.push_str(format_function_declaration(func).trim());
1168 }
1169 out.push_str("<tool|>");
1170 }
1171 if !tools.is_empty() {
1172 prev = Some("tool");
1173 }
1174 out.push_str("<turn|>\n");
1175 }
1176
1177 let last_user_idx: isize = msgs
1178 .iter()
1179 .enumerate()
1180 .rev()
1181 .find(|(_, t)| t.role == "user")
1182 .map(|(i, _)| i as isize)
1183 .unwrap_or(-1);
1184
1185 for (i, m) in msgs.iter().enumerate() {
1186 if m.role == "tool" {
1187 continue; }
1189 prev = None;
1190 let role = if m.role == "assistant" {
1191 "model"
1192 } else {
1193 m.role.as_str()
1194 };
1195 let prev_nt_role = (0..i)
1196 .rev()
1197 .map(|j| &msgs[j])
1198 .find(|t| t.role != "tool")
1199 .map(|t| t.role.as_str());
1200 let continue_same_model_turn = role == "model" && prev_nt_role == Some("assistant");
1201 if !continue_same_model_turn {
1202 out.push_str("<|turn>");
1203 out.push_str(role);
1204 out.push('\n');
1205 }
1206
1207 if let Some(rt) = m.reasoning.as_deref() {
1209 if !rt.is_empty() && (i as isize) > last_user_idx && !m.tool_calls.is_empty() {
1210 out.push_str("<|channel>thought\n");
1211 out.push_str(rt);
1212 out.push_str("\n<channel|>");
1213 }
1214 }
1215
1216 if !m.tool_calls.is_empty() {
1218 for tc in &m.tool_calls {
1219 out.push_str("<|tool_call>call:");
1220 out.push_str(&tc.name);
1221 out.push('{');
1222 for (j, (k, v)) in dictsort(&tc.args).iter().map(|p| (&p.0, &p.1)).enumerate() {
1223 if j > 0 {
1224 out.push(',');
1225 }
1226 out.push_str(k);
1227 out.push(':');
1228 out.push_str(&format_argument(v, false));
1229 }
1230 out.push_str("}<tool_call|>");
1231 }
1232 prev = Some("tool_call");
1233 }
1234
1235 let mut tr_flag = false;
1237 if !m.tool_responses.is_empty() {
1238 for (name, resp) in &m.tool_responses {
1239 out.push_str(&format_tool_response_block(name, resp));
1240 tr_flag = true;
1241 prev = Some("tool_response");
1242 }
1243 } else if !m.tool_calls.is_empty() {
1244 for k in (i + 1)..msgs.len() {
1245 let follow = &msgs[k];
1246 if follow.role != "tool" {
1247 break;
1248 }
1249 let mut name = follow
1250 .tool_name
1251 .clone()
1252 .unwrap_or_else(|| "unknown".to_string());
1253 if let Some(fid) = follow.tool_call_id.as_deref() {
1254 for tc in &m.tool_calls {
1255 if tc.id.as_deref() == Some(fid) {
1256 name = tc.name.clone();
1257 }
1258 }
1259 }
1260 out.push_str(&format_tool_response_block(
1261 &name,
1262 &Val::Str(follow.content.clone()),
1263 ));
1264 tr_flag = true;
1265 prev = Some("tool_response");
1266 }
1267 }
1268
1269 let captured = if role == "model" {
1271 strip_thinking(&m.content)
1272 } else {
1273 m.content.trim().to_string()
1274 };
1275 out.push_str(&captured);
1276 let has_content = !captured.trim().is_empty();
1277
1278 if prev == Some("tool_call") && !tr_flag {
1279 out.push_str("<|tool_response>"); } else if !(tr_flag && !has_content) {
1281 out.push_str("<turn|>\n");
1282 }
1283 }
1284
1285 if add_generation_prompt && prev != Some("tool_response") && prev != Some("tool_call") {
1286 out.push_str("<|turn>model\n");
1287 if closed_tail && !thinking {
1288 out.push_str("<|channel>thought\n<channel|>");
1289 }
1290 }
1291 out
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296 use super::*;
1297
1298 #[test]
1299 fn plain_chatml() {
1300 let s = apply_chat_template_str(None, &[("user", "Hello")], true);
1301 assert_eq!(
1302 s,
1303 "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n"
1304 );
1305 }
1306
1307 const QWEN_TOOLS_TMPL: &str =
1310 "... <tools> ... add_generation_prompt ... enable_thinking ... '<think>\\n' ...";
1311
1312 #[test]
1316 fn tools_renderer_matches_legacy_when_plain() {
1317 let batteries: &[&[(&str, &str)]] = &[
1318 &[("user", "Hello")],
1319 &[("system", "You are helpful."), ("user", "Hi")],
1320 &[
1321 ("system", "rules"),
1322 ("user", "task"),
1323 ("assistant", "work"),
1324 ("user", "more"),
1325 ],
1326 &[("user", " padded "), ("assistant", "reply\nwith lines")],
1327 ];
1328 for tmpl in [None, Some(QWEN_TOOLS_TMPL)] {
1329 for msgs in batteries {
1330 let legacy = apply_chat_template_str(tmpl, msgs, true);
1331 let turns: Vec<Turn> = msgs
1332 .iter()
1333 .map(|(r, c)| Turn {
1334 role: r.to_string(),
1335 content: c.to_string(),
1336 tool_calls: Vec::new(),
1337 ..Default::default()
1338 })
1339 .collect();
1340 let ext =
1341 apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
1342 .unwrap();
1343 assert_eq!(legacy, ext, "template={tmpl:?} msgs={msgs:?}");
1344 }
1345 }
1346 }
1347
1348 #[test]
1349 fn tools_header_and_tool_response_render_per_template_law() {
1350 let tools =
1351 vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
1352 let turns = vec![
1353 Turn {
1354 role: "system".into(),
1355 content: "Be terse.".into(),
1356 tool_calls: Vec::new(),
1357 ..Default::default()
1358 },
1359 Turn {
1360 role: "user".into(),
1361 content: "Weather in Paris?".into(),
1362 tool_calls: Vec::new(),
1363 ..Default::default()
1364 },
1365 Turn {
1366 role: "assistant".into(),
1367 content: "".into(),
1368 tool_calls: vec![ToolCall {
1369 name: "get_weather".into(),
1370 params: vec![("city".into(), "Paris".into())],
1371 ..Default::default()
1372 }],
1373 ..Default::default()
1374 },
1375 Turn {
1376 role: "tool".into(),
1377 content: "{\"temp_c\": 21}".into(),
1378 tool_calls: Vec::new(),
1379 ..Default::default()
1380 },
1381 ];
1382 let s = apply_chat_template_tools(
1383 Some(QWEN_TOOLS_TMPL),
1384 &turns,
1385 true,
1386 &tools,
1387 ThinkMode::Default,
1388 None,
1389 )
1390 .unwrap();
1391 let expected = concat!(
1392 "<|im_start|>system\n# Tools\n\nYou have access to the following functions:\n\n",
1393 "<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n</tools>",
1394 "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
1395 "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
1396 "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
1397 "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
1398 "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
1399 "<function=...></function> block must be nested within <tool_call></tool_call> XML tags\n",
1400 "- Required parameters MUST be specified\n- You may provide optional reasoning for your ",
1401 "function call in natural language BEFORE the function call, but NOT after\n- If there is ",
1402 "no function call available, answer the question like normal with your current knowledge ",
1403 "and do not tell the user about function calls\n</IMPORTANT>",
1404 "\n\nBe terse.<|im_end|>\n",
1405 "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
1406 "<|im_start|>assistant\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n",
1407 "</parameter>\n</function>\n</tool_call><|im_end|>\n",
1408 "<|im_start|>user\n<tool_response>\n{\"temp_c\": 21}\n</tool_response><|im_end|>\n",
1409 "<|im_start|>assistant\n<think>\n",
1410 );
1411 assert_eq!(s, expected);
1412 }
1413
1414 #[test]
1415 fn assistant_content_plus_calls_and_consecutive_tool_turns_group() {
1416 let turns = vec![
1417 Turn {
1418 role: "user".into(),
1419 content: "both".into(),
1420 tool_calls: Vec::new(),
1421 ..Default::default()
1422 },
1423 Turn {
1424 role: "assistant".into(),
1425 content: "checking".into(),
1426 tool_calls: vec![
1427 ToolCall {
1428 name: "a".into(),
1429 params: vec![("x".into(), "1".into())],
1430 ..Default::default()
1431 },
1432 ToolCall {
1433 name: "b".into(),
1434 params: Vec::new(),
1435 ..Default::default()
1436 },
1437 ],
1438 ..Default::default()
1439 },
1440 Turn {
1441 role: "tool".into(),
1442 content: "r1".into(),
1443 tool_calls: Vec::new(),
1444 ..Default::default()
1445 },
1446 Turn {
1447 role: "tool".into(),
1448 content: "r2".into(),
1449 tool_calls: Vec::new(),
1450 ..Default::default()
1451 },
1452 ];
1453 let s = apply_chat_template_tools(
1454 Some(QWEN_TOOLS_TMPL),
1455 &turns,
1456 false,
1457 &[],
1458 ThinkMode::Default,
1459 None,
1460 )
1461 .unwrap();
1462 assert_eq!(
1463 s,
1464 concat!(
1465 "<|im_start|>user\nboth<|im_end|>\n",
1466 "<|im_start|>assistant\nchecking\n\n",
1467 "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>\n",
1468 "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
1469 "<|im_start|>user\n<tool_response>\nr1\n</tool_response>",
1470 "\n<tool_response>\nr2\n</tool_response><|im_end|>\n",
1471 )
1472 );
1473 }
1474
1475 #[test]
1476 fn nothink_maps_to_enable_thinking_false_tail_and_degrades_gracefully() {
1477 let turns = vec![Turn {
1478 role: "user".into(),
1479 content: "hi".into(),
1480 tool_calls: Vec::new(),
1481 ..Default::default()
1482 }];
1483 let s = apply_chat_template_tools(
1485 Some(QWEN_TOOLS_TMPL),
1486 &turns,
1487 true,
1488 &[],
1489 ThinkMode::NoThink,
1490 None,
1491 )
1492 .unwrap();
1493 assert!(
1494 s.ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"),
1495 "{s:?}"
1496 );
1497 let tmpl_no_switch = "... add_generation_prompt ... '<think>\\n' ...";
1499 let s = apply_chat_template_tools(
1500 Some(tmpl_no_switch),
1501 &turns,
1502 true,
1503 &[],
1504 ThinkMode::NoThink,
1505 None,
1506 )
1507 .unwrap();
1508 assert!(s.ends_with("<|im_start|>assistant\n<think>\n"), "{s:?}");
1509 let s =
1511 apply_chat_template_tools(None, &turns, true, &[], ThinkMode::NoThink, None).unwrap();
1512 assert!(s.ends_with("<|im_start|>assistant\n"), "{s:?}");
1513 }
1514
1515 #[test]
1516 fn tools_on_templates_without_tools_branch_error() {
1517 let turns = vec![Turn {
1518 role: "user".into(),
1519 content: "hi".into(),
1520 tool_calls: Vec::new(),
1521 ..Default::default()
1522 }];
1523 let tools = vec!["{}".to_string()];
1524 for tmpl in [None, Some("... hy_User ..."), Some("... <|turn> ...")] {
1525 let err =
1526 apply_chat_template_tools(tmpl, &turns, true, &tools, ThinkMode::Default, None);
1527 assert!(err.is_err(), "template={tmpl:?}");
1528 }
1529 let tool_turns = vec![Turn {
1531 role: "tool".into(),
1532 content: "r".into(),
1533 tool_calls: Vec::new(),
1534 ..Default::default()
1535 }];
1536 assert!(
1537 apply_chat_template_tools(None, &tool_turns, true, &[], ThinkMode::Default, None)
1538 .is_err()
1539 );
1540 }
1541
1542 fn one_user() -> Vec<Turn> {
1549 vec![turn("user", "Hi")]
1550 }
1551
1552 #[test]
1553 fn gemma4_thinking_maps_to_the_think_token_and_open_turn() {
1554 let g = |think: ThinkMode| {
1555 apply_chat_template_tools(Some("... <|turn> ..."), &one_user(), true, &[], think, None)
1556 .unwrap()
1557 };
1558 let closed = "<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>";
1561 assert_eq!(g(ThinkMode::Default), closed);
1562 assert_eq!(g(ThinkMode::NoThink), closed);
1563 assert_eq!(
1564 apply_chat_template_str(Some("... <|turn> ..."), &[("user", "Hi")], true),
1565 closed,
1566 "legacy renderer = the default arm"
1567 );
1568 assert_eq!(
1571 g(ThinkMode::Think),
1572 "<|turn>system\n<|think|>\n<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n"
1573 );
1574 let turns = vec![turn("system", "Be terse."), turn("user", "Hi")];
1576 let s = apply_chat_template_tools(
1577 Some("... <|turn> ..."),
1578 &turns,
1579 true,
1580 &[],
1581 ThinkMode::Think,
1582 None,
1583 )
1584 .unwrap();
1585 assert_eq!(
1586 s,
1587 "<|turn>system\n<|think|>\nBe terse.<turn|>\n\
1588 <|turn>user\nHi<turn|>\n<|turn>model\n"
1589 );
1590 }
1591
1592 const GEMMA_TOOLUSE_QAT_TMPL: &str =
1597 "... <|turn> ... <|tool> ... <|channel>thought\\n<channel|> ...";
1598
1599 #[test]
1600 fn gemma4_tools_arm_is_byte_identical_to_legacy_on_toolless_requests() {
1601 let batteries: &[&[(&str, &str)]] = &[
1605 &[("user", "Hi")],
1606 &[("system", "Be terse."), ("user", "Weather?")],
1607 &[
1608 ("system", "rules"),
1609 ("user", "task"),
1610 ("assistant", "work"),
1611 ("user", "more"),
1612 ],
1613 &[("user", " padded "), ("assistant", "reply\nwith lines")],
1614 ];
1615 for msgs in batteries {
1616 let turns: Vec<Turn> = msgs
1617 .iter()
1618 .map(|(r, c)| Turn {
1619 role: r.to_string(),
1620 content: c.to_string(),
1621 ..Default::default()
1622 })
1623 .collect();
1624 for (mode, thinking) in [
1625 (ThinkMode::Default, false),
1626 (ThinkMode::NoThink, false),
1627 (ThinkMode::Think, true),
1628 ] {
1629 let legacy = apply_gemma4_template(msgs, true, thinking);
1630 let arm = apply_chat_template_tools(
1631 Some(GEMMA_TOOLUSE_QAT_TMPL),
1632 &turns,
1633 true,
1634 &[],
1635 mode,
1636 None,
1637 )
1638 .unwrap();
1639 assert_eq!(legacy, arm, "mode={mode:?} msgs={msgs:?}");
1640 }
1641 }
1642 }
1643
1644 #[test]
1645 fn gemma4_tools_arm_still_rejects_tools_without_the_tool_marker() {
1646 let turns = vec![turn("user", "Weather?")];
1649 let tools = vec![r#"{"function":{"name":"f"}}"#.to_string()];
1650 let err = apply_chat_template_tools(
1651 Some("... <|turn> ..."),
1652 &turns,
1653 true,
1654 &tools,
1655 ThinkMode::Default,
1656 None,
1657 );
1658 assert!(err.is_err());
1659 }
1660
1661 #[test]
1662 fn hy3_thinking_maps_to_its_reasoning_effort_levels() {
1663 const HY_TMPL: Option<&str> = Some("... hy_User ...");
1664 let h = |think: ThinkMode, effort: Option<&str>| {
1665 apply_chat_template_tools(HY_TMPL, &one_user(), true, &[], think, effort).unwrap()
1666 };
1667 let closed = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
1670 <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:no_think\
1671 <\u{ff5c}hy_User:opensource\u{ff5c}>Hi\
1672 <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
1673 <think:opensource></think:opensource>";
1674 assert_eq!(h(ThinkMode::Default, None), closed);
1675 assert_eq!(
1676 h(ThinkMode::NoThink, Some("low")),
1677 closed,
1678 "NoThink wins over a level: thinking off IS no_think"
1679 );
1680 assert_eq!(
1681 apply_chat_template_str(HY_TMPL, &[("user", "Hi")], true),
1682 closed,
1683 "legacy renderer = the default arm"
1684 );
1685 let low = h(ThinkMode::Think, Some("low"));
1688 assert!(low.contains("reasoning_effort:low"), "{low:?}");
1689 assert!(low.ends_with("<think:opensource>"), "{low:?}");
1690 let high = h(ThinkMode::Think, Some("high"));
1691 assert!(high.contains("reasoning_effort:high"), "{high:?}");
1692 assert!(high.ends_with("<think:opensource>"), "{high:?}");
1693 assert_eq!(h(ThinkMode::Think, Some("medium")), low);
1696 assert_eq!(h(ThinkMode::Think, None), low);
1697 let turns = vec![
1700 turn("user", "q"),
1701 turn("assistant", "a"),
1702 turn("user", "more"),
1703 ];
1704 let s =
1705 apply_chat_template_tools(HY_TMPL, &turns, true, &[], ThinkMode::Think, Some("low"))
1706 .unwrap();
1707 assert_eq!(
1708 s,
1709 "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
1710 <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:low\
1711 <\u{ff5c}hy_User:opensource\u{ff5c}>q\
1712 <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
1713 <think:opensource></think:opensource>a\
1714 <\u{ff5c}hy_eos:opensource\u{ff5c}>\
1715 <\u{ff5c}hy_User:opensource\u{ff5c}>more\
1716 <\u{ff5c}hy_Assistant:opensource\u{ff5c}><think:opensource>"
1717 );
1718 }
1719
1720 #[test]
1721 fn qwen_think_mode_covers_all_three_directions() {
1722 let q = |think: ThinkMode| {
1723 apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &one_user(), true, &[], think, None)
1724 .unwrap()
1725 };
1726 assert!(q(ThinkMode::Default).ends_with("<|im_start|>assistant\n<think>\n"));
1728 assert_eq!(q(ThinkMode::Think), q(ThinkMode::Default));
1729 assert!(q(ThinkMode::NoThink).ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
1730 }
1731
1732 const STEP35_TMPL: &str = "{% macro render_message_content(message) %}... <tools> ... add_generation_prompt ... '<think>\\n' ...";
1744
1745 fn s35(msgs: &[(&str, &str)], genp: bool) -> String {
1746 apply_chat_template_str(Some(STEP35_TMPL), msgs, genp)
1747 }
1748
1749 fn s35_turns(turns: Vec<Turn>, genp: bool, tools: &[String]) -> String {
1750 apply_chat_template_tools(
1751 Some(STEP35_TMPL),
1752 &turns,
1753 genp,
1754 tools,
1755 ThinkMode::Default,
1756 None,
1757 )
1758 .unwrap()
1759 }
1760
1761 fn turn(role: &str, content: &str) -> Turn {
1762 Turn {
1763 role: role.into(),
1764 content: content.into(),
1765 tool_calls: Vec::new(),
1766 ..Default::default()
1767 }
1768 }
1769
1770 #[test]
1771 fn step35_plain_paths_match_the_shipped_jinja() {
1772 assert_eq!(
1773 s35(&[("user", "Hello")], true),
1774 "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think>\n"
1775 );
1776 assert_eq!(
1777 s35(&[("user", "Hello")], false),
1778 "<|im_start|>user\nHello<|im_end|>\n"
1779 );
1780 assert_eq!(
1781 s35(&[("system", "You are helpful."), ("user", "Hi")], true),
1782 "<|im_start|>system\nYou are helpful.<|im_end|>\n\
1783 <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
1784 );
1785 assert_eq!(
1788 s35(
1789 &[
1790 ("system", "rules"),
1791 ("user", "task"),
1792 ("assistant", "work"),
1793 ("user", "more")
1794 ],
1795 true
1796 ),
1797 "<|im_start|>system\nrules<|im_end|>\n<|im_start|>user\ntask<|im_end|>\n\
1798 <|im_start|>assistant\nwork<|im_end|>\n<|im_start|>user\nmore<|im_end|>\n\
1799 <|im_start|>assistant\n<think>\n"
1800 );
1801 assert_eq!(
1803 s35(&[("user", " padded ")], true),
1804 "<|im_start|>user\n padded <|im_end|>\n<|im_start|>assistant\n<think>\n"
1805 );
1806 }
1807
1808 #[test]
1809 fn step35_dispatch_beats_the_qwen_marker_arm() {
1810 let qwen = apply_chat_template_str(Some(QWEN_TOOLS_TMPL), &[("user", " pad ")], true);
1814 let step = s35(&[("user", " pad ")], true);
1815 assert_eq!(
1816 qwen,
1817 "<|im_start|>user\npad<|im_end|>\n<|im_start|>assistant\n<think>\n"
1818 );
1819 assert_eq!(
1820 step,
1821 "<|im_start|>user\n pad <|im_end|>\n<|im_start|>assistant\n<think>\n"
1822 );
1823 assert_ne!(qwen, step);
1824 }
1825
1826 #[test]
1827 fn step35_reasoning_effort_renders_in_the_system_turn() {
1828 assert_eq!(
1829 apply_step35_template(&[turn("user", "Hi")], true, &[], Some("high")),
1830 "<|im_start|>system\nReasoning: high\n\n<|im_end|>\n\
1831 <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
1832 );
1833 assert_eq!(
1834 apply_step35_template(
1835 &[turn("system", "Be terse."), turn("user", "Hi")],
1836 true,
1837 &[],
1838 Some("low")
1839 ),
1840 "<|im_start|>system\nReasoning: low\n\nBe terse.<|im_end|>\n\
1841 <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
1842 );
1843 let tools = vec![r#"{"type": "function", "function": {"name": "f"}}"#.to_string()];
1845 let s = apply_step35_template(
1846 &[turn("system", "Be terse."), turn("user", "q")],
1847 true,
1848 &tools,
1849 Some("medium"),
1850 );
1851 assert!(
1852 s.starts_with("<|im_start|>system\nReasoning: medium\n\nBe terse.\n\n# Tools\n"),
1853 "{s:?}"
1854 );
1855 }
1856
1857 #[test]
1858 fn reasoning_effort_reaches_step35_through_the_public_entry_and_only_step35() {
1859 let turns = vec![turn("user", "Hi")];
1862 let s = apply_chat_template_tools(
1863 Some(STEP35_TMPL),
1864 &turns,
1865 true,
1866 &[],
1867 ThinkMode::Default,
1868 Some("high"),
1869 )
1870 .unwrap();
1871 assert!(
1872 s.starts_with("<|im_start|>system\nReasoning: high\n\n<|im_end|>\n"),
1873 "{s:?}"
1874 );
1875 let s = apply_chat_template_tools(
1877 Some(STEP35_TMPL),
1878 &turns,
1879 true,
1880 &[],
1881 ThinkMode::Default,
1882 None,
1883 )
1884 .unwrap();
1885 assert!(!s.contains("Reasoning:"), "{s:?}");
1886 for tmpl in [
1889 None,
1890 Some(QWEN_TOOLS_TMPL),
1891 Some("... hy_User ..."),
1892 Some("... <|turn> ..."),
1893 ] {
1894 let with = apply_chat_template_tools(
1895 tmpl,
1896 &turns,
1897 true,
1898 &[],
1899 ThinkMode::Default,
1900 Some("high"),
1901 )
1902 .unwrap();
1903 let without =
1904 apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
1905 .unwrap();
1906 assert_eq!(with, without, "template={tmpl:?}");
1907 }
1908 }
1909
1910 #[test]
1911 fn step35_tools_header_is_not_the_qwen_header() {
1912 let tools = vec![
1913 r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string(),
1914 r#"{"type": "function", "function": {"name": "search"}}"#.to_string(),
1915 ];
1916 let s = s35_turns(
1917 vec![
1918 turn("system", "Be terse."),
1919 turn("user", "Weather in Paris?"),
1920 ],
1921 true,
1922 &tools,
1923 );
1924 assert_eq!(
1925 s,
1926 concat!(
1927 "<|im_start|>system\nBe terse.\n\n# Tools\n\n",
1930 "You have access to the following functions in JSONSchema format:\n\n<tools>\n",
1931 "{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n",
1932 "{\"type\": \"function\", \"function\": {\"name\": \"search\"}}\n</tools>",
1933 "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
1934 "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
1935 "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
1936 "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
1937 "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
1940 "<function=...>\n...\n</function> block must be nested within <tool_call>\n...\n",
1941 "</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>",
1942 "<|im_end|>\n",
1943 "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
1944 "<|im_start|>assistant\n<think>\n",
1945 )
1946 );
1947 assert!(!s.contains(QWEN_TOOLS_INSTRUCTION));
1949 }
1950
1951 #[test]
1952 fn step35_tool_results_take_their_own_role_and_group() {
1953 let tools =
1954 vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
1955 let turns = vec![
1956 turn("user", "both"),
1957 Turn {
1958 role: "assistant".into(),
1959 content: "checking".into(),
1960 tool_calls: vec![
1961 ToolCall {
1962 name: "a".into(),
1963 params: vec![("x".into(), "1".into())],
1964 ..Default::default()
1965 },
1966 ToolCall {
1967 name: "b".into(),
1968 params: Vec::new(),
1969 ..Default::default()
1970 },
1971 ],
1972 ..Default::default()
1973 },
1974 turn("tool", "r1"),
1975 turn("tool", "r2"),
1976 ];
1977 let s = s35_turns(turns, true, &tools);
1978 let body = s
1979 .split("<|im_end|>\n")
1980 .skip(1)
1981 .collect::<Vec<_>>()
1982 .join("<|im_end|>\n");
1983 assert_eq!(
1984 body,
1985 concat!(
1986 "<|im_start|>user\nboth<|im_end|>\n",
1987 "<|im_start|>assistant\n<think>\n\n</think>\nchecking",
1990 "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>",
1992 "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
1993 "<|im_start|>tool_response\n<tool_response>r1</tool_response>",
1995 "<tool_response>r2</tool_response><|im_end|>\n",
1996 "<|im_start|>assistant\n<think>\n",
1997 )
1998 );
1999 }
2000
2001 #[test]
2002 fn step35_assistant_think_split_and_the_reasoning_boundary() {
2003 assert_eq!(
2005 s35(
2006 &[
2007 ("user", "q"),
2008 ("assistant", "<think>\nreasoned\n</think>\nanswer")
2009 ],
2010 false
2011 ),
2012 "<|im_start|>user\nq<|im_end|>\n\
2013 <|im_start|>assistant\n<think>\nreasoned\n</think>\nanswer<|im_end|>\n"
2014 );
2015 assert_eq!(
2017 s35(&[("user", "q"), ("assistant", "plain")], false),
2018 "<|im_start|>user\nq<|im_end|>\n\
2019 <|im_start|>assistant\n<think>\n\n</think>\nplain<|im_end|>\n"
2020 );
2021 assert_eq!(
2024 s35(
2025 &[
2026 ("user", "real question"),
2027 ("assistant", "thinking about it"),
2028 ("user", "<tool_response>r</tool_response>")
2029 ],
2030 true
2031 ),
2032 "<|im_start|>user\nreal question<|im_end|>\n\
2033 <|im_start|>assistant\n<think>\n\n</think>\nthinking about it<|im_end|>\n\
2034 <|im_start|>user\n<tool_response>r</tool_response><|im_end|>\n\
2035 <|im_start|>assistant\n<think>\n"
2036 );
2037 }
2038
2039 #[test]
2040 fn step35_think_tail_is_unconditional_and_nothink_is_a_noop() {
2041 let turns = vec![turn("user", "hi")];
2045 for mode in [ThinkMode::Default, ThinkMode::NoThink] {
2046 let s = apply_chat_template_tools(Some(STEP35_TMPL), &turns, true, &[], mode, None)
2047 .unwrap();
2048 assert!(
2049 s.ends_with("<|im_start|>assistant\n<think>\n"),
2050 "mode={mode:?} {s:?}"
2051 );
2052 }
2053 }
2054
2055 #[test]
2056 fn step35_plain_path_is_identical_through_both_renderers() {
2057 let batteries: &[&[(&str, &str)]] = &[
2060 &[("user", "Hello")],
2061 &[("system", "You are helpful."), ("user", "Hi")],
2062 &[
2063 ("system", "rules"),
2064 ("user", "task"),
2065 ("assistant", "work"),
2066 ("user", "more"),
2067 ],
2068 &[("user", " padded "), ("assistant", "reply\nwith lines")],
2069 ];
2070 for msgs in batteries {
2071 let legacy = s35(msgs, true);
2072 let ext = s35_turns(msgs.iter().map(|(r, c)| turn(r, c)).collect(), true, &[]);
2073 assert_eq!(legacy, ext, "msgs={msgs:?}");
2074 }
2075 }
2076
2077 #[test]
2078 fn qwen_think_tail() {
2079 let tmpl = "... add_generation_prompt ... '<think>\\n' ...";
2081 let s = apply_chat_template_str(
2082 Some(tmpl),
2083 &[("system", "You are helpful."), ("user", "Hi")],
2084 true,
2085 );
2086 assert_eq!(
2087 s,
2088 "<|im_start|>system\nYou are helpful.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
2089 );
2090 }
2091}