1use std::sync::Arc;
71
72use minijinja::value::Value as JinjaValue;
73use serde_json::Value;
74
75#[derive(Debug, Clone, thiserror::Error)]
78pub enum TemplateError {
79 #[error("chat template does not compile: {0}")]
82 Compile(String),
83 #[error("chat template failed to render: {0}")]
87 Render(String),
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum BuiltinTemplate {
99 ChatMl,
103 Plain,
106}
107
108enum Kind {
109 Jinja(Box<JinjaTemplate>),
111 Broken(TemplateError),
115 Builtin(BuiltinTemplate),
117}
118
119struct JinjaTemplate {
120 env: minijinja::Environment<'static>,
121 source: String,
122}
123
124#[derive(Clone)]
128pub struct ChatTemplate(Arc<Kind>);
129
130impl std::fmt::Debug for ChatTemplate {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 match &*self.0 {
133 Kind::Jinja(t) => write!(f, "Jinja({} bytes)", t.source.len()),
134 Kind::Broken(e) => write!(f, "Broken({e})"),
135 Kind::Builtin(b) => write!(f, "Builtin({b:?})"),
136 }
137 }
138}
139
140#[derive(Debug, Clone, Default)]
148pub struct RenderOptions {
149 pub add_generation_prompt: bool,
150 pub bos_token: Option<String>,
154 pub eos_token: Option<String>,
155 pub tools: Vec<Value>,
159 pub extra: serde_json::Map<String, Value>,
160}
161
162impl ChatTemplate {
163 pub fn from_jinja(source: &str) -> Result<Self, TemplateError> {
165 let mut env = new_environment();
166 env.add_template_owned("chat".to_string(), source.to_string())
167 .map_err(|e| TemplateError::Compile(format_jinja_error(&e)))?;
168 Ok(Self(Arc::new(Kind::Jinja(Box::new(JinjaTemplate {
169 env,
170 source: source.to_string(),
171 })))))
172 }
173
174 pub fn builtin(b: BuiltinTemplate) -> Self {
175 Self(Arc::new(Kind::Builtin(b)))
176 }
177
178 pub fn from_gguf_metadata(
189 chat_template: Option<&str>,
190 arch: Option<&str>,
191 byte_tokenizer: bool,
192 chatml_tokens_present: bool,
193 ) -> Self {
194 match chat_template.filter(|t| !t.trim().is_empty()) {
195 Some(t) => match Self::from_jinja(t) {
196 Ok(tmpl) => tmpl,
197 Err(e) => Self(Arc::new(Kind::Broken(e))),
198 },
199 None if byte_tokenizer || arch.is_none() || !chatml_tokens_present => {
215 Self::builtin(BuiltinTemplate::Plain)
216 }
217 None => Self::builtin(BuiltinTemplate::ChatMl),
218 }
219 }
220
221 pub fn vocab_has_chatml(file: &impl ferrox_gguf::TensorSource) -> bool {
226 let Some(ferrox_gguf::GgufValue::Array(tokens)) = file.metadata("tokenizer.ggml.tokens")
227 else {
228 return false;
229 };
230 let (mut start, mut end) = (false, false);
231 for t in tokens {
232 match t.as_str() {
233 Some("<|im_start|>") => start = true,
234 Some("<|im_end|>") => end = true,
235 _ => {}
236 }
237 if start && end {
238 return true;
239 }
240 }
241 false
242 }
243
244 pub fn is_jinja(&self) -> bool {
246 matches!(&*self.0, Kind::Jinja(_))
247 }
248
249 pub fn source(&self) -> Option<&str> {
251 match &*self.0 {
252 Kind::Jinja(t) => Some(&t.source),
253 _ => None,
254 }
255 }
256
257 pub fn handles_tools(&self) -> bool {
267 match &*self.0 {
268 Kind::Jinja(t) => t.source.contains("tools"),
269 Kind::Broken(_) | Kind::Builtin(_) => false,
270 }
271 }
272
273 pub fn describe(&self) -> String {
275 match &*self.0 {
276 Kind::Jinja(t) => format!("jinja ({} bytes from the GGUF)", t.source.len()),
277 Kind::Broken(e) => format!("BROKEN: {e}"),
278 Kind::Builtin(b) => format!("builtin {b:?} (checkpoint ships no chat template)"),
279 }
280 }
281
282 pub fn render(
284 &self,
285 messages: &[Value],
286 opts: &RenderOptions,
287 ) -> Result<String, TemplateError> {
288 match &*self.0 {
289 Kind::Broken(e) => Err(e.clone()),
290 Kind::Builtin(b) => Ok(render_builtin(*b, messages, opts)),
291 Kind::Jinja(t) => {
292 let tmpl = t
293 .env
294 .get_template("chat")
295 .map_err(|e| TemplateError::Compile(format_jinja_error(&e)))?;
296 let mut ctx = serde_json::Map::new();
297 for (k, v) in &opts.extra {
300 ctx.insert(k.clone(), v.clone());
301 }
302 ctx.insert("messages".into(), Value::Array(messages.to_vec()));
303 ctx.insert(
304 "add_generation_prompt".into(),
305 Value::Bool(opts.add_generation_prompt),
306 );
307 ctx.insert(
316 "tools".into(),
317 if opts.tools.is_empty() {
318 Value::Null
319 } else {
320 Value::Array(opts.tools.clone())
321 },
322 );
323 for (name, tok) in [
324 ("bos_token", &opts.bos_token),
325 ("eos_token", &opts.eos_token),
326 ] {
327 if let Some(tok) = tok {
328 ctx.insert(name.into(), Value::String(tok.clone()));
329 }
330 }
331 tmpl.render(JinjaValue::from_serialize(Value::Object(ctx)))
332 .map_err(|e| TemplateError::Render(format_jinja_error(&e)))
333 }
334 }
335 }
336}
337
338fn format_jinja_error(err: &minijinja::Error) -> String {
344 let mut out = err.to_string();
345 if let Some(line) = err.line() {
346 out.push_str(&format!(" (line {line})"));
347 }
348 let mut src = std::error::Error::source(err);
349 while let Some(e) = src {
350 out.push_str(&format!(": {e}"));
351 src = std::error::Error::source(e);
352 }
353 out
354}
355
356fn new_environment() -> minijinja::Environment<'static> {
357 let mut env = minijinja::Environment::new();
358 env.set_undefined_behavior(minijinja::UndefinedBehavior::Lenient);
365 env.set_trim_blocks(true);
385 env.set_lstrip_blocks(true);
386 env.add_function("raise_exception", raise_exception);
390 env.add_function("strftime_now", strftime_now);
391 env.add_filter("tojson", tojson);
392 env.set_unknown_method_callback(python_method);
393 env
394}
395
396fn tojson(value: JinjaValue) -> Result<String, minijinja::Error> {
423 let json: Value = serde_json::to_value(&value).map_err(|e| {
424 minijinja::Error::new(
425 minijinja::ErrorKind::InvalidOperation,
426 format!("tojson: value is not serialisable: {e}"),
427 )
428 })?;
429 let mut out = String::new();
430 write_python_json(&json, &mut out);
431 Ok(out)
432}
433
434fn write_python_json(v: &Value, out: &mut String) {
435 match v {
436 Value::Object(map) => {
437 let mut keys: Vec<&String> = map.keys().collect();
439 keys.sort();
440 out.push('{');
441 for (i, k) in keys.iter().enumerate() {
442 if i > 0 {
443 out.push_str(", ");
444 }
445 out.push_str(&Value::String((*k).clone()).to_string());
446 out.push_str(": ");
447 write_python_json(&map[*k], out);
448 }
449 out.push('}');
450 }
451 Value::Array(items) => {
452 out.push('[');
453 for (i, item) in items.iter().enumerate() {
454 if i > 0 {
455 out.push_str(", ");
456 }
457 write_python_json(item, out);
458 }
459 out.push(']');
460 }
461 other => out.push_str(&other.to_string()),
462 }
463}
464
465fn python_method(
477 _state: &minijinja::State,
478 value: &JinjaValue,
479 method: &str,
480 args: &[JinjaValue],
481) -> Result<JinjaValue, minijinja::Error> {
482 fn unknown() -> minijinja::Error {
483 minijinja::Error::from(minijinja::ErrorKind::UnknownMethod)
484 }
485 fn as_str(v: &JinjaValue) -> Result<&str, minijinja::Error> {
486 v.as_str().ok_or_else(|| {
487 minijinja::Error::new(
488 minijinja::ErrorKind::InvalidOperation,
489 "expected a string argument",
490 )
491 })
492 }
493 match method {
494 "get" => {
496 if value.as_object().is_none() {
497 return Err(unknown());
498 }
499 let (key, default) = match args {
500 [k] => (k, JinjaValue::from(())),
501 [k, d] => (k, d.clone()),
502 _ => {
503 return Err(minijinja::Error::new(
504 minijinja::ErrorKind::InvalidOperation,
505 "get() takes 1 or 2 arguments",
506 ))
507 }
508 };
509 Ok(value
510 .get_item(key)
511 .ok()
512 .filter(|v| !v.is_undefined())
513 .unwrap_or(default))
514 }
515 "split" => {
517 let s = value.as_str().ok_or_else(unknown)?;
518 let parts: Vec<JinjaValue> = match args {
519 [] => s.split_whitespace().map(JinjaValue::from).collect(),
520 [sep] => s.split(as_str(sep)?).map(JinjaValue::from).collect(),
521 _ => {
522 return Err(minijinja::Error::new(
523 minijinja::ErrorKind::InvalidOperation,
524 "ferrox implements split() with at most one separator argument",
525 ))
526 }
527 };
528 Ok(JinjaValue::from(parts))
529 }
530 "strip" | "lstrip" | "rstrip" => {
531 let s = value.as_str().ok_or_else(unknown)?;
532 let chars: Option<Vec<char>> = match args {
533 [] => None,
534 [c] => Some(as_str(c)?.chars().collect()),
535 _ => {
536 return Err(minijinja::Error::new(
537 minijinja::ErrorKind::InvalidOperation,
538 "strip() takes at most one argument",
539 ))
540 }
541 };
542 let pred = |c: char| match &chars {
543 Some(set) => set.contains(&c),
544 None => c.is_whitespace(),
545 };
546 Ok(JinjaValue::from(match method {
547 "strip" => s.trim_matches(pred),
548 "lstrip" => s.trim_start_matches(pred),
549 _ => s.trim_end_matches(pred),
550 }))
551 }
552 _ => Err(unknown()),
553 }
554}
555
556fn raise_exception(msg: String) -> Result<JinjaValue, minijinja::Error> {
561 Err(minijinja::Error::new(
562 minijinja::ErrorKind::InvalidOperation,
563 format!("template raised: {msg}"),
564 ))
565}
566
567fn strftime_now(fmt: String) -> Result<String, minijinja::Error> {
579 let secs = match std::env::var("FERROX_TEST_CHAT_TEMPLATE_NOW") {
580 Ok(v) => v.trim().parse::<i64>().map_err(|_| {
581 minijinja::Error::new(
582 minijinja::ErrorKind::InvalidOperation,
583 "FERROX_TEST_CHAT_TEMPLATE_NOW must be Unix seconds",
584 )
585 })?,
586 Err(_) => std::time::SystemTime::now()
587 .duration_since(std::time::UNIX_EPOCH)
588 .map(|d| d.as_secs() as i64)
589 .unwrap_or(0),
590 };
591 format_utc(secs, &fmt).map_err(|spec| {
592 minijinja::Error::new(
593 minijinja::ErrorKind::InvalidOperation,
594 format!(
595 "strftime_now: unsupported format specifier `%{spec}` in {fmt:?} -- \
596 ferrox implements a subset (%Y %y %m %d %e %H %M %S %j %B %b %A %a %F %T %%) \
597 and refuses rather than stamping a wrong date into the prompt"
598 ),
599 )
600 })
601}
602
603const MONTHS: [&str; 12] = [
604 "January",
605 "February",
606 "March",
607 "April",
608 "May",
609 "June",
610 "July",
611 "August",
612 "September",
613 "October",
614 "November",
615 "December",
616];
617const WEEKDAYS: [&str; 7] = [
618 "Thursday",
619 "Friday",
620 "Saturday",
621 "Sunday",
622 "Monday",
623 "Tuesday",
624 "Wednesday",
625];
626
627fn format_utc(secs: i64, fmt: &str) -> Result<String, char> {
631 let days = secs.div_euclid(86_400);
632 let tod = secs.rem_euclid(86_400);
633 let (hour, minute, second) = (tod / 3600, (tod % 3600) / 60, tod % 60);
634 let weekday = days.rem_euclid(7) as usize;
636
637 let z = days + 719_468;
638 let era = z.div_euclid(146_097);
639 let doe = z.rem_euclid(146_097);
640 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
641 let y = yoe + era * 400;
642 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
643 let mp = (5 * doy + 2) / 153;
644 let day = doy - (153 * mp + 2) / 5 + 1;
645 let month = if mp < 10 { mp + 3 } else { mp - 9 };
646 let year = if month <= 2 { y + 1 } else { y };
647
648 let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
650 const CUM: [i64; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
651 let yday = CUM[(month - 1) as usize] + day + i64::from(leap && month > 2);
652
653 let mut out = String::with_capacity(fmt.len() + 8);
654 let mut chars = fmt.chars().peekable();
655 while let Some(c) = chars.next() {
656 if c != '%' {
657 out.push(c);
658 continue;
659 }
660 let mut pad = true;
661 let mut spec = chars.next().ok_or('%')?;
662 if spec == '-' {
663 pad = false;
664 spec = chars.next().ok_or('-')?;
665 }
666 let num = |out: &mut String, v: i64, w: usize| {
667 if pad {
668 out.push_str(&format!("{v:0w$}"));
669 } else {
670 out.push_str(&v.to_string());
671 }
672 };
673 match spec {
674 'Y' => out.push_str(&year.to_string()),
675 'y' => num(&mut out, year.rem_euclid(100), 2),
676 'm' => num(&mut out, month, 2),
677 'd' => num(&mut out, day, 2),
678 'e' => out.push_str(&format!("{day:2}")),
680 'H' => num(&mut out, hour, 2),
681 'M' => num(&mut out, minute, 2),
682 'S' => num(&mut out, second, 2),
683 'j' => num(&mut out, yday, 3),
684 'B' => out.push_str(MONTHS[(month - 1) as usize]),
685 'b' => out.push_str(&MONTHS[(month - 1) as usize][..3]),
686 'A' => out.push_str(WEEKDAYS[weekday]),
687 'a' => out.push_str(&WEEKDAYS[weekday][..3]),
688 'F' => out.push_str(&format!("{year:04}-{month:02}-{day:02}")),
689 'T' => out.push_str(&format!("{hour:02}:{minute:02}:{second:02}")),
690 '%' => out.push('%'),
691 other => return Err(other),
692 }
693 }
694 Ok(out)
695}
696
697fn builtin_message_text(m: &Value) -> String {
702 let mut out = match m.get("content") {
703 Some(Value::String(s)) => s.clone(),
704 Some(Value::Array(parts)) => parts
705 .iter()
706 .filter_map(|p| p.get("text").and_then(Value::as_str))
707 .collect::<Vec<_>>()
708 .join(""),
709 _ => String::new(),
710 };
711 if let Some(Value::Array(calls)) = m.get("tool_calls") {
712 for call in calls {
713 let f = call.get("function");
714 let name = f
715 .and_then(|f| f.get("name"))
716 .and_then(Value::as_str)
717 .unwrap_or("");
718 let args = f
719 .and_then(|f| f.get("arguments"))
720 .map(|a| match a {
721 Value::String(s) => s.clone(),
722 other => other.to_string(),
723 })
724 .unwrap_or_else(|| "{}".to_string());
725 out.push_str(&format!(
726 "<tool_call>{{\"name\": \"{name}\", \"arguments\": {args}}}</tool_call>"
727 ));
728 }
729 }
730 out
731}
732
733fn builtin_role(m: &Value) -> &str {
734 m.get("role").and_then(Value::as_str).unwrap_or("user")
735}
736
737fn render_builtin(b: BuiltinTemplate, messages: &[Value], opts: &RenderOptions) -> String {
738 let mut out = String::new();
739 match b {
740 BuiltinTemplate::ChatMl => {
741 for m in messages {
742 out.push_str("<|im_start|>");
743 out.push_str(builtin_role(m));
744 out.push('\n');
745 out.push_str(&builtin_message_text(m));
746 out.push_str("<|im_end|>\n");
747 }
748 if opts.add_generation_prompt {
749 out.push_str("<|im_start|>assistant\n");
750 }
751 }
752 BuiltinTemplate::Plain => {
753 let lines: Vec<String> = messages
754 .iter()
755 .map(|m| format!("{}: {}", builtin_role(m), builtin_message_text(m)))
756 .collect();
757 out.push_str(&lines.join("\n"));
758 }
759 }
760 out
761}
762
763#[cfg(test)]
764mod tests {
765
766 #[test]
777 fn a_vocab_without_chatml_markers_falls_back_to_plain() {
778 let without = ChatTemplate::from_gguf_metadata(None, Some("olmoe"), false, false);
779 let with = ChatTemplate::from_gguf_metadata(None, Some("olmoe"), false, true);
780 assert_ne!(
781 without.describe(),
782 with.describe(),
783 "the ChatML fallback must depend on the markers actually existing"
784 );
785 assert!(
786 !without.describe().to_lowercase().contains("chatml"),
787 "got {} for a vocab with no ChatML tokens",
788 without.describe()
789 );
790 }
791 use super::*;
792 use serde_json::json;
793
794 fn msg(role: &str, content: &str) -> Value {
795 json!({"role": role, "content": content})
796 }
797
798 fn opts() -> RenderOptions {
799 RenderOptions {
800 add_generation_prompt: true,
801 bos_token: Some("<s>".into()),
802 eos_token: Some("</s>".into()),
803 ..Default::default()
804 }
805 }
806
807 #[test]
812 fn renders_bos_token_and_the_real_mistral_inst_framing() {
813 let src = "{{ bos_token }}{% for message in messages %}{% if (message['role'] == 'user') != (loop.index0 % 2 == 0) %}{{ raise_exception('Conversation roles must alternate user/assistant/user/assistant/...') }}{% endif %}{% if message['role'] == 'user' %}{{ '[INST] ' + message['content'] + ' [/INST]' }}{% elif message['role'] == 'assistant' %}{{ message['content'] + eos_token}}{% else %}{{ raise_exception('Only user and assistant roles are supported!') }}{% endif %}{% endfor %}";
814 let t = ChatTemplate::from_jinja(src).unwrap();
815 let out = t
816 .render(
817 &[
818 msg("user", "hi"),
819 msg("assistant", "hello"),
820 msg("user", "2+2?"),
821 ],
822 &opts(),
823 )
824 .unwrap();
825 assert_eq!(out, "<s>[INST] hi [/INST]hello</s>[INST] 2+2? [/INST]");
826 }
827
828 #[test]
831 fn mistral_is_not_plain_role_labelled_lines() {
832 let src = "{{ bos_token }}{% for message in messages %}{% if message['role'] == 'user' %}{{ '[INST] ' + message['content'] + ' [/INST]' }}{% endif %}{% endfor %}";
833 let out = ChatTemplate::from_jinja(src)
834 .unwrap()
835 .render(&[msg("user", "hi")], &opts())
836 .unwrap();
837 assert!(!out.contains("user: hi"), "{out}");
838 assert!(out.contains("[INST]"), "{out}");
839 }
840
841 #[test]
846 fn renders_a_system_message_with_the_real_gemma3_template() {
847 let src = GEMMA3_TEMPLATE;
848 let t = ChatTemplate::from_jinja(src).unwrap();
849 let out = t
850 .render(
851 &[msg("system", "be brief"), msg("user", "hi")],
852 &RenderOptions {
853 add_generation_prompt: true,
854 bos_token: Some("<bos>".into()),
855 ..Default::default()
856 },
857 )
858 .unwrap();
859 assert_eq!(
860 out,
861 "<bos><start_of_turn>user\nbe brief\n\nhi<end_of_turn>\n<start_of_turn>model\n"
862 );
863 }
864
865 #[test]
868 fn gemma3_emits_the_image_placeholder_for_content_parts() {
869 let out = ChatTemplate::from_jinja(GEMMA3_TEMPLATE)
870 .unwrap()
871 .render(
872 &[json!({"role": "user", "content": [
873 {"type": "image"},
874 {"type": "text", "text": "what is this?"}
875 ]})],
876 &RenderOptions {
877 add_generation_prompt: true,
878 bos_token: Some("<bos>".into()),
879 ..Default::default()
880 },
881 )
882 .unwrap();
883 assert_eq!(
884 out,
885 "<bos><start_of_turn>user\n<start_of_image>what is this?<end_of_turn>\n<start_of_turn>model\n"
886 );
887 }
888
889 #[test]
894 fn renders_a_tool_call_block_with_the_real_qwen25_template() {
895 let t = ChatTemplate::from_jinja(QWEN25_TEMPLATE).unwrap();
896 assert!(t.handles_tools());
897 let out = t
898 .render(
899 &[
900 msg("user", "weather in Paris?"),
901 json!({"role": "assistant", "content": "", "tool_calls": [
902 {"type": "function", "function": {"name": "get_weather", "arguments": {"city": "Paris"}}}
903 ]}),
904 json!({"role": "tool", "content": "18C"}),
905 ],
906 &RenderOptions {
907 add_generation_prompt: true,
908 tools: vec![json!({"type": "function", "function": {
909 "name": "get_weather",
910 "description": "Current weather",
911 "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
912 }})],
913 ..Default::default()
914 },
915 )
916 .unwrap();
917 assert_eq!(
918 out,
919 concat!(
920 "<|im_start|>system\n",
921 "You are Qwen, created by Alibaba Cloud. You are a helpful assistant.\n\n",
922 "# Tools\n\n",
923 "You may call one or more functions to assist with the user query.\n\n",
924 "You are provided with function signatures within <tools></tools> XML tags:\n",
925 "<tools>\n",
926 "{\"function\": {\"description\": \"Current weather\", \"name\": \"get_weather\", ",
929 "\"parameters\": {\"properties\": {\"city\": {\"type\": \"string\"}}, ",
930 "\"type\": \"object\"}}, \"type\": \"function\"}\n",
931 "</tools>\n\n",
932 "For each function call, return a json object with function name and arguments ",
933 "within <tool_call></tool_call> XML tags:\n",
934 "<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n",
935 "</tool_call><|im_end|>\n",
936 "<|im_start|>user\nweather in Paris?<|im_end|>\n",
937 "<|im_start|>assistant\n",
938 "<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Paris\"}}\n",
939 "</tool_call><|im_end|>\n",
940 "<|im_start|>user\n<tool_response>\n18C\n</tool_response><|im_end|>\n",
941 "<|im_start|>assistant\n",
942 )
943 );
944 }
945
946 #[test]
951 fn add_generation_prompt_is_honoured_both_ways() {
952 let t = ChatTemplate::from_jinja(GEMMA3_TEMPLATE).unwrap();
953 let with = t
954 .render(
955 &[msg("user", "hi")],
956 &RenderOptions {
957 add_generation_prompt: true,
958 ..Default::default()
959 },
960 )
961 .unwrap();
962 let without = t
963 .render(
964 &[msg("user", "hi")],
965 &RenderOptions {
966 add_generation_prompt: false,
967 ..Default::default()
968 },
969 )
970 .unwrap();
971 assert_eq!(
972 with,
973 "<start_of_turn>user\nhi<end_of_turn>\n<start_of_turn>model\n"
974 );
975 assert_eq!(without, "<start_of_turn>user\nhi<end_of_turn>\n");
976 }
977
978 #[test]
985 fn llama31_binds_tools_to_null_so_a_plain_chat_gets_no_tool_preamble() {
986 let out = ChatTemplate::from_jinja(LLAMA31_TEMPLATE)
987 .unwrap()
988 .render(
989 &[msg("system", "be brief"), msg("user", "hi")],
990 &RenderOptions {
991 add_generation_prompt: true,
992 bos_token: Some("<|begin_of_text|>".into()),
993 ..Default::default()
994 },
995 )
996 .unwrap();
997 assert_eq!(
998 out,
999 concat!(
1000 "<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n",
1001 "Cutting Knowledge Date: December 2023\n",
1002 "Today Date: 26 Jul 2024\n\n",
1003 "be brief<|eot_id|>",
1004 "<|start_header_id|>user<|end_header_id|>\n\nhi<|eot_id|>",
1005 "<|start_header_id|>assistant<|end_header_id|>\n\n",
1006 )
1007 );
1008 assert!(!out.contains("Environment: ipython"), "{out}");
1009 }
1010
1011 #[test]
1014 fn raise_exception_fails_the_render_and_keeps_the_message() {
1015 let src = "{{ bos_token }}{% for m in messages %}{% if (m['role'] == 'user') != (loop.index0 % 2 == 0) %}{{ raise_exception('Conversation roles must alternate user/assistant/user/assistant/...') }}{% endif %}{% endfor %}";
1016 let err = ChatTemplate::from_jinja(src)
1017 .unwrap()
1018 .render(&[msg("assistant", "oops")], &opts())
1019 .unwrap_err();
1020 let text = err.to_string();
1021 assert!(matches!(err, TemplateError::Render(_)), "{text}");
1022 assert!(text.contains("roles must alternate"), "{text}");
1023 }
1024
1025 #[test]
1026 fn strftime_now_stamps_a_pinned_clock() {
1027 std::env::set_var("FERROX_TEST_CHAT_TEMPLATE_NOW", "1720096496");
1029 let out = ChatTemplate::from_jinja(
1030 "{{ strftime_now(\"%d %b %Y\") }}|{{ strftime_now('%A %F %T %j %-d') }}",
1031 )
1032 .unwrap()
1033 .render(&[], &opts())
1034 .unwrap();
1035 std::env::remove_var("FERROX_TEST_CHAT_TEMPLATE_NOW");
1036 assert_eq!(out, "04 Jul 2024|Thursday 2024-07-04 12:34:56 186 4");
1037 }
1038
1039 #[test]
1040 fn strftime_now_refuses_an_unimplemented_specifier() {
1041 let err = ChatTemplate::from_jinja("{{ strftime_now('%Z') }}")
1042 .unwrap()
1043 .render(&[], &opts())
1044 .unwrap_err();
1045 assert!(
1046 err.to_string().contains("unsupported format specifier"),
1047 "{err}"
1048 );
1049 }
1050
1051 #[test]
1055 fn whitespace_control_is_respected() {
1056 let out = ChatTemplate::from_jinja(
1057 "{%- for m in messages -%}\n {{- m['role'] -}}\n{%- endfor -%}",
1058 )
1059 .unwrap()
1060 .render(&[msg("user", "x"), msg("assistant", "y")], &opts())
1061 .unwrap();
1062 assert_eq!(out, "userassistant");
1063 }
1064
1065 #[test]
1069 fn namespace_writes_escape_loop_scope() {
1070 let out = ChatTemplate::from_jinja(
1071 "{%- set ns = namespace(n=0) -%}{%- for m in messages -%}{%- set ns.n = ns.n + 1 -%}{%- endfor -%}{{ ns.n }}",
1072 )
1073 .unwrap()
1074 .render(&[msg("user", "a"), msg("user", "b"), msg("user", "c")], &opts())
1075 .unwrap();
1076 assert_eq!(out, "3");
1077 }
1078
1079 #[test]
1081 fn an_unsupported_construct_fails_loudly() {
1082 let err = ChatTemplate::from_jinja("{{ messages | no_such_filter }}")
1083 .unwrap()
1084 .render(&[msg("user", "hi")], &opts())
1085 .unwrap_err();
1086 let text = err.to_string();
1087 assert!(matches!(err, TemplateError::Render(_)), "{text}");
1088 assert!(text.contains("no_such_filter"), "{text}");
1089 }
1090
1091 #[test]
1092 fn a_template_that_does_not_compile_is_recorded_not_replaced() {
1093 let t = ChatTemplate::from_gguf_metadata(
1094 Some("{% for m in messages %}{{ m }}"),
1095 Some("llama"),
1096 false,
1097 true,
1098 );
1099 assert!(!t.is_jinja());
1100 let err = t.render(&[msg("user", "hi")], &opts()).unwrap_err();
1101 assert!(matches!(err, TemplateError::Compile(_)), "{err}");
1102 assert!(t.describe().starts_with("BROKEN"), "{}", t.describe());
1104 }
1105
1106 #[test]
1109 fn chat_template_kwargs_reach_the_template() {
1110 let src = "{%- if enable_thinking -%}THINK{%- else -%}PLAIN{%- endif -%}";
1111 let t = ChatTemplate::from_jinja(src).unwrap();
1112 let mut extra = serde_json::Map::new();
1113 extra.insert("enable_thinking".into(), Value::Bool(true));
1114 let on = t
1115 .render(
1116 &[msg("user", "hi")],
1117 &RenderOptions {
1118 extra,
1119 ..Default::default()
1120 },
1121 )
1122 .unwrap();
1123 let off = t
1124 .render(&[msg("user", "hi")], &RenderOptions::default())
1125 .unwrap();
1126 assert_eq!((on.as_str(), off.as_str()), ("THINK", "PLAIN"));
1127 }
1128
1129 #[test]
1130 fn chat_template_kwargs_cannot_shadow_messages_or_tools() {
1131 let mut extra = serde_json::Map::new();
1132 extra.insert(
1133 "messages".into(),
1134 json!([{"role": "user", "content": "INJECTED"}]),
1135 );
1136 extra.insert("add_generation_prompt".into(), Value::Bool(true));
1137 let out = ChatTemplate::from_jinja(
1138 "{%- for m in messages -%}{{ m['content'] }}{%- endfor -%}|{{ add_generation_prompt }}",
1139 )
1140 .unwrap()
1141 .render(
1142 &[msg("user", "real")],
1143 &RenderOptions {
1144 add_generation_prompt: false,
1145 extra,
1146 ..Default::default()
1147 },
1148 )
1149 .unwrap();
1150 assert_eq!(out, "real|false");
1151 }
1152
1153 #[test]
1161 fn gemma4_thinking_injection_is_reachable_now() {
1162 let t = ChatTemplate::from_jinja(GEMMA4_TEMPLATE_CORE).unwrap();
1163 let mut extra = serde_json::Map::new();
1164 extra.insert("enable_thinking".into(), Value::Bool(true));
1165 let thinking = t
1166 .render(
1167 &[msg("user", "hi")],
1168 &RenderOptions {
1169 add_generation_prompt: true,
1170 bos_token: Some("<bos>".into()),
1171 extra,
1172 ..Default::default()
1173 },
1174 )
1175 .unwrap();
1176 assert_eq!(
1177 thinking,
1178 "<bos><|turn>system\n<|think|>\n<turn|>\n<|turn>user\nhi<turn|>\n<|turn>model\n"
1179 );
1180 let plain = t
1181 .render(
1182 &[msg("user", "hi")],
1183 &RenderOptions {
1184 add_generation_prompt: true,
1185 bos_token: Some("<bos>".into()),
1186 ..Default::default()
1187 },
1188 )
1189 .unwrap();
1190 assert_eq!(plain, "<bos><|turn>user\nhi<turn|>\n<|turn>model\n");
1191 }
1192
1193 #[test]
1197 fn gemma4_strip_thinking_removes_replayed_reasoning() {
1198 let out = ChatTemplate::from_jinja(GEMMA4_TEMPLATE_CORE)
1199 .unwrap()
1200 .render(
1201 &[
1202 msg("user", "hi"),
1203 msg(
1204 "assistant",
1205 "<|channel>thought\nlet me think<channel|>the answer is 4",
1206 ),
1207 msg("user", "again?"),
1208 ],
1209 &RenderOptions {
1210 add_generation_prompt: true,
1211 bos_token: Some("<bos>".into()),
1212 ..Default::default()
1213 },
1214 )
1215 .unwrap();
1216 assert!(!out.contains("let me think"), "{out}");
1217 assert!(
1218 out.contains("<|turn>model\nthe answer is 4<turn|>\n"),
1219 "{out}"
1220 );
1221 }
1222
1223 #[test]
1226 fn a_checkpoint_with_no_template_gets_chatml_or_plain() {
1227 assert!(matches!(
1228 &*ChatTemplate::from_gguf_metadata(None, Some("olmoe"), false, true).0,
1229 Kind::Builtin(BuiltinTemplate::ChatMl)
1230 ));
1231 assert!(matches!(
1232 &*ChatTemplate::from_gguf_metadata(Some(" "), Some("olmoe"), false, true).0,
1233 Kind::Builtin(BuiltinTemplate::ChatMl)
1234 ));
1235 assert!(matches!(
1236 &*ChatTemplate::from_gguf_metadata(None, Some("olmoe"), true, true).0,
1237 Kind::Builtin(BuiltinTemplate::Plain)
1238 ));
1239 assert!(matches!(
1240 &*ChatTemplate::from_gguf_metadata(None, None, false, true).0,
1241 Kind::Builtin(BuiltinTemplate::Plain)
1242 ));
1243 }
1244
1245 #[test]
1246 fn builtin_chatml_and_plain_render_as_before() {
1247 let msgs = [msg("system", "be helpful"), msg("user", "hi")];
1248 assert_eq!(
1249 ChatTemplate::builtin(BuiltinTemplate::ChatMl)
1250 .render(&msgs, &opts())
1251 .unwrap(),
1252 "<|im_start|>system\nbe helpful<|im_end|>\n<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\n"
1253 );
1254 assert_eq!(
1255 ChatTemplate::builtin(BuiltinTemplate::Plain)
1256 .render(&msgs, &opts())
1257 .unwrap(),
1258 "system: be helpful\nuser: hi"
1259 );
1260 }
1261
1262 #[test]
1263 fn builtin_renders_replayed_tool_calls_as_marker_text() {
1264 let msgs = [json!({"role": "assistant", "tool_calls": [
1265 {"function": {"name": "f", "arguments": "{\"a\": 1}"}}
1266 ]})];
1267 assert_eq!(
1268 ChatTemplate::builtin(BuiltinTemplate::Plain)
1269 .render(&msgs, &opts())
1270 .unwrap(),
1271 "assistant: <tool_call>{\"name\": \"f\", \"arguments\": {\"a\": 1}}</tool_call>"
1272 );
1273 }
1274
1275 #[test]
1276 fn handles_tools_is_a_property_of_the_template_not_a_guess() {
1277 assert!(ChatTemplate::from_jinja(QWEN25_TEMPLATE)
1278 .unwrap()
1279 .handles_tools());
1280 assert!(!ChatTemplate::from_jinja(GEMMA3_TEMPLATE)
1281 .unwrap()
1282 .handles_tools());
1283 assert!(!ChatTemplate::builtin(BuiltinTemplate::ChatMl).handles_tools());
1284 }
1285
1286 #[test]
1287 fn utc_calendar_math_matches_known_dates() {
1288 assert_eq!(
1289 format_utc(0, "%F %T %A %j").unwrap(),
1290 "1970-01-01 00:00:00 Thursday 001"
1291 );
1292 assert_eq!(
1293 format_utc(951_782_400, "%F %A %j").unwrap(),
1294 "2000-02-29 Tuesday 060"
1295 );
1296 assert_eq!(
1297 format_utc(1_709_164_800, "%F %A %j").unwrap(),
1298 "2024-02-29 Thursday 060"
1299 );
1300 assert_eq!(
1301 format_utc(1_767_225_599, "%F %T %j").unwrap(),
1302 "2025-12-31 23:59:59 365"
1303 );
1304 assert_eq!(
1305 format_utc(-86_400, "%F %A").unwrap(),
1306 "1969-12-31 Wednesday"
1307 );
1308 }
1309
1310 const GEMMA3_TEMPLATE: &str = include_str!("../tests/templates/gemma-3-1b-it.jinja");
1315 const QWEN25_TEMPLATE: &str = include_str!("../tests/templates/qwen2.5-instruct.jinja");
1317 const GEMMA4_TEMPLATE_CORE: &str = include_str!("../tests/templates/gemma-4-E2B-it.jinja");
1324 const LLAMA31_TEMPLATE: &str = include_str!("../tests/templates/llama-3.1-8b-instruct.jinja");
1326}