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 pub task: Option<String>,
82 pub tools: Vec<Val>,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum ThinkMode {
107 Default,
108 NoThink,
109 Think,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum Dsv4Encoding {
139 Preview,
141 V0731,
143}
144
145pub fn apply_chat_template_enc(
160 template: Option<&str>,
161 messages: &[(&str, &str)],
162 add_generation_prompt: bool,
163 dsv4_encoding: Option<Dsv4Encoding>,
164) -> Result<String, String> {
165 if dsv4_encoding.is_some() && !template.is_some_and(template_is_dsv4) {
166 let msgs: Vec<Turn> = messages
167 .iter()
168 .map(|(r, c)| Turn {
169 role: r.to_string(),
170 content: c.to_string(),
171 ..Default::default()
172 })
173 .collect();
174 return apply_dsv4_template(
175 &msgs,
176 add_generation_prompt,
177 &[],
178 ThinkMode::Default,
179 None,
180 dsv4_encoding,
181 );
182 }
183 Ok(apply_chat_template_str(
184 template,
185 messages,
186 add_generation_prompt,
187 ))
188}
189
190pub fn apply_chat_template_str(
191 template: Option<&str>,
192 messages: &[(&str, &str)],
193 add_generation_prompt: bool,
194) -> String {
195 if template.is_some_and(|t| t.contains("hy_User")) {
199 return apply_hy3_template(messages, add_generation_prompt, "no_think");
200 }
201 if template.is_some_and(|t| t.contains("render_message_content")) {
207 let turns: Vec<Turn> = messages
208 .iter()
209 .map(|(r, c)| Turn {
210 role: r.to_string(),
211 content: c.to_string(),
212 tool_calls: Vec::new(),
213 ..Default::default()
214 })
215 .collect();
216 return apply_step35_template(&turns, add_generation_prompt, &[], None);
217 }
218 if template.is_some_and(template_is_dsv4) {
225 let msgs: Vec<Turn> = messages
226 .iter()
227 .map(|(r, c)| Turn {
228 role: r.to_string(),
229 content: c.to_string(),
230 ..Default::default()
231 })
232 .collect();
233 return apply_dsv4_template(
234 &msgs,
235 add_generation_prompt,
236 &[],
237 ThinkMode::Default,
238 None,
239 None,
240 )
241 .expect("dsv4 render without reasoning_effort is encoding-independent");
242 }
243 if template.is_some_and(|t| t.contains("<|turn>")) {
249 return apply_gemma4_template(messages, add_generation_prompt, false);
250 }
251 let qwen_think = template
253 .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
254 .unwrap_or(false);
255
256 let mut out = String::new();
257 for (i, (role, content)) in messages.iter().enumerate() {
258 let content = content.trim();
259 match *role {
260 "system" => {
261 let _ = i;
264 out.push_str("<|im_start|>system\n");
265 out.push_str(content);
266 out.push_str("<|im_end|>\n");
267 }
268 "user" => {
269 out.push_str("<|im_start|>user\n");
270 out.push_str(content);
271 out.push_str("<|im_end|>\n");
272 }
273 "assistant" => {
274 out.push_str("<|im_start|>assistant\n");
275 out.push_str(content);
276 out.push_str("<|im_end|>\n");
277 }
278 other => {
279 out.push_str("<|im_start|>");
281 out.push_str(other);
282 out.push('\n');
283 out.push_str(content);
284 out.push_str("<|im_end|>\n");
285 }
286 }
287 }
288
289 if add_generation_prompt {
290 out.push_str("<|im_start|>assistant\n");
291 if qwen_think {
292 out.push_str("<think>\n");
293 }
294 }
295
296 out
297}
298
299const QWEN_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
303following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
304<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
305This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
306</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
307format: an inner <function=...></function> block must be nested within <tool_call></tool_call> \
308XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for \
309your function call in natural language BEFORE the function call, but NOT after\n- If there is \
310no function call available, answer the question like normal with your current knowledge and do \
311not tell the user about function calls\n</IMPORTANT>";
312
313pub fn apply_chat_template_tools(
345 template: Option<&str>,
346 turns: &[Turn],
347 add_generation_prompt: bool,
348 tools_json: &[String],
349 think: ThinkMode,
350 reasoning_effort: Option<&str>,
351) -> Result<String, String> {
352 apply_chat_template_tools_ex(
357 template,
358 turns,
359 add_generation_prompt,
360 tools_json,
361 &[],
362 think,
363 reasoning_effort,
364 None,
365 )
366}
367
368#[allow(clippy::too_many_arguments)]
373pub fn apply_chat_template_tools_ex(
374 template: Option<&str>,
375 turns: &[Turn],
376 add_generation_prompt: bool,
377 tools_json: &[String],
378 tools_struct: &[Val],
379 think: ThinkMode,
380 reasoning_effort: Option<&str>,
381 dsv4_encoding: Option<Dsv4Encoding>,
382) -> Result<String, String> {
383 let has_tool_features = !tools_json.is_empty()
384 || turns
385 .iter()
386 .any(|t| t.role == "tool" || !t.tool_calls.is_empty());
387 let is_dsv4 = dsv4_encoding.is_some() || template.is_some_and(template_is_dsv4);
390 let tools_branch = is_dsv4 || template.is_some_and(template_has_tools_branch);
394 if has_tool_features && !tools_branch {
395 return Err("model chat template has no tools branch".into());
396 }
397 if is_dsv4 {
404 return apply_dsv4_template(
405 turns,
406 add_generation_prompt,
407 tools_struct,
408 think,
409 reasoning_effort,
410 dsv4_encoding,
411 );
412 }
413 if template.is_some_and(|t| t.contains("render_message_content")) {
420 return Ok(apply_step35_template(
421 turns,
422 add_generation_prompt,
423 tools_json,
424 reasoning_effort,
425 ));
426 }
427 if template.is_some_and(|t| t.contains("<|turn>") && t.contains("<|tool>")) {
435 let closed_tail = template.is_some_and(|t| t.contains("<|channel>thought\\n<channel|>"));
440 return Ok(apply_gemma4_tools_template(
441 turns,
442 add_generation_prompt,
443 tools_struct,
444 think == ThinkMode::Think,
445 closed_tail,
446 ));
447 }
448 if template.is_some_and(|t| t.contains("hy_User") || t.contains("<|turn>")) {
449 if has_tool_features {
458 return Err("tools are not supported on this model's chat-template dialect".into());
459 }
460 let messages: Vec<(&str, &str)> = turns
461 .iter()
462 .map(|t| (t.role.as_str(), t.content.as_str()))
463 .collect();
464 if template.is_some_and(|t| t.contains("hy_User")) {
465 let effort = match (think, reasoning_effort) {
468 (ThinkMode::Think, Some("high")) => "high",
469 (ThinkMode::Think, _) => "low",
470 _ => "no_think",
471 };
472 return Ok(apply_hy3_template(&messages, add_generation_prompt, effort));
473 }
474 return Ok(apply_gemma4_template(
475 &messages,
476 add_generation_prompt,
477 think == ThinkMode::Think,
478 ));
479 }
480 let qwen_think = template
481 .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
482 .unwrap_or(false);
483 let think_switch = template.is_some_and(|t| t.contains("enable_thinking"));
484
485 let mut out = String::new();
486 let mut skip_leading_system = false;
489 if !tools_json.is_empty() {
490 out.push_str("<|im_start|>system\n");
491 out.push_str("# Tools\n\nYou have access to the following functions:\n\n<tools>");
492 for tool in tools_json {
493 out.push('\n');
494 out.push_str(tool);
495 }
496 out.push_str("\n</tools>");
497 out.push_str(QWEN_TOOLS_INSTRUCTION);
498 if let Some(first) = turns.first() {
499 if first.role == "system" {
500 skip_leading_system = true;
501 let content = first.content.trim();
502 if !content.is_empty() {
503 out.push_str("\n\n");
504 out.push_str(content);
505 }
506 }
507 }
508 out.push_str("<|im_end|>\n");
509 }
510
511 for (i, turn) in turns.iter().enumerate() {
512 if i == 0 && skip_leading_system {
513 continue;
514 }
515 let content = turn.content.trim();
516 match turn.role.as_str() {
517 "system" => {
518 out.push_str("<|im_start|>system\n");
519 out.push_str(content);
520 out.push_str("<|im_end|>\n");
521 }
522 "user" => {
523 out.push_str("<|im_start|>user\n");
524 out.push_str(content);
525 out.push_str("<|im_end|>\n");
526 }
527 "assistant" => {
528 out.push_str("<|im_start|>assistant\n");
529 out.push_str(content);
530 for (k, call) in turn.tool_calls.iter().enumerate() {
531 if k == 0 {
532 if !content.is_empty() {
533 out.push_str("\n\n");
534 }
535 } else {
536 out.push('\n');
537 }
538 out.push_str("<tool_call>\n<function=");
539 out.push_str(&call.name);
540 out.push_str(">\n");
541 for (key, value) in &call.params {
542 out.push_str("<parameter=");
543 out.push_str(key);
544 out.push_str(">\n");
545 out.push_str(value);
546 out.push_str("\n</parameter>\n");
547 }
548 out.push_str("</function>\n</tool_call>");
549 }
550 out.push_str("<|im_end|>\n");
551 }
552 "tool" => {
553 if i == 0 || turns[i - 1].role != "tool" {
554 out.push_str("<|im_start|>user");
555 }
556 out.push_str("\n<tool_response>\n");
557 out.push_str(content);
558 out.push_str("\n</tool_response>");
559 if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
560 out.push_str("<|im_end|>\n");
561 }
562 }
563 other => {
564 out.push_str("<|im_start|>");
566 out.push_str(other);
567 out.push('\n');
568 out.push_str(content);
569 out.push_str("<|im_end|>\n");
570 }
571 }
572 }
573
574 if add_generation_prompt {
575 out.push_str("<|im_start|>assistant\n");
576 if qwen_think {
577 if think == ThinkMode::NoThink && think_switch {
578 out.push_str("<think>\n\n</think>\n\n");
579 } else {
580 out.push_str("<think>\n");
581 }
582 }
583 }
584 Ok(out)
585}
586
587const STEP35_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
596following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
597<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
598This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
599</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
600format: an inner <function=...>\n...\n</function> block must be nested within <tool_call>\n\
601...\n</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>";
602
603fn apply_step35_template(
648 turns: &[Turn],
649 add_generation_prompt: bool,
650 tools_json: &[String],
651 reasoning_effort: Option<&str>,
652) -> String {
653 let mut out = String::new();
654 let leading_system = turns.first().filter(|t| t.role == "system");
655
656 if !tools_json.is_empty() {
658 out.push_str("<|im_start|>system\n");
659 if let Some(effort) = reasoning_effort {
660 out.push_str("Reasoning: ");
661 out.push_str(effort);
662 out.push_str("\n\n");
663 }
664 if let Some(sys) = leading_system {
665 out.push_str(&sys.content);
667 out.push_str("\n\n");
668 }
669 out.push_str(
670 "# Tools\n\nYou have access to the following functions in JSONSchema \
671 format:\n\n<tools>",
672 );
673 for tool in tools_json {
674 out.push('\n');
675 out.push_str(tool);
676 }
677 out.push_str("\n</tools>");
678 out.push_str(STEP35_TOOLS_INSTRUCTION);
679 out.push_str("<|im_end|>\n");
680 } else if let Some(sys) = leading_system {
681 out.push_str("<|im_start|>system\n");
682 if let Some(effort) = reasoning_effort {
683 out.push_str("Reasoning: ");
684 out.push_str(effort);
685 out.push_str("\n\n");
686 }
687 out.push_str(&sys.content);
688 out.push_str("<|im_end|>\n");
689 } else if let Some(effort) = reasoning_effort {
690 out.push_str("<|im_start|>system\nReasoning: ");
691 out.push_str(effort);
692 out.push_str("\n\n<|im_end|>\n");
693 }
694
695 let last_query_index = turns
700 .iter()
701 .enumerate()
702 .rev()
703 .find(|(_, t)| {
704 t.role == "user"
705 && !(t.content.starts_with("<tool_response>")
706 && t.content.ends_with("</tool_response>"))
707 })
708 .map(|(i, _)| i)
709 .unwrap_or(turns.len().saturating_sub(1));
710
711 for (i, turn) in turns.iter().enumerate() {
712 let content = &turn.content; match turn.role.as_str() {
714 "system" if i == 0 => {}
716 "system" | "user" => {
717 out.push_str("<|im_start|>");
718 out.push_str(&turn.role);
719 out.push('\n');
720 out.push_str(content);
721 out.push_str("<|im_end|>\n");
722 }
723 "assistant" => {
724 let (reasoning, body): (String, &str) = match content.find("</think>") {
730 Some(first) => {
731 let pre = content[..first].trim_end_matches('\n');
732 let pre = match pre.rfind("<think>") {
733 Some(o) => &pre[o + "<think>".len()..],
734 None => pre,
735 };
736 let last = content.rfind("</think>").unwrap();
737 (
738 pre.trim_start_matches('\n').to_string(),
739 content[last + "</think>".len()..].trim_start_matches('\n'),
740 )
741 }
742 None => (String::new(), content.as_str()),
743 };
744 out.push_str("<|im_start|>assistant\n");
745 if i > last_query_index {
746 out.push_str("<think>\n");
747 out.push_str(&reasoning);
748 out.push_str("\n</think>\n");
749 }
750 out.push_str(body);
751 for call in &turn.tool_calls {
753 out.push_str("<tool_call>\n<function=");
754 out.push_str(&call.name);
755 out.push_str(">\n");
756 for (key, value) in &call.params {
757 out.push_str("<parameter=");
758 out.push_str(key);
759 out.push_str(">\n");
760 out.push_str(value);
761 out.push_str("\n</parameter>\n");
762 }
763 out.push_str("</function>\n</tool_call>");
764 }
765 out.push_str("<|im_end|>\n");
766 }
767 "tool" => {
768 if i == 0 || turns[i - 1].role != "tool" {
770 out.push_str("<|im_start|>tool_response\n");
771 }
772 out.push_str("<tool_response>");
773 out.push_str(content);
774 out.push_str("</tool_response>");
775 if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
776 out.push_str("<|im_end|>\n");
777 }
778 }
779 other => {
780 out.push_str("<|im_start|>");
782 out.push_str(other);
783 out.push('\n');
784 out.push_str(content);
785 out.push_str("<|im_end|>\n");
786 }
787 }
788 }
789
790 if add_generation_prompt {
791 out.push_str("<|im_start|>assistant\n<think>\n");
792 }
793 out
794}
795
796fn apply_hy3_template(
812 messages: &[(&str, &str)],
813 add_generation_prompt: bool,
814 effort: &str,
815) -> String {
816 const BOS: &str = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>";
817 const USER: &str = "<\u{ff5c}hy_User:opensource\u{ff5c}>";
818 const ASSISTANT: &str = "<\u{ff5c}hy_Assistant:opensource\u{ff5c}>";
819 const EOS: &str = "<\u{ff5c}hy_eos:opensource\u{ff5c}>";
820 const REASONING: &str = "<\u{ff5c}reasoning_mode:opensource\u{ff5c}>";
821 const THINK_BEGIN: &str = "<think:opensource>";
822 const THINK_END: &str = "</think:opensource>";
823
824 debug_assert!(
825 matches!(effort, "no_think" | "low" | "high"),
826 "hy3 reasoning_effort must be no_think|low|high, got {effort:?}"
827 );
828 let mut out = String::from(BOS);
829 for (role, content) in messages.iter().filter(|(r, _)| *r == "system") {
830 let _ = role;
831 out.push_str(content);
832 }
833 out.push_str(REASONING);
834 out.push_str("reasoning_effort:");
835 out.push_str(effort);
836
837 let mut last_is_assistant = false;
838 let n = messages.len();
839 for (i, (role, content)) in messages.iter().enumerate() {
840 last_is_assistant = false;
841 match *role {
842 "user" => {
843 out.push_str(USER);
844 out.push_str(content);
845 }
846 "assistant" => {
847 out.push_str(ASSISTANT);
848 out.push_str(THINK_BEGIN);
849 out.push_str(THINK_END);
850 out.push_str(content);
851 if i + 1 < n {
852 out.push_str(EOS);
853 } last_is_assistant = true;
855 }
856 _ => {} }
858 }
859 if add_generation_prompt && !last_is_assistant {
860 out.push_str(ASSISTANT);
861 out.push_str(THINK_BEGIN);
862 if effort == "no_think" {
863 out.push_str(THINK_END); }
865 }
866 out
867}
868
869fn apply_gemma4_template(
881 messages: &[(&str, &str)],
882 add_generation_prompt: bool,
883 thinking: bool,
884) -> String {
885 let mut out = String::new();
886 let mut msgs = messages;
887 let leading_system = msgs.first().filter(|(r, _)| *r == "system");
889 if thinking || leading_system.is_some() {
890 out.push_str("<|turn>system\n");
891 if thinking {
892 out.push_str("<|think|>\n");
893 }
894 if let Some((_, content)) = leading_system {
895 out.push_str(content.trim());
896 msgs = &msgs[1..];
897 }
898 out.push_str("<turn|>\n");
899 }
900 for (role, content) in msgs {
901 let role = if *role == "assistant" { "model" } else { role };
902 out.push_str("<|turn>");
903 out.push_str(role);
904 out.push('\n');
905 out.push_str(content.trim());
906 out.push_str("<turn|>\n");
907 }
908 if add_generation_prompt {
909 out.push_str("<|turn>model\n");
910 if !thinking {
911 out.push_str("<|channel>thought\n<channel|>");
912 }
913 }
914 out
915}
916
917pub fn template_has_tools_branch(t: &str) -> bool {
921 if t.contains("hy_User") {
922 return false;
923 }
924 template_is_dsv4(t) || t.contains("<tools>") || (t.contains("<|turn>") && t.contains("<|tool>"))
925}
926
927pub fn template_is_dsv4(t: &str) -> bool {
932 t.contains("<\u{ff5c}Assistant\u{ff5c}>") && t.contains("\u{ff5c}DSML\u{ff5c}")
933}
934
935fn dictsort(pairs: &[(String, Val)]) -> Vec<&(String, Val)> {
946 let mut v: Vec<&(String, Val)> = pairs.iter().collect();
947 v.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
948 v
949}
950
951fn format_argument(v: &Val, escape_keys: bool) -> String {
955 match v {
956 Val::Str(s) => format!("<|\"|>{s}<|\"|>"),
957 Val::Bool(b) => if *b { "true" } else { "false" }.to_string(),
958 Val::Obj(pairs) => {
959 let mut out = String::from("{");
960 for (i, (k, val)) in dictsort(pairs).iter().map(|p| (&p.0, &p.1)).enumerate() {
961 if i > 0 {
962 out.push(',');
963 }
964 if escape_keys {
965 out.push_str(&format!("<|\"|>{k}<|\"|>"));
966 } else {
967 out.push_str(k);
968 }
969 out.push(':');
970 out.push_str(&format_argument(val, escape_keys));
971 }
972 out.push('}');
973 out
974 }
975 Val::Arr(items) => {
976 let mut out = String::from("[");
977 for (i, item) in items.iter().enumerate() {
978 if i > 0 {
979 out.push(',');
980 }
981 out.push_str(&format_argument(item, escape_keys));
982 }
983 out.push(']');
984 out
985 }
986 Val::Null => "None".to_string(),
987 Val::Num(s) => s.clone(),
988 }
989}
990
991fn strip_thinking(text: &str) -> String {
995 let mut result = String::new();
996 for part in text.split("<channel|>") {
997 match part.find("<|channel>") {
998 Some(o) => result.push_str(&part[..o]),
999 None => result.push_str(part),
1000 }
1001 }
1002 result.trim().to_string()
1003}
1004
1005fn val_get<'a>(obj: &'a [(String, Val)], key: &str) -> Option<&'a Val> {
1006 obj.iter().find(|(k, _)| k == key).map(|(_, v)| v)
1007}
1008fn as_obj(v: &Val) -> Option<&[(String, Val)]> {
1009 match v {
1010 Val::Obj(p) => Some(p),
1011 _ => None,
1012 }
1013}
1014fn as_str(v: &Val) -> Option<&str> {
1015 match v {
1016 Val::Str(s) => Some(s),
1017 _ => None,
1018 }
1019}
1020fn truthy(v: &Val) -> bool {
1022 match v {
1023 Val::Null => false,
1024 Val::Bool(b) => *b,
1025 Val::Str(s) => !s.is_empty(),
1026 Val::Num(s) => s != "0" && s != "0.0",
1027 Val::Arr(a) => !a.is_empty(),
1028 Val::Obj(o) => !o.is_empty(),
1029 }
1030}
1031
1032fn comma(out: &mut String, add: &mut bool) {
1035 if *add {
1036 out.push(',');
1037 } else {
1038 *add = true;
1039 }
1040}
1041
1042fn format_parameters(out: &mut String, props: &[(String, Val)], filter_keys: bool) {
1045 const STANDARD: [&str; 5] = ["description", "type", "properties", "required", "nullable"];
1046 let mut found_first = false;
1047 for (key, value) in dictsort(props).iter().map(|p| (&p.0, &p.1)) {
1048 if filter_keys && STANDARD.contains(&key.as_str()) {
1049 continue;
1050 }
1051 if found_first {
1052 out.push(',');
1053 }
1054 found_first = true;
1055 out.push_str(key);
1056 out.push_str(":{");
1057 let vobj = as_obj(value);
1058 let mut add = false;
1059 if let Some(d) = vobj
1061 .and_then(|o| val_get(o, "description"))
1062 .filter(|d| truthy(d))
1063 {
1064 out.push_str("description:<|\"|>");
1065 out.push_str(as_str(d).unwrap_or(""));
1066 out.push_str("<|\"|>");
1067 add = true;
1068 }
1069 let ty_up = vobj
1070 .and_then(|o| val_get(o, "type"))
1071 .and_then(as_str)
1072 .map(|s| s.to_uppercase());
1073 match ty_up.as_deref() {
1074 Some("STRING") => {
1075 if let Some(en) = vobj.and_then(|o| val_get(o, "enum")).filter(|e| truthy(e)) {
1076 comma(out, &mut add);
1077 out.push_str("enum:");
1078 out.push_str(&format_argument(en, true));
1079 }
1080 }
1081 Some("ARRAY") => {
1082 if let Some(items) = vobj
1083 .and_then(|o| val_get(o, "items"))
1084 .filter(|it| matches!(it, Val::Obj(o) if !o.is_empty()))
1085 {
1086 comma(out, &mut add);
1087 out.push_str("items:{");
1088 format_items(out, as_obj(items).unwrap());
1089 out.push('}');
1090 }
1091 }
1092 _ => {}
1093 }
1094 if vobj
1096 .and_then(|o| val_get(o, "nullable"))
1097 .is_some_and(truthy)
1098 {
1099 comma(out, &mut add);
1100 out.push_str("nullable:true");
1101 }
1102 if ty_up.as_deref() == Some("OBJECT") {
1104 if let Some(sub) = vobj.and_then(|o| val_get(o, "properties")).and_then(as_obj) {
1105 comma(out, &mut add);
1106 out.push_str("properties:{");
1107 format_parameters(out, sub, false);
1108 out.push('}');
1109 } else if let Some(o) = vobj {
1110 comma(out, &mut add);
1113 out.push_str("properties:{");
1114 format_parameters(out, o, true);
1115 out.push('}');
1116 }
1117 if let Some(req) = vobj
1118 .and_then(|o| val_get(o, "required"))
1119 .filter(|r| truthy(r))
1120 {
1121 comma(out, &mut add);
1122 out.push_str("required:[");
1123 push_str_list(out, req);
1124 out.push(']');
1125 }
1126 }
1127 comma(out, &mut add);
1129 out.push_str("type:<|\"|>");
1130 out.push_str(ty_up.as_deref().unwrap_or(""));
1131 out.push_str("<|\"|>}");
1132 }
1133}
1134
1135fn format_items(out: &mut String, items: &[(String, Val)]) {
1138 let mut found_first = false;
1139 for (k, v) in dictsort(items).iter().map(|p| (&p.0, &p.1)) {
1140 if matches!(v, Val::Null) {
1141 continue;
1142 }
1143 if found_first {
1144 out.push(',');
1145 }
1146 found_first = true;
1147 match k.as_str() {
1148 "properties" => {
1149 out.push_str("properties:{");
1150 if let Some(o) = as_obj(v) {
1151 format_parameters(out, o, false);
1152 }
1153 out.push('}');
1154 }
1155 "required" => {
1156 out.push_str("required:[");
1157 push_str_list(out, v);
1158 out.push(']');
1159 }
1160 "type" => {
1161 out.push_str("type:");
1162 match v {
1163 Val::Str(s) => {
1164 out.push_str(&format_argument(&Val::Str(s.to_uppercase()), true))
1165 }
1166 Val::Arr(a) => {
1167 let upper: Vec<Val> = a
1168 .iter()
1169 .map(|x| Val::Str(as_str(x).unwrap_or("").to_uppercase()))
1170 .collect();
1171 out.push_str(&format_argument(&Val::Arr(upper), true));
1172 }
1173 other => out.push_str(&format_argument(other, true)),
1174 }
1175 }
1176 _ => {
1177 out.push_str(k);
1178 out.push(':');
1179 out.push_str(&format_argument(v, true));
1180 }
1181 }
1182 }
1183}
1184
1185fn push_str_list(out: &mut String, v: &Val) {
1187 if let Val::Arr(items) = v {
1188 for (i, item) in items.iter().enumerate() {
1189 if i > 0 {
1190 out.push(',');
1191 }
1192 out.push_str("<|\"|>");
1193 out.push_str(as_str(item).unwrap_or(""));
1194 out.push_str("<|\"|>");
1195 }
1196 }
1197}
1198
1199fn format_function_declaration(func: &[(String, Val)]) -> String {
1201 let mut out = String::new();
1202 out.push_str("declaration:");
1203 out.push_str(val_get(func, "name").and_then(as_str).unwrap_or(""));
1204 out.push_str("{description:<|\"|>");
1205 out.push_str(val_get(func, "description").and_then(as_str).unwrap_or(""));
1206 out.push_str("<|\"|>");
1207 if let Some(params) = val_get(func, "parameters").filter(|p| truthy(p)) {
1208 let pobj = as_obj(params);
1209 out.push_str(",parameters:{");
1210 if let Some(props) = pobj
1211 .and_then(|o| val_get(o, "properties"))
1212 .filter(|p| truthy(p))
1213 .and_then(as_obj)
1214 {
1215 out.push_str("properties:{");
1216 format_parameters(&mut out, props, false);
1217 out.push_str("},");
1218 }
1219 if let Some(req) = pobj
1220 .and_then(|o| val_get(o, "required"))
1221 .filter(|r| truthy(r))
1222 {
1223 out.push_str("required:[");
1224 push_str_list(&mut out, req);
1225 out.push_str("],");
1226 }
1227 if let Some(ty) = pobj.and_then(|o| val_get(o, "type")).filter(|t| truthy(t)) {
1228 out.push_str("type:<|\"|>");
1229 out.push_str(&as_str(ty).unwrap_or("").to_uppercase());
1230 out.push_str("<|\"|>}");
1231 }
1232 }
1233 if let Some(resp) = val_get(func, "response").and_then(as_obj) {
1234 out.push_str(",response:{");
1235 if let Some(d) = val_get(resp, "description").filter(|d| truthy(d)) {
1236 out.push_str("description:<|\"|>");
1237 out.push_str(as_str(d).unwrap_or(""));
1238 out.push_str("<|\"|>,");
1239 }
1240 if val_get(resp, "type")
1241 .and_then(as_str)
1242 .map(|s| s.to_uppercase())
1243 == Some("OBJECT".into())
1244 {
1245 out.push_str("type:<|\"|>OBJECT<|\"|>}");
1246 }
1247 }
1248 out.push('}');
1249 out
1250}
1251
1252fn format_tool_response_block(name: &str, response: &Val) -> String {
1254 let mut out = String::from("<|tool_response>");
1255 match response {
1256 Val::Obj(pairs) => {
1257 out.push_str("response:");
1258 out.push_str(name);
1259 out.push('{');
1260 for (i, (k, v)) in dictsort(pairs).iter().map(|p| (&p.0, &p.1)).enumerate() {
1261 if i > 0 {
1262 out.push(',');
1263 }
1264 out.push_str(k);
1265 out.push(':');
1266 out.push_str(&format_argument(v, false));
1267 }
1268 out.push('}');
1269 }
1270 other => {
1271 out.push_str("response:");
1272 out.push_str(name);
1273 out.push_str("{value:");
1274 out.push_str(&format_argument(other, false));
1275 out.push('}');
1276 }
1277 }
1278 out.push_str("<tool_response|>");
1279 out
1280}
1281
1282fn apply_gemma4_tools_template(
1287 turns: &[Turn],
1288 add_generation_prompt: bool,
1289 tools: &[Val],
1290 thinking: bool,
1291 closed_tail: bool,
1292) -> String {
1293 let mut out = String::new();
1294 let mut prev: Option<&str> = None;
1295 let mut msgs = turns;
1296 let is_sys = |r: &str| r == "system" || r == "developer";
1297
1298 let leading_system = msgs.first().filter(|t| is_sys(&t.role));
1299 if thinking || !tools.is_empty() || leading_system.is_some() {
1300 out.push_str("<|turn>system\n");
1301 if thinking {
1302 out.push_str("<|think|>\n");
1303 prev = Some("think");
1304 }
1305 if let Some(sys) = leading_system {
1306 out.push_str(sys.content.trim());
1307 msgs = &msgs[1..];
1308 }
1309 for tool in tools {
1310 out.push_str("<|tool>");
1311 if let Some(func) = as_obj(tool) {
1312 out.push_str(format_function_declaration(func).trim());
1313 }
1314 out.push_str("<tool|>");
1315 }
1316 if !tools.is_empty() {
1317 prev = Some("tool");
1318 }
1319 out.push_str("<turn|>\n");
1320 }
1321
1322 let last_user_idx: isize = msgs
1323 .iter()
1324 .enumerate()
1325 .rev()
1326 .find(|(_, t)| t.role == "user")
1327 .map(|(i, _)| i as isize)
1328 .unwrap_or(-1);
1329
1330 for (i, m) in msgs.iter().enumerate() {
1331 if m.role == "tool" {
1332 continue; }
1334 prev = None;
1335 let role = if m.role == "assistant" {
1336 "model"
1337 } else {
1338 m.role.as_str()
1339 };
1340 let prev_nt_role = (0..i)
1341 .rev()
1342 .map(|j| &msgs[j])
1343 .find(|t| t.role != "tool")
1344 .map(|t| t.role.as_str());
1345 let continue_same_model_turn = role == "model" && prev_nt_role == Some("assistant");
1346 if !continue_same_model_turn {
1347 out.push_str("<|turn>");
1348 out.push_str(role);
1349 out.push('\n');
1350 }
1351
1352 if let Some(rt) = m.reasoning.as_deref() {
1354 if !rt.is_empty() && (i as isize) > last_user_idx && !m.tool_calls.is_empty() {
1355 out.push_str("<|channel>thought\n");
1356 out.push_str(rt);
1357 out.push_str("\n<channel|>");
1358 }
1359 }
1360
1361 if !m.tool_calls.is_empty() {
1363 for tc in &m.tool_calls {
1364 out.push_str("<|tool_call>call:");
1365 out.push_str(&tc.name);
1366 out.push('{');
1367 for (j, (k, v)) in dictsort(&tc.args).iter().map(|p| (&p.0, &p.1)).enumerate() {
1368 if j > 0 {
1369 out.push(',');
1370 }
1371 out.push_str(k);
1372 out.push(':');
1373 out.push_str(&format_argument(v, false));
1374 }
1375 out.push_str("}<tool_call|>");
1376 }
1377 prev = Some("tool_call");
1378 }
1379
1380 let mut tr_flag = false;
1382 if !m.tool_responses.is_empty() {
1383 for (name, resp) in &m.tool_responses {
1384 out.push_str(&format_tool_response_block(name, resp));
1385 tr_flag = true;
1386 prev = Some("tool_response");
1387 }
1388 } else if !m.tool_calls.is_empty() {
1389 for k in (i + 1)..msgs.len() {
1390 let follow = &msgs[k];
1391 if follow.role != "tool" {
1392 break;
1393 }
1394 let mut name = follow
1395 .tool_name
1396 .clone()
1397 .unwrap_or_else(|| "unknown".to_string());
1398 if let Some(fid) = follow.tool_call_id.as_deref() {
1399 for tc in &m.tool_calls {
1400 if tc.id.as_deref() == Some(fid) {
1401 name = tc.name.clone();
1402 }
1403 }
1404 }
1405 out.push_str(&format_tool_response_block(
1406 &name,
1407 &Val::Str(follow.content.clone()),
1408 ));
1409 tr_flag = true;
1410 prev = Some("tool_response");
1411 }
1412 }
1413
1414 let captured = if role == "model" {
1416 strip_thinking(&m.content)
1417 } else {
1418 m.content.trim().to_string()
1419 };
1420 out.push_str(&captured);
1421 let has_content = !captured.trim().is_empty();
1422
1423 if prev == Some("tool_call") && !tr_flag {
1424 out.push_str("<|tool_response>"); } else if !(tr_flag && !has_content) {
1426 out.push_str("<turn|>\n");
1427 }
1428 }
1429
1430 if add_generation_prompt && prev != Some("tool_response") && prev != Some("tool_call") {
1431 out.push_str("<|turn>model\n");
1432 if closed_tail && !thinking {
1433 out.push_str("<|channel>thought\n<channel|>");
1434 }
1435 }
1436 out
1437}
1438
1439const DS_BOS: &str = "<\u{ff5c}begin\u{2581}of\u{2581}sentence\u{ff5c}>";
1453const DS_EOS: &str = "<\u{ff5c}end\u{2581}of\u{2581}sentence\u{ff5c}>";
1454const DS_USER: &str = "<\u{ff5c}User\u{ff5c}>";
1455const DS_ASSISTANT: &str = "<\u{ff5c}Assistant\u{ff5c}>";
1456const DS_REMINDER: &str = "<\u{ff5c}latest_reminder\u{ff5c}>";
1457const DS_THINK_START: &str = "<think>";
1458const DS_THINK_END: &str = "</think>";
1459const DS_DSML: &str = "\u{ff5c}DSML\u{ff5c}";
1460const DS_EFFORT_ABSOLUTE_MAX: &str = "Reasoning Effort: Absolute maximum with no shortcuts permitted.\nYou MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\nExplicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.\n\n";
1463const DS_EFFORT_BEYOND_MAX: &str = "Reasoning Effort: Beyond maximum \u{2014} exhaustive, relentless, and uncompromising.\nYou MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.\nDo not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.\n\n";
1466
1467fn dsv4_effort_prefix(
1477 thinking: bool,
1478 effort: Option<&str>,
1479 encoding: Option<Dsv4Encoding>,
1480) -> Result<&'static str, String> {
1481 if !thinking {
1482 return Ok("");
1485 }
1486 match effort {
1487 None | Some("low") => Ok(""),
1491 Some("high") => match encoding {
1492 Some(Dsv4Encoding::Preview) => Ok(""), Some(Dsv4Encoding::V0731) => Ok(DS_EFFORT_ABSOLUTE_MAX),
1494 None => Err(
1495 "dsv4 reasoning_effort \"high\" renders differently on the preview vs 0731 \
1496 encoding and this artifact's encoding revision is unknown (config.json \
1497 dspark_* census unavailable) — refusing rather than guessing"
1498 .into(),
1499 ),
1500 },
1501 Some("max") => match encoding {
1502 Some(Dsv4Encoding::Preview) => Ok(DS_EFFORT_ABSOLUTE_MAX),
1503 Some(Dsv4Encoding::V0731) => Ok(DS_EFFORT_BEYOND_MAX),
1504 None => Err(
1505 "dsv4 reasoning_effort \"max\" renders differently on the preview vs 0731 \
1506 encoding and this artifact's encoding revision is unknown (config.json \
1507 dspark_* census unavailable) — refusing rather than guessing"
1508 .into(),
1509 ),
1510 },
1511 Some(_) => Ok(""),
1512 }
1513}
1514
1515fn ds_task_token(task: &str) -> Option<&'static str> {
1517 match task {
1518 "action" => Some("<\u{ff5c}action\u{ff5c}>"),
1519 "query" => Some("<\u{ff5c}query\u{ff5c}>"),
1520 "authority" => Some("<\u{ff5c}authority\u{ff5c}>"),
1521 "domain" => Some("<\u{ff5c}domain\u{ff5c}>"),
1522 "title" => Some("<\u{ff5c}title\u{ff5c}>"),
1523 "read_url" => Some("<\u{ff5c}read_url\u{ff5c}>"),
1524 _ => None,
1525 }
1526}
1527
1528fn dsv4_json(v: &Val, out: &mut String) {
1532 match v {
1533 Val::Null => out.push_str("null"),
1534 Val::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
1535 Val::Num(s) => out.push_str(s),
1536 Val::Str(s) => {
1537 out.push('"');
1538 dsv4_json_escape(s, out);
1539 out.push('"');
1540 }
1541 Val::Arr(a) => {
1542 out.push('[');
1543 for (i, x) in a.iter().enumerate() {
1544 if i > 0 {
1545 out.push_str(", ");
1546 }
1547 dsv4_json(x, out);
1548 }
1549 out.push(']');
1550 }
1551 Val::Obj(o) => {
1552 out.push('{');
1553 for (i, (k, val)) in o.iter().enumerate() {
1554 if i > 0 {
1555 out.push_str(", ");
1556 }
1557 out.push('"');
1558 dsv4_json_escape(k, out);
1559 out.push_str("\": ");
1560 dsv4_json(val, out);
1561 }
1562 out.push('}');
1563 }
1564 }
1565}
1566
1567fn dsv4_json_escape(s: &str, out: &mut String) {
1571 for c in s.chars() {
1572 match c {
1573 '"' => out.push_str("\\\""),
1574 '\\' => out.push_str("\\\\"),
1575 '\n' => out.push_str("\\n"),
1576 '\r' => out.push_str("\\r"),
1577 '\t' => out.push_str("\\t"),
1578 '\u{8}' => out.push_str("\\b"),
1579 '\u{c}' => out.push_str("\\f"),
1580 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1581 c => out.push(c),
1582 }
1583 }
1584}
1585
1586fn dsv4_render_tools(funcs: &[Val]) -> String {
1590 let mut schemas = String::new();
1591 for (i, f) in funcs.iter().enumerate() {
1592 if i > 0 {
1593 schemas.push('\n');
1594 }
1595 dsv4_json(f, &mut schemas);
1596 }
1597 format!(
1598 "## Tools\n\nYou have access to a set of tools to help answer the user's question. \
1599You can invoke tools by writing a \"<{d}tool_calls>\" block like the following:\n\n\
1600<{d}tool_calls>\n<{d}invoke name=\"$TOOL_NAME\">\n\
1601<{d}parameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE</{d}parameter>\n\
1602...\n</{d}invoke>\n<{d}invoke name=\"$TOOL_NAME2\">\n...\n</{d}invoke>\n</{d}tool_calls>\n\n\
1603String parameters should be specified as is and set `string=\"true\"`. For all other types \
1604(numbers, booleans, arrays, objects), pass the value in JSON format and set `string=\"false\"`.\
1605\n\nIf thinking_mode is enabled (triggered by {ts}), you MUST output your complete reasoning \
1606inside {ts}...{te} BEFORE any tool calls or final response.\n\nOtherwise, output directly \
1607after {te} with tool calls or final response.\n\n### Available Tool Schemas\n\n{schemas}\n\n\
1608You MUST strictly follow the above defined tool name and parameter schemas to invoke tool \
1609calls.\n",
1610 d = DS_DSML,
1611 ts = DS_THINK_START,
1612 te = DS_THINK_END,
1613 schemas = schemas,
1614 )
1615}
1616
1617fn dsv4_render_tool_calls(calls: &[ToolCall]) -> String {
1622 let mut invokes = String::new();
1623 for (i, call) in calls.iter().enumerate() {
1624 if i > 0 {
1625 invokes.push('\n');
1626 }
1627 invokes.push_str(&format!(
1628 "<{d}invoke name=\"{n}\">\n",
1629 d = DS_DSML,
1630 n = call.name
1631 ));
1632 for (j, (k, v)) in call.args.iter().enumerate() {
1633 if j > 0 {
1634 invokes.push('\n');
1635 }
1636 let is_str = matches!(v, Val::Str(_));
1637 invokes.push_str(&format!(
1638 "<{d}parameter name=\"{k}\" string=\"{b}\">",
1639 d = DS_DSML,
1640 k = k,
1641 b = if is_str { "true" } else { "false" },
1642 ));
1643 match v {
1644 Val::Str(s) => invokes.push_str(s),
1645 other => dsv4_json(other, &mut invokes),
1646 }
1647 invokes.push_str(&format!("</{d}parameter>", d = DS_DSML));
1648 }
1649 invokes.push_str(&format!("\n</{d}invoke>", d = DS_DSML));
1650 }
1651 format!(
1652 "\n\n<{d}tool_calls>\n{invokes}\n</{d}tool_calls>",
1653 d = DS_DSML,
1654 invokes = invokes
1655 )
1656}
1657
1658enum DsBlock {
1660 Text(String),
1661 ToolResult {
1662 content: String,
1663 tool_use_id: String,
1664 },
1665}
1666
1667struct DsMsg {
1670 role: String,
1671 content: String,
1672 blocks: Option<Vec<DsBlock>>,
1673 reasoning: String,
1674 tool_calls: Vec<ToolCall>,
1675 tools: Vec<Val>,
1676 task: Option<String>,
1677}
1678
1679fn dsv4_merge(turns: &[Turn], req_tools: &[Val]) -> Vec<DsMsg> {
1686 let mut merged: Vec<DsMsg> = Vec::new();
1687 let any_turn_tools = turns.iter().any(|t| !t.tools.is_empty());
1688 let mut leading_tools_pending = !req_tools.is_empty() && !any_turn_tools;
1690 if leading_tools_pending && !turns.first().map(|t| t.role == "system").unwrap_or(false) {
1691 merged.push(DsMsg {
1692 role: "system".into(),
1693 content: String::new(),
1694 blocks: None,
1695 reasoning: String::new(),
1696 tool_calls: Vec::new(),
1697 tools: req_tools.to_vec(),
1698 task: None,
1699 });
1700 leading_tools_pending = false;
1701 }
1702 for turn in turns {
1703 match turn.role.as_str() {
1704 "tool" => {
1705 let block = DsBlock::ToolResult {
1706 content: turn.content.clone(),
1707 tool_use_id: turn.tool_call_id.clone().unwrap_or_default(),
1708 };
1709 match merged.last_mut() {
1710 Some(m) if m.role == "user" && m.blocks.is_some() => {
1711 m.blocks.as_mut().unwrap().push(block);
1712 }
1713 _ => merged.push(DsMsg {
1714 role: "user".into(),
1715 content: String::new(),
1716 blocks: Some(vec![block]),
1717 reasoning: String::new(),
1718 tool_calls: Vec::new(),
1719 tools: Vec::new(),
1720 task: None,
1721 }),
1722 }
1723 }
1724 "user" => {
1725 let text = DsBlock::Text(turn.content.clone());
1726 match merged.last_mut() {
1727 Some(m) if m.role == "user" && m.blocks.is_some() && m.task.is_none() => {
1728 m.blocks.as_mut().unwrap().push(text);
1729 }
1730 _ => merged.push(DsMsg {
1731 role: "user".into(),
1732 content: turn.content.clone(),
1733 blocks: Some(vec![text]),
1734 reasoning: String::new(),
1735 tool_calls: Vec::new(),
1736 tools: turn.tools.clone(),
1737 task: turn.task.clone(),
1738 }),
1739 }
1740 }
1741 role => {
1742 let mut tools = turn.tools.clone();
1743 if role == "system" && leading_tools_pending && merged.is_empty() {
1744 tools = req_tools.to_vec();
1745 leading_tools_pending = false;
1746 }
1747 merged.push(DsMsg {
1748 role: role.to_string(),
1749 content: turn.content.clone(),
1750 blocks: None,
1751 reasoning: turn.reasoning.clone().unwrap_or_default(),
1752 tool_calls: turn.tool_calls.clone(),
1753 tools,
1754 task: turn.task.clone(),
1755 });
1756 }
1757 }
1758 }
1759 merged
1760}
1761
1762#[allow(clippy::needless_range_loop)] fn dsv4_sort_tool_results(msgs: &mut [DsMsg]) {
1767 let mut order: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1768 for i in 0..msgs.len() {
1770 if msgs[i].role == "assistant" && !msgs[i].tool_calls.is_empty() {
1771 order.clear();
1772 for (idx, tc) in msgs[i].tool_calls.iter().enumerate() {
1773 if let Some(id) = tc.id.as_deref() {
1774 if !id.is_empty() {
1775 order.insert(id.to_string(), idx);
1776 }
1777 }
1778 }
1779 } else if msgs[i].role == "user" {
1780 let n_tool = msgs[i]
1781 .blocks
1782 .as_ref()
1783 .map(|b| {
1784 b.iter()
1785 .filter(|x| matches!(x, DsBlock::ToolResult { .. }))
1786 .count()
1787 })
1788 .unwrap_or(0);
1789 if n_tool > 1 && !order.is_empty() {
1790 let blocks = msgs[i].blocks.take().unwrap();
1791 let mut tool_blocks: Vec<DsBlock> = Vec::new();
1793 let mut positions: Vec<bool> = Vec::new(); let mut others: Vec<DsBlock> = Vec::new();
1795 for b in blocks {
1796 match b {
1797 DsBlock::ToolResult { .. } => {
1798 positions.push(true);
1799 tool_blocks.push(b);
1800 }
1801 other => {
1802 positions.push(false);
1803 others.push(other);
1804 }
1805 }
1806 }
1807 tool_blocks.sort_by_key(|b| match b {
1808 DsBlock::ToolResult { tool_use_id, .. } => {
1809 *order.get(tool_use_id).unwrap_or(&0)
1810 }
1811 _ => 0,
1812 });
1813 let mut ti = tool_blocks.into_iter();
1814 let mut oi = others.into_iter();
1815 let rebuilt: Vec<DsBlock> = positions
1816 .into_iter()
1817 .map(|is_tool| {
1818 if is_tool {
1819 ti.next().unwrap()
1820 } else {
1821 oi.next().unwrap()
1822 }
1823 })
1824 .collect();
1825 msgs[i].blocks = Some(rebuilt);
1826 }
1827 }
1828 }
1829}
1830
1831fn dsv4_last_user_idx(msgs: &[DsMsg]) -> isize {
1833 for i in (0..msgs.len()).rev() {
1834 if msgs[i].role == "user" || msgs[i].role == "developer" {
1835 return i as isize;
1836 }
1837 }
1838 -1
1839}
1840
1841fn dsv4_drop_thinking(msgs: Vec<DsMsg>) -> Vec<DsMsg> {
1845 let last = dsv4_last_user_idx(&msgs);
1846 let mut out = Vec::with_capacity(msgs.len());
1847 for (i, mut m) in msgs.into_iter().enumerate() {
1848 let keep_role = matches!(
1849 m.role.as_str(),
1850 "user" | "system" | "latest_reminder" | "direct_search_results"
1851 );
1852 if keep_role || (i as isize) >= last {
1853 out.push(m);
1854 } else if m.role == "assistant" {
1855 m.reasoning.clear();
1856 out.push(m);
1857 }
1858 }
1860 out
1861}
1862
1863fn apply_dsv4_template(
1888 turns: &[Turn],
1889 add_generation_prompt: bool,
1890 req_tools: &[Val],
1891 think: ThinkMode,
1892 reasoning_effort: Option<&str>,
1893 encoding: Option<Dsv4Encoding>,
1894) -> Result<String, String> {
1895 let thinking = think != ThinkMode::NoThink; let effort_prefix = dsv4_effort_prefix(thinking, reasoning_effort, encoding)?;
1897
1898 let mut msgs = dsv4_merge(turns, req_tools);
1899 dsv4_sort_tool_results(&mut msgs);
1900 let any_tools = msgs.iter().any(|m| !m.tools.is_empty());
1902 let effective_drop = !any_tools;
1903 if thinking && effective_drop {
1904 msgs = dsv4_drop_thinking(msgs);
1905 }
1906 let last_user = dsv4_last_user_idx(&msgs);
1907 let n = msgs.len();
1908
1909 let mut out = String::from(DS_BOS);
1910 for idx in 0..n {
1911 let m = &msgs[idx];
1912 if idx == 0 {
1913 out.push_str(effort_prefix);
1916 }
1917 match m.role.as_str() {
1918 "system" => {
1919 out.push_str(&m.content);
1920 if !m.tools.is_empty() {
1921 out.push_str("\n\n");
1922 out.push_str(&dsv4_render_tools(&m.tools));
1923 }
1924 }
1925 "developer" => {
1926 out.push_str(DS_USER);
1927 out.push_str(&m.content);
1928 if !m.tools.is_empty() {
1929 out.push_str("\n\n");
1930 out.push_str(&dsv4_render_tools(&m.tools));
1931 }
1932 }
1933 "user" => {
1934 out.push_str(DS_USER);
1935 if let Some(blocks) = &m.blocks {
1936 for (i, b) in blocks.iter().enumerate() {
1937 if i > 0 {
1938 out.push_str("\n\n");
1939 }
1940 match b {
1941 DsBlock::Text(t) => out.push_str(t),
1942 DsBlock::ToolResult { content, .. } => {
1943 out.push_str("<tool_result>");
1944 out.push_str(content);
1945 out.push_str("</tool_result>");
1946 }
1947 }
1948 }
1949 } else {
1950 out.push_str(&m.content);
1951 }
1952 }
1953 "latest_reminder" => {
1954 out.push_str(DS_REMINDER);
1955 out.push_str(&m.content);
1956 }
1957 "assistant" => {
1958 let prev_has_task = idx > 0 && msgs[idx - 1].task.is_some();
1959 let mut thinking_part = String::new();
1960 if thinking && !prev_has_task && (!effective_drop || (idx as isize) > last_user) {
1961 thinking_part.push_str(&m.reasoning);
1962 thinking_part.push_str(DS_THINK_END);
1963 }
1964 out.push_str(&thinking_part);
1965 out.push_str(&m.content);
1966 if !m.tool_calls.is_empty() {
1967 out.push_str(&dsv4_render_tool_calls(&m.tool_calls));
1968 }
1969 out.push_str(DS_EOS);
1970 }
1971 _ => {} }
1973
1974 if idx + 1 < n {
1978 let next = msgs[idx + 1].role.as_str();
1979 if next != "assistant" && next != "latest_reminder" {
1980 continue;
1981 }
1982 }
1983 let is_last = idx + 1 >= n;
1984 if let Some(task) = m.task.as_deref() {
1985 if is_last && !add_generation_prompt {
1987 continue;
1988 }
1989 if let Some(tok) = ds_task_token(task) {
1990 if task != "action" {
1991 out.push_str(tok);
1992 } else {
1993 out.push_str(DS_ASSISTANT);
1994 out.push_str(if thinking {
1995 DS_THINK_START
1996 } else {
1997 DS_THINK_END
1998 });
1999 out.push_str(tok);
2000 }
2001 }
2002 } else if m.role == "user" || m.role == "developer" {
2003 if is_last && !add_generation_prompt {
2004 continue;
2005 }
2006 out.push_str(DS_ASSISTANT);
2007 if thinking && (!effective_drop || (idx as isize) >= last_user) {
2011 out.push_str(DS_THINK_START);
2012 } else {
2013 out.push_str(DS_THINK_END);
2014 }
2015 }
2016 }
2017 Ok(out)
2018}
2019
2020#[cfg(test)]
2021mod tests {
2022 use super::*;
2023
2024 #[test]
2028 fn templateless_dsv4_artifact_dispatches_on_encoding() {
2029 let s =
2030 apply_chat_template_enc(None, &[("user", "Hello")], true, Some(Dsv4Encoding::V0731))
2031 .unwrap();
2032 assert!(
2033 s.contains("<\u{ff5c}User\u{ff5c}>") && s.contains("<\u{ff5c}Assistant\u{ff5c}>"),
2034 "encoding dispatch did not reach the dsv4 renderer: {s:?}"
2035 );
2036 assert!(!s.contains("<|im_start|>"), "fell back to ChatML: {s:?}");
2037 let legacy = apply_chat_template_enc(None, &[("user", "Hello")], true, None).unwrap();
2038 assert_eq!(
2039 legacy,
2040 apply_chat_template_str(None, &[("user", "Hello")], true)
2041 );
2042
2043 let turns = vec![Turn {
2044 role: "user".into(),
2045 content: "What is the weather in Paris? Use the tool.".into(),
2046 ..Default::default()
2047 }];
2048 let tj = vec![
2049 r#"{"type":"function","function":{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}"#.to_string(),
2050 ];
2051 let tv = vec![Val::Obj(vec![
2053 ("name".into(), Val::Str("get_weather".into())),
2054 (
2055 "description".into(),
2056 Val::Str("Get weather for a city".into()),
2057 ),
2058 (
2059 "parameters".into(),
2060 Val::Obj(vec![
2061 ("type".into(), Val::Str("object".into())),
2062 (
2063 "properties".into(),
2064 Val::Obj(vec![(
2065 "city".into(),
2066 Val::Obj(vec![("type".into(), Val::Str("string".into()))]),
2067 )]),
2068 ),
2069 ]),
2070 ),
2071 ])];
2072 let out = apply_chat_template_tools_ex(
2073 None,
2074 &turns,
2075 true,
2076 &tj,
2077 &tv,
2078 ThinkMode::Default,
2079 None,
2080 Some(Dsv4Encoding::V0731),
2081 )
2082 .expect("templateless dsv4 artifact must render tools (DSML is its protocol)");
2083 assert!(
2084 out.contains("\u{ff5c}DSML\u{ff5c}") || out.contains("get_weather"),
2085 "tools block missing from the DSML render: {out:?}"
2086 );
2087 }
2088
2089 #[test]
2090 fn plain_chatml() {
2091 let s = apply_chat_template_str(None, &[("user", "Hello")], true);
2092 assert_eq!(
2093 s,
2094 "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n"
2095 );
2096 }
2097
2098 const QWEN_TOOLS_TMPL: &str =
2101 "... <tools> ... add_generation_prompt ... enable_thinking ... '<think>\\n' ...";
2102
2103 #[test]
2107 fn tools_renderer_matches_legacy_when_plain() {
2108 let batteries: &[&[(&str, &str)]] = &[
2109 &[("user", "Hello")],
2110 &[("system", "You are helpful."), ("user", "Hi")],
2111 &[
2112 ("system", "rules"),
2113 ("user", "task"),
2114 ("assistant", "work"),
2115 ("user", "more"),
2116 ],
2117 &[("user", " padded "), ("assistant", "reply\nwith lines")],
2118 ];
2119 for tmpl in [None, Some(QWEN_TOOLS_TMPL)] {
2120 for msgs in batteries {
2121 let legacy = apply_chat_template_str(tmpl, msgs, true);
2122 let turns: Vec<Turn> = msgs
2123 .iter()
2124 .map(|(r, c)| Turn {
2125 role: r.to_string(),
2126 content: c.to_string(),
2127 tool_calls: Vec::new(),
2128 ..Default::default()
2129 })
2130 .collect();
2131 let ext =
2132 apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
2133 .unwrap();
2134 assert_eq!(legacy, ext, "template={tmpl:?} msgs={msgs:?}");
2135 }
2136 }
2137 }
2138
2139 #[test]
2140 fn tools_header_and_tool_response_render_per_template_law() {
2141 let tools =
2142 vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
2143 let turns = vec![
2144 Turn {
2145 role: "system".into(),
2146 content: "Be terse.".into(),
2147 tool_calls: Vec::new(),
2148 ..Default::default()
2149 },
2150 Turn {
2151 role: "user".into(),
2152 content: "Weather in Paris?".into(),
2153 tool_calls: Vec::new(),
2154 ..Default::default()
2155 },
2156 Turn {
2157 role: "assistant".into(),
2158 content: "".into(),
2159 tool_calls: vec![ToolCall {
2160 name: "get_weather".into(),
2161 params: vec![("city".into(), "Paris".into())],
2162 ..Default::default()
2163 }],
2164 ..Default::default()
2165 },
2166 Turn {
2167 role: "tool".into(),
2168 content: "{\"temp_c\": 21}".into(),
2169 tool_calls: Vec::new(),
2170 ..Default::default()
2171 },
2172 ];
2173 let s = apply_chat_template_tools(
2174 Some(QWEN_TOOLS_TMPL),
2175 &turns,
2176 true,
2177 &tools,
2178 ThinkMode::Default,
2179 None,
2180 )
2181 .unwrap();
2182 let expected = concat!(
2183 "<|im_start|>system\n# Tools\n\nYou have access to the following functions:\n\n",
2184 "<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n</tools>",
2185 "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
2186 "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
2187 "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
2188 "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
2189 "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
2190 "<function=...></function> block must be nested within <tool_call></tool_call> XML tags\n",
2191 "- Required parameters MUST be specified\n- You may provide optional reasoning for your ",
2192 "function call in natural language BEFORE the function call, but NOT after\n- If there is ",
2193 "no function call available, answer the question like normal with your current knowledge ",
2194 "and do not tell the user about function calls\n</IMPORTANT>",
2195 "\n\nBe terse.<|im_end|>\n",
2196 "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
2197 "<|im_start|>assistant\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n",
2198 "</parameter>\n</function>\n</tool_call><|im_end|>\n",
2199 "<|im_start|>user\n<tool_response>\n{\"temp_c\": 21}\n</tool_response><|im_end|>\n",
2200 "<|im_start|>assistant\n<think>\n",
2201 );
2202 assert_eq!(s, expected);
2203 }
2204
2205 #[test]
2206 fn assistant_content_plus_calls_and_consecutive_tool_turns_group() {
2207 let turns = vec![
2208 Turn {
2209 role: "user".into(),
2210 content: "both".into(),
2211 tool_calls: Vec::new(),
2212 ..Default::default()
2213 },
2214 Turn {
2215 role: "assistant".into(),
2216 content: "checking".into(),
2217 tool_calls: vec![
2218 ToolCall {
2219 name: "a".into(),
2220 params: vec![("x".into(), "1".into())],
2221 ..Default::default()
2222 },
2223 ToolCall {
2224 name: "b".into(),
2225 params: Vec::new(),
2226 ..Default::default()
2227 },
2228 ],
2229 ..Default::default()
2230 },
2231 Turn {
2232 role: "tool".into(),
2233 content: "r1".into(),
2234 tool_calls: Vec::new(),
2235 ..Default::default()
2236 },
2237 Turn {
2238 role: "tool".into(),
2239 content: "r2".into(),
2240 tool_calls: Vec::new(),
2241 ..Default::default()
2242 },
2243 ];
2244 let s = apply_chat_template_tools(
2245 Some(QWEN_TOOLS_TMPL),
2246 &turns,
2247 false,
2248 &[],
2249 ThinkMode::Default,
2250 None,
2251 )
2252 .unwrap();
2253 assert_eq!(
2254 s,
2255 concat!(
2256 "<|im_start|>user\nboth<|im_end|>\n",
2257 "<|im_start|>assistant\nchecking\n\n",
2258 "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>\n",
2259 "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
2260 "<|im_start|>user\n<tool_response>\nr1\n</tool_response>",
2261 "\n<tool_response>\nr2\n</tool_response><|im_end|>\n",
2262 )
2263 );
2264 }
2265
2266 #[test]
2267 fn nothink_maps_to_enable_thinking_false_tail_and_degrades_gracefully() {
2268 let turns = vec![Turn {
2269 role: "user".into(),
2270 content: "hi".into(),
2271 tool_calls: Vec::new(),
2272 ..Default::default()
2273 }];
2274 let s = apply_chat_template_tools(
2276 Some(QWEN_TOOLS_TMPL),
2277 &turns,
2278 true,
2279 &[],
2280 ThinkMode::NoThink,
2281 None,
2282 )
2283 .unwrap();
2284 assert!(
2285 s.ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"),
2286 "{s:?}"
2287 );
2288 let tmpl_no_switch = "... add_generation_prompt ... '<think>\\n' ...";
2290 let s = apply_chat_template_tools(
2291 Some(tmpl_no_switch),
2292 &turns,
2293 true,
2294 &[],
2295 ThinkMode::NoThink,
2296 None,
2297 )
2298 .unwrap();
2299 assert!(s.ends_with("<|im_start|>assistant\n<think>\n"), "{s:?}");
2300 let s =
2302 apply_chat_template_tools(None, &turns, true, &[], ThinkMode::NoThink, None).unwrap();
2303 assert!(s.ends_with("<|im_start|>assistant\n"), "{s:?}");
2304 }
2305
2306 #[test]
2307 fn tools_on_templates_without_tools_branch_error() {
2308 let turns = vec![Turn {
2309 role: "user".into(),
2310 content: "hi".into(),
2311 tool_calls: Vec::new(),
2312 ..Default::default()
2313 }];
2314 let tools = vec!["{}".to_string()];
2315 for tmpl in [None, Some("... hy_User ..."), Some("... <|turn> ...")] {
2316 let err =
2317 apply_chat_template_tools(tmpl, &turns, true, &tools, ThinkMode::Default, None);
2318 assert!(err.is_err(), "template={tmpl:?}");
2319 }
2320 let tool_turns = vec![Turn {
2322 role: "tool".into(),
2323 content: "r".into(),
2324 tool_calls: Vec::new(),
2325 ..Default::default()
2326 }];
2327 assert!(
2328 apply_chat_template_tools(None, &tool_turns, true, &[], ThinkMode::Default, None)
2329 .is_err()
2330 );
2331 }
2332
2333 fn one_user() -> Vec<Turn> {
2340 vec![turn("user", "Hi")]
2341 }
2342
2343 #[test]
2344 fn gemma4_thinking_maps_to_the_think_token_and_open_turn() {
2345 let g = |think: ThinkMode| {
2346 apply_chat_template_tools(Some("... <|turn> ..."), &one_user(), true, &[], think, None)
2347 .unwrap()
2348 };
2349 let closed = "<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>";
2352 assert_eq!(g(ThinkMode::Default), closed);
2353 assert_eq!(g(ThinkMode::NoThink), closed);
2354 assert_eq!(
2355 apply_chat_template_str(Some("... <|turn> ..."), &[("user", "Hi")], true),
2356 closed,
2357 "legacy renderer = the default arm"
2358 );
2359 assert_eq!(
2362 g(ThinkMode::Think),
2363 "<|turn>system\n<|think|>\n<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n"
2364 );
2365 let turns = vec![turn("system", "Be terse."), turn("user", "Hi")];
2367 let s = apply_chat_template_tools(
2368 Some("... <|turn> ..."),
2369 &turns,
2370 true,
2371 &[],
2372 ThinkMode::Think,
2373 None,
2374 )
2375 .unwrap();
2376 assert_eq!(
2377 s,
2378 "<|turn>system\n<|think|>\nBe terse.<turn|>\n\
2379 <|turn>user\nHi<turn|>\n<|turn>model\n"
2380 );
2381 }
2382
2383 const GEMMA_TOOLUSE_QAT_TMPL: &str =
2388 "... <|turn> ... <|tool> ... <|channel>thought\\n<channel|> ...";
2389
2390 #[test]
2391 fn gemma4_tools_arm_is_byte_identical_to_legacy_on_toolless_requests() {
2392 let batteries: &[&[(&str, &str)]] = &[
2396 &[("user", "Hi")],
2397 &[("system", "Be terse."), ("user", "Weather?")],
2398 &[
2399 ("system", "rules"),
2400 ("user", "task"),
2401 ("assistant", "work"),
2402 ("user", "more"),
2403 ],
2404 &[("user", " padded "), ("assistant", "reply\nwith lines")],
2405 ];
2406 for msgs in batteries {
2407 let turns: Vec<Turn> = msgs
2408 .iter()
2409 .map(|(r, c)| Turn {
2410 role: r.to_string(),
2411 content: c.to_string(),
2412 ..Default::default()
2413 })
2414 .collect();
2415 for (mode, thinking) in [
2416 (ThinkMode::Default, false),
2417 (ThinkMode::NoThink, false),
2418 (ThinkMode::Think, true),
2419 ] {
2420 let legacy = apply_gemma4_template(msgs, true, thinking);
2421 let arm = apply_chat_template_tools(
2422 Some(GEMMA_TOOLUSE_QAT_TMPL),
2423 &turns,
2424 true,
2425 &[],
2426 mode,
2427 None,
2428 )
2429 .unwrap();
2430 assert_eq!(legacy, arm, "mode={mode:?} msgs={msgs:?}");
2431 }
2432 }
2433 }
2434
2435 #[test]
2436 fn gemma4_tools_arm_still_rejects_tools_without_the_tool_marker() {
2437 let turns = vec![turn("user", "Weather?")];
2440 let tools = vec![r#"{"function":{"name":"f"}}"#.to_string()];
2441 let err = apply_chat_template_tools(
2442 Some("... <|turn> ..."),
2443 &turns,
2444 true,
2445 &tools,
2446 ThinkMode::Default,
2447 None,
2448 );
2449 assert!(err.is_err());
2450 }
2451
2452 #[test]
2453 fn hy3_thinking_maps_to_its_reasoning_effort_levels() {
2454 const HY_TMPL: Option<&str> = Some("... hy_User ...");
2455 let h = |think: ThinkMode, effort: Option<&str>| {
2456 apply_chat_template_tools(HY_TMPL, &one_user(), true, &[], think, effort).unwrap()
2457 };
2458 let closed = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
2461 <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:no_think\
2462 <\u{ff5c}hy_User:opensource\u{ff5c}>Hi\
2463 <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
2464 <think:opensource></think:opensource>";
2465 assert_eq!(h(ThinkMode::Default, None), closed);
2466 assert_eq!(
2467 h(ThinkMode::NoThink, Some("low")),
2468 closed,
2469 "NoThink wins over a level: thinking off IS no_think"
2470 );
2471 assert_eq!(
2472 apply_chat_template_str(HY_TMPL, &[("user", "Hi")], true),
2473 closed,
2474 "legacy renderer = the default arm"
2475 );
2476 let low = h(ThinkMode::Think, Some("low"));
2479 assert!(low.contains("reasoning_effort:low"), "{low:?}");
2480 assert!(low.ends_with("<think:opensource>"), "{low:?}");
2481 let high = h(ThinkMode::Think, Some("high"));
2482 assert!(high.contains("reasoning_effort:high"), "{high:?}");
2483 assert!(high.ends_with("<think:opensource>"), "{high:?}");
2484 assert_eq!(h(ThinkMode::Think, Some("medium")), low);
2487 assert_eq!(h(ThinkMode::Think, None), low);
2488 let turns = vec![
2491 turn("user", "q"),
2492 turn("assistant", "a"),
2493 turn("user", "more"),
2494 ];
2495 let s =
2496 apply_chat_template_tools(HY_TMPL, &turns, true, &[], ThinkMode::Think, Some("low"))
2497 .unwrap();
2498 assert_eq!(
2499 s,
2500 "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
2501 <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:low\
2502 <\u{ff5c}hy_User:opensource\u{ff5c}>q\
2503 <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
2504 <think:opensource></think:opensource>a\
2505 <\u{ff5c}hy_eos:opensource\u{ff5c}>\
2506 <\u{ff5c}hy_User:opensource\u{ff5c}>more\
2507 <\u{ff5c}hy_Assistant:opensource\u{ff5c}><think:opensource>"
2508 );
2509 }
2510
2511 #[test]
2512 fn qwen_think_mode_covers_all_three_directions() {
2513 let q = |think: ThinkMode| {
2514 apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &one_user(), true, &[], think, None)
2515 .unwrap()
2516 };
2517 assert!(q(ThinkMode::Default).ends_with("<|im_start|>assistant\n<think>\n"));
2519 assert_eq!(q(ThinkMode::Think), q(ThinkMode::Default));
2520 assert!(q(ThinkMode::NoThink).ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
2521 }
2522
2523 const STEP35_TMPL: &str = "{% macro render_message_content(message) %}... <tools> ... add_generation_prompt ... '<think>\\n' ...";
2535
2536 fn s35(msgs: &[(&str, &str)], genp: bool) -> String {
2537 apply_chat_template_str(Some(STEP35_TMPL), msgs, genp)
2538 }
2539
2540 fn s35_turns(turns: Vec<Turn>, genp: bool, tools: &[String]) -> String {
2541 apply_chat_template_tools(
2542 Some(STEP35_TMPL),
2543 &turns,
2544 genp,
2545 tools,
2546 ThinkMode::Default,
2547 None,
2548 )
2549 .unwrap()
2550 }
2551
2552 fn turn(role: &str, content: &str) -> Turn {
2553 Turn {
2554 role: role.into(),
2555 content: content.into(),
2556 tool_calls: Vec::new(),
2557 ..Default::default()
2558 }
2559 }
2560
2561 #[test]
2562 fn step35_plain_paths_match_the_shipped_jinja() {
2563 assert_eq!(
2564 s35(&[("user", "Hello")], true),
2565 "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think>\n"
2566 );
2567 assert_eq!(
2568 s35(&[("user", "Hello")], false),
2569 "<|im_start|>user\nHello<|im_end|>\n"
2570 );
2571 assert_eq!(
2572 s35(&[("system", "You are helpful."), ("user", "Hi")], true),
2573 "<|im_start|>system\nYou are helpful.<|im_end|>\n\
2574 <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
2575 );
2576 assert_eq!(
2579 s35(
2580 &[
2581 ("system", "rules"),
2582 ("user", "task"),
2583 ("assistant", "work"),
2584 ("user", "more")
2585 ],
2586 true
2587 ),
2588 "<|im_start|>system\nrules<|im_end|>\n<|im_start|>user\ntask<|im_end|>\n\
2589 <|im_start|>assistant\nwork<|im_end|>\n<|im_start|>user\nmore<|im_end|>\n\
2590 <|im_start|>assistant\n<think>\n"
2591 );
2592 assert_eq!(
2594 s35(&[("user", " padded ")], true),
2595 "<|im_start|>user\n padded <|im_end|>\n<|im_start|>assistant\n<think>\n"
2596 );
2597 }
2598
2599 #[test]
2600 fn step35_dispatch_beats_the_qwen_marker_arm() {
2601 let qwen = apply_chat_template_str(Some(QWEN_TOOLS_TMPL), &[("user", " pad ")], true);
2605 let step = s35(&[("user", " pad ")], true);
2606 assert_eq!(
2607 qwen,
2608 "<|im_start|>user\npad<|im_end|>\n<|im_start|>assistant\n<think>\n"
2609 );
2610 assert_eq!(
2611 step,
2612 "<|im_start|>user\n pad <|im_end|>\n<|im_start|>assistant\n<think>\n"
2613 );
2614 assert_ne!(qwen, step);
2615 }
2616
2617 #[test]
2618 fn step35_reasoning_effort_renders_in_the_system_turn() {
2619 assert_eq!(
2620 apply_step35_template(&[turn("user", "Hi")], true, &[], Some("high")),
2621 "<|im_start|>system\nReasoning: high\n\n<|im_end|>\n\
2622 <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
2623 );
2624 assert_eq!(
2625 apply_step35_template(
2626 &[turn("system", "Be terse."), turn("user", "Hi")],
2627 true,
2628 &[],
2629 Some("low")
2630 ),
2631 "<|im_start|>system\nReasoning: low\n\nBe terse.<|im_end|>\n\
2632 <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
2633 );
2634 let tools = vec![r#"{"type": "function", "function": {"name": "f"}}"#.to_string()];
2636 let s = apply_step35_template(
2637 &[turn("system", "Be terse."), turn("user", "q")],
2638 true,
2639 &tools,
2640 Some("medium"),
2641 );
2642 assert!(
2643 s.starts_with("<|im_start|>system\nReasoning: medium\n\nBe terse.\n\n# Tools\n"),
2644 "{s:?}"
2645 );
2646 }
2647
2648 #[test]
2649 fn reasoning_effort_reaches_step35_through_the_public_entry_and_only_step35() {
2650 let turns = vec![turn("user", "Hi")];
2653 let s = apply_chat_template_tools(
2654 Some(STEP35_TMPL),
2655 &turns,
2656 true,
2657 &[],
2658 ThinkMode::Default,
2659 Some("high"),
2660 )
2661 .unwrap();
2662 assert!(
2663 s.starts_with("<|im_start|>system\nReasoning: high\n\n<|im_end|>\n"),
2664 "{s:?}"
2665 );
2666 let s = apply_chat_template_tools(
2668 Some(STEP35_TMPL),
2669 &turns,
2670 true,
2671 &[],
2672 ThinkMode::Default,
2673 None,
2674 )
2675 .unwrap();
2676 assert!(!s.contains("Reasoning:"), "{s:?}");
2677 for tmpl in [
2680 None,
2681 Some(QWEN_TOOLS_TMPL),
2682 Some("... hy_User ..."),
2683 Some("... <|turn> ..."),
2684 ] {
2685 let with = apply_chat_template_tools(
2686 tmpl,
2687 &turns,
2688 true,
2689 &[],
2690 ThinkMode::Default,
2691 Some("high"),
2692 )
2693 .unwrap();
2694 let without =
2695 apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
2696 .unwrap();
2697 assert_eq!(with, without, "template={tmpl:?}");
2698 }
2699 }
2700
2701 #[test]
2702 fn step35_tools_header_is_not_the_qwen_header() {
2703 let tools = vec![
2704 r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string(),
2705 r#"{"type": "function", "function": {"name": "search"}}"#.to_string(),
2706 ];
2707 let s = s35_turns(
2708 vec![
2709 turn("system", "Be terse."),
2710 turn("user", "Weather in Paris?"),
2711 ],
2712 true,
2713 &tools,
2714 );
2715 assert_eq!(
2716 s,
2717 concat!(
2718 "<|im_start|>system\nBe terse.\n\n# Tools\n\n",
2721 "You have access to the following functions in JSONSchema format:\n\n<tools>\n",
2722 "{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n",
2723 "{\"type\": \"function\", \"function\": {\"name\": \"search\"}}\n</tools>",
2724 "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
2725 "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
2726 "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
2727 "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
2728 "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
2731 "<function=...>\n...\n</function> block must be nested within <tool_call>\n...\n",
2732 "</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>",
2733 "<|im_end|>\n",
2734 "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
2735 "<|im_start|>assistant\n<think>\n",
2736 )
2737 );
2738 assert!(!s.contains(QWEN_TOOLS_INSTRUCTION));
2740 }
2741
2742 #[test]
2743 fn step35_tool_results_take_their_own_role_and_group() {
2744 let tools =
2745 vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
2746 let turns = vec![
2747 turn("user", "both"),
2748 Turn {
2749 role: "assistant".into(),
2750 content: "checking".into(),
2751 tool_calls: vec![
2752 ToolCall {
2753 name: "a".into(),
2754 params: vec![("x".into(), "1".into())],
2755 ..Default::default()
2756 },
2757 ToolCall {
2758 name: "b".into(),
2759 params: Vec::new(),
2760 ..Default::default()
2761 },
2762 ],
2763 ..Default::default()
2764 },
2765 turn("tool", "r1"),
2766 turn("tool", "r2"),
2767 ];
2768 let s = s35_turns(turns, true, &tools);
2769 let body = s
2770 .split("<|im_end|>\n")
2771 .skip(1)
2772 .collect::<Vec<_>>()
2773 .join("<|im_end|>\n");
2774 assert_eq!(
2775 body,
2776 concat!(
2777 "<|im_start|>user\nboth<|im_end|>\n",
2778 "<|im_start|>assistant\n<think>\n\n</think>\nchecking",
2781 "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>",
2783 "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
2784 "<|im_start|>tool_response\n<tool_response>r1</tool_response>",
2786 "<tool_response>r2</tool_response><|im_end|>\n",
2787 "<|im_start|>assistant\n<think>\n",
2788 )
2789 );
2790 }
2791
2792 #[test]
2793 fn step35_assistant_think_split_and_the_reasoning_boundary() {
2794 assert_eq!(
2796 s35(
2797 &[
2798 ("user", "q"),
2799 ("assistant", "<think>\nreasoned\n</think>\nanswer")
2800 ],
2801 false
2802 ),
2803 "<|im_start|>user\nq<|im_end|>\n\
2804 <|im_start|>assistant\n<think>\nreasoned\n</think>\nanswer<|im_end|>\n"
2805 );
2806 assert_eq!(
2808 s35(&[("user", "q"), ("assistant", "plain")], false),
2809 "<|im_start|>user\nq<|im_end|>\n\
2810 <|im_start|>assistant\n<think>\n\n</think>\nplain<|im_end|>\n"
2811 );
2812 assert_eq!(
2815 s35(
2816 &[
2817 ("user", "real question"),
2818 ("assistant", "thinking about it"),
2819 ("user", "<tool_response>r</tool_response>")
2820 ],
2821 true
2822 ),
2823 "<|im_start|>user\nreal question<|im_end|>\n\
2824 <|im_start|>assistant\n<think>\n\n</think>\nthinking about it<|im_end|>\n\
2825 <|im_start|>user\n<tool_response>r</tool_response><|im_end|>\n\
2826 <|im_start|>assistant\n<think>\n"
2827 );
2828 }
2829
2830 #[test]
2831 fn step35_think_tail_is_unconditional_and_nothink_is_a_noop() {
2832 let turns = vec![turn("user", "hi")];
2836 for mode in [ThinkMode::Default, ThinkMode::NoThink] {
2837 let s = apply_chat_template_tools(Some(STEP35_TMPL), &turns, true, &[], mode, None)
2838 .unwrap();
2839 assert!(
2840 s.ends_with("<|im_start|>assistant\n<think>\n"),
2841 "mode={mode:?} {s:?}"
2842 );
2843 }
2844 }
2845
2846 #[test]
2847 fn step35_plain_path_is_identical_through_both_renderers() {
2848 let batteries: &[&[(&str, &str)]] = &[
2851 &[("user", "Hello")],
2852 &[("system", "You are helpful."), ("user", "Hi")],
2853 &[
2854 ("system", "rules"),
2855 ("user", "task"),
2856 ("assistant", "work"),
2857 ("user", "more"),
2858 ],
2859 &[("user", " padded "), ("assistant", "reply\nwith lines")],
2860 ];
2861 for msgs in batteries {
2862 let legacy = s35(msgs, true);
2863 let ext = s35_turns(msgs.iter().map(|(r, c)| turn(r, c)).collect(), true, &[]);
2864 assert_eq!(legacy, ext, "msgs={msgs:?}");
2865 }
2866 }
2867
2868 #[test]
2869 fn qwen_think_tail() {
2870 let tmpl = "... add_generation_prompt ... '<think>\\n' ...";
2872 let s = apply_chat_template_str(
2873 Some(tmpl),
2874 &[("system", "You are helpful."), ("user", "Hi")],
2875 true,
2876 );
2877 assert_eq!(
2878 s,
2879 "<|im_start|>system\nYou are helpful.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
2880 );
2881 }
2882
2883 #[test]
2888 fn dsv4_effort_prefix_law() {
2889 use Dsv4Encoding::{Preview, V0731};
2890 let p = dsv4_effort_prefix;
2891 for enc in [None, Some(Preview), Some(V0731)] {
2893 for eff in [None, Some("low"), Some("high"), Some("max")] {
2894 assert_eq!(p(false, eff, enc), Ok(""), "chat eff={eff:?} enc={enc:?}");
2895 }
2896 }
2897 for enc in [None, Some(Preview), Some(V0731)] {
2899 assert_eq!(p(true, None, enc), Ok(""));
2900 assert_eq!(p(true, Some("low"), enc), Ok(""));
2901 assert_eq!(p(true, Some("medium"), enc), Ok(""));
2902 }
2903 assert_eq!(p(true, Some("high"), Some(Preview)), Ok(""));
2905 assert_eq!(
2906 p(true, Some("max"), Some(Preview)),
2907 Ok(DS_EFFORT_ABSOLUTE_MAX)
2908 );
2909 assert_eq!(
2911 p(true, Some("high"), Some(V0731)),
2912 Ok(DS_EFFORT_ABSOLUTE_MAX)
2913 );
2914 assert_eq!(p(true, Some("max"), Some(V0731)), Ok(DS_EFFORT_BEYOND_MAX));
2915 assert!(p(true, Some("high"), None).is_err());
2917 assert!(p(true, Some("max"), None).is_err());
2918 assert!(DS_EFFORT_ABSOLUTE_MAX.starts_with("Reasoning Effort: Absolute maximum"));
2921 assert!(DS_EFFORT_BEYOND_MAX.starts_with("Reasoning Effort: Beyond maximum \u{2014}"));
2922 assert!(DS_EFFORT_ABSOLUTE_MAX.ends_with("\n\n"));
2923 assert!(DS_EFFORT_BEYOND_MAX.ends_with("\n\n"));
2924 assert_ne!(DS_EFFORT_ABSOLUTE_MAX, DS_EFFORT_BEYOND_MAX);
2925 }
2926
2927 #[test]
2930 fn dsv4_effort_renders_per_encoding_through_dispatch() {
2931 const DSV4_TMPL: &str = "<\u{ff5c}Assistant\u{ff5c}> \u{ff5c}DSML\u{ff5c}";
2932 let turns = vec![Turn {
2933 role: "user".into(),
2934 content: "Hi".into(),
2935 ..Default::default()
2936 }];
2937 let render = |effort: Option<&str>, enc: Option<Dsv4Encoding>| {
2938 apply_chat_template_tools_ex(
2939 Some(DSV4_TMPL),
2940 &turns,
2941 true,
2942 &[],
2943 &[],
2944 ThinkMode::Think,
2945 effort,
2946 enc,
2947 )
2948 };
2949 let base = render(None, None).unwrap();
2950 assert_eq!(
2952 render(Some("high"), Some(Dsv4Encoding::Preview)).unwrap(),
2953 base
2954 );
2955 let pv_max = render(Some("max"), Some(Dsv4Encoding::Preview)).unwrap();
2956 assert_eq!(
2957 pv_max,
2958 format!("{DS_BOS}{DS_EFFORT_ABSOLUTE_MAX}{}", &base[DS_BOS.len()..])
2959 );
2960 let v_low = render(Some("low"), Some(Dsv4Encoding::V0731)).unwrap();
2962 assert_eq!(v_low, base);
2963 let v_high = render(Some("high"), Some(Dsv4Encoding::V0731)).unwrap();
2964 assert_eq!(v_high, pv_max);
2965 let v_max = render(Some("max"), Some(Dsv4Encoding::V0731)).unwrap();
2966 assert_eq!(
2967 v_max,
2968 format!("{DS_BOS}{DS_EFFORT_BEYOND_MAX}{}", &base[DS_BOS.len()..])
2969 );
2970 assert_eq!(render(Some("low"), None).unwrap(), base);
2972 assert!(render(Some("high"), None).is_err());
2973 assert!(render(Some("max"), None).is_err());
2974 }
2975}