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 ) -> Self {
193 match chat_template.filter(|t| !t.trim().is_empty()) {
194 Some(t) => match Self::from_jinja(t) {
195 Ok(tmpl) => tmpl,
196 Err(e) => Self(Arc::new(Kind::Broken(e))),
197 },
198 None if byte_tokenizer || arch.is_none() => Self::builtin(BuiltinTemplate::Plain),
199 None => Self::builtin(BuiltinTemplate::ChatMl),
200 }
201 }
202
203 pub fn is_jinja(&self) -> bool {
205 matches!(&*self.0, Kind::Jinja(_))
206 }
207
208 pub fn source(&self) -> Option<&str> {
210 match &*self.0 {
211 Kind::Jinja(t) => Some(&t.source),
212 _ => None,
213 }
214 }
215
216 pub fn handles_tools(&self) -> bool {
226 match &*self.0 {
227 Kind::Jinja(t) => t.source.contains("tools"),
228 Kind::Broken(_) | Kind::Builtin(_) => false,
229 }
230 }
231
232 pub fn describe(&self) -> String {
234 match &*self.0 {
235 Kind::Jinja(t) => format!("jinja ({} bytes from the GGUF)", t.source.len()),
236 Kind::Broken(e) => format!("BROKEN: {e}"),
237 Kind::Builtin(b) => format!("builtin {b:?} (checkpoint ships no chat template)"),
238 }
239 }
240
241 pub fn render(
243 &self,
244 messages: &[Value],
245 opts: &RenderOptions,
246 ) -> Result<String, TemplateError> {
247 match &*self.0 {
248 Kind::Broken(e) => Err(e.clone()),
249 Kind::Builtin(b) => Ok(render_builtin(*b, messages, opts)),
250 Kind::Jinja(t) => {
251 let tmpl = t
252 .env
253 .get_template("chat")
254 .map_err(|e| TemplateError::Compile(format_jinja_error(&e)))?;
255 let mut ctx = serde_json::Map::new();
256 for (k, v) in &opts.extra {
259 ctx.insert(k.clone(), v.clone());
260 }
261 ctx.insert("messages".into(), Value::Array(messages.to_vec()));
262 ctx.insert(
263 "add_generation_prompt".into(),
264 Value::Bool(opts.add_generation_prompt),
265 );
266 ctx.insert(
275 "tools".into(),
276 if opts.tools.is_empty() {
277 Value::Null
278 } else {
279 Value::Array(opts.tools.clone())
280 },
281 );
282 for (name, tok) in [
283 ("bos_token", &opts.bos_token),
284 ("eos_token", &opts.eos_token),
285 ] {
286 if let Some(tok) = tok {
287 ctx.insert(name.into(), Value::String(tok.clone()));
288 }
289 }
290 tmpl.render(JinjaValue::from_serialize(Value::Object(ctx)))
291 .map_err(|e| TemplateError::Render(format_jinja_error(&e)))
292 }
293 }
294 }
295}
296
297fn format_jinja_error(err: &minijinja::Error) -> String {
303 let mut out = err.to_string();
304 if let Some(line) = err.line() {
305 out.push_str(&format!(" (line {line})"));
306 }
307 let mut src = std::error::Error::source(err);
308 while let Some(e) = src {
309 out.push_str(&format!(": {e}"));
310 src = std::error::Error::source(e);
311 }
312 out
313}
314
315fn new_environment() -> minijinja::Environment<'static> {
316 let mut env = minijinja::Environment::new();
317 env.set_undefined_behavior(minijinja::UndefinedBehavior::Lenient);
324 env.set_trim_blocks(true);
344 env.set_lstrip_blocks(true);
345 env.add_function("raise_exception", raise_exception);
349 env.add_function("strftime_now", strftime_now);
350 env.add_filter("tojson", tojson);
351 env.set_unknown_method_callback(python_method);
352 env
353}
354
355fn tojson(value: JinjaValue) -> Result<String, minijinja::Error> {
382 let json: Value = serde_json::to_value(&value).map_err(|e| {
383 minijinja::Error::new(
384 minijinja::ErrorKind::InvalidOperation,
385 format!("tojson: value is not serialisable: {e}"),
386 )
387 })?;
388 let mut out = String::new();
389 write_python_json(&json, &mut out);
390 Ok(out)
391}
392
393fn write_python_json(v: &Value, out: &mut String) {
394 match v {
395 Value::Object(map) => {
396 let mut keys: Vec<&String> = map.keys().collect();
398 keys.sort();
399 out.push('{');
400 for (i, k) in keys.iter().enumerate() {
401 if i > 0 {
402 out.push_str(", ");
403 }
404 out.push_str(&Value::String((*k).clone()).to_string());
405 out.push_str(": ");
406 write_python_json(&map[*k], out);
407 }
408 out.push('}');
409 }
410 Value::Array(items) => {
411 out.push('[');
412 for (i, item) in items.iter().enumerate() {
413 if i > 0 {
414 out.push_str(", ");
415 }
416 write_python_json(item, out);
417 }
418 out.push(']');
419 }
420 other => out.push_str(&other.to_string()),
421 }
422}
423
424fn python_method(
436 _state: &minijinja::State,
437 value: &JinjaValue,
438 method: &str,
439 args: &[JinjaValue],
440) -> Result<JinjaValue, minijinja::Error> {
441 fn unknown() -> minijinja::Error {
442 minijinja::Error::from(minijinja::ErrorKind::UnknownMethod)
443 }
444 fn as_str(v: &JinjaValue) -> Result<&str, minijinja::Error> {
445 v.as_str().ok_or_else(|| {
446 minijinja::Error::new(
447 minijinja::ErrorKind::InvalidOperation,
448 "expected a string argument",
449 )
450 })
451 }
452 match method {
453 "get" => {
455 if value.as_object().is_none() {
456 return Err(unknown());
457 }
458 let (key, default) = match args {
459 [k] => (k, JinjaValue::from(())),
460 [k, d] => (k, d.clone()),
461 _ => {
462 return Err(minijinja::Error::new(
463 minijinja::ErrorKind::InvalidOperation,
464 "get() takes 1 or 2 arguments",
465 ))
466 }
467 };
468 Ok(value
469 .get_item(key)
470 .ok()
471 .filter(|v| !v.is_undefined())
472 .unwrap_or(default))
473 }
474 "split" => {
476 let s = value.as_str().ok_or_else(unknown)?;
477 let parts: Vec<JinjaValue> = match args {
478 [] => s.split_whitespace().map(JinjaValue::from).collect(),
479 [sep] => s.split(as_str(sep)?).map(JinjaValue::from).collect(),
480 _ => {
481 return Err(minijinja::Error::new(
482 minijinja::ErrorKind::InvalidOperation,
483 "ferrox implements split() with at most one separator argument",
484 ))
485 }
486 };
487 Ok(JinjaValue::from(parts))
488 }
489 "strip" | "lstrip" | "rstrip" => {
490 let s = value.as_str().ok_or_else(unknown)?;
491 let chars: Option<Vec<char>> = match args {
492 [] => None,
493 [c] => Some(as_str(c)?.chars().collect()),
494 _ => {
495 return Err(minijinja::Error::new(
496 minijinja::ErrorKind::InvalidOperation,
497 "strip() takes at most one argument",
498 ))
499 }
500 };
501 let pred = |c: char| match &chars {
502 Some(set) => set.contains(&c),
503 None => c.is_whitespace(),
504 };
505 Ok(JinjaValue::from(match method {
506 "strip" => s.trim_matches(pred),
507 "lstrip" => s.trim_start_matches(pred),
508 _ => s.trim_end_matches(pred),
509 }))
510 }
511 _ => Err(unknown()),
512 }
513}
514
515fn raise_exception(msg: String) -> Result<JinjaValue, minijinja::Error> {
520 Err(minijinja::Error::new(
521 minijinja::ErrorKind::InvalidOperation,
522 format!("template raised: {msg}"),
523 ))
524}
525
526fn strftime_now(fmt: String) -> Result<String, minijinja::Error> {
538 let secs = match std::env::var("FERROX_TEST_CHAT_TEMPLATE_NOW") {
539 Ok(v) => v.trim().parse::<i64>().map_err(|_| {
540 minijinja::Error::new(
541 minijinja::ErrorKind::InvalidOperation,
542 "FERROX_TEST_CHAT_TEMPLATE_NOW must be Unix seconds",
543 )
544 })?,
545 Err(_) => std::time::SystemTime::now()
546 .duration_since(std::time::UNIX_EPOCH)
547 .map(|d| d.as_secs() as i64)
548 .unwrap_or(0),
549 };
550 format_utc(secs, &fmt).map_err(|spec| {
551 minijinja::Error::new(
552 minijinja::ErrorKind::InvalidOperation,
553 format!(
554 "strftime_now: unsupported format specifier `%{spec}` in {fmt:?} -- \
555 ferrox implements a subset (%Y %y %m %d %e %H %M %S %j %B %b %A %a %F %T %%) \
556 and refuses rather than stamping a wrong date into the prompt"
557 ),
558 )
559 })
560}
561
562const MONTHS: [&str; 12] = [
563 "January",
564 "February",
565 "March",
566 "April",
567 "May",
568 "June",
569 "July",
570 "August",
571 "September",
572 "October",
573 "November",
574 "December",
575];
576const WEEKDAYS: [&str; 7] = [
577 "Thursday",
578 "Friday",
579 "Saturday",
580 "Sunday",
581 "Monday",
582 "Tuesday",
583 "Wednesday",
584];
585
586fn format_utc(secs: i64, fmt: &str) -> Result<String, char> {
590 let days = secs.div_euclid(86_400);
591 let tod = secs.rem_euclid(86_400);
592 let (hour, minute, second) = (tod / 3600, (tod % 3600) / 60, tod % 60);
593 let weekday = days.rem_euclid(7) as usize;
595
596 let z = days + 719_468;
597 let era = z.div_euclid(146_097);
598 let doe = z.rem_euclid(146_097);
599 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
600 let y = yoe + era * 400;
601 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
602 let mp = (5 * doy + 2) / 153;
603 let day = doy - (153 * mp + 2) / 5 + 1;
604 let month = if mp < 10 { mp + 3 } else { mp - 9 };
605 let year = if month <= 2 { y + 1 } else { y };
606
607 let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
609 const CUM: [i64; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
610 let yday = CUM[(month - 1) as usize] + day + i64::from(leap && month > 2);
611
612 let mut out = String::with_capacity(fmt.len() + 8);
613 let mut chars = fmt.chars().peekable();
614 while let Some(c) = chars.next() {
615 if c != '%' {
616 out.push(c);
617 continue;
618 }
619 let mut pad = true;
620 let mut spec = chars.next().ok_or('%')?;
621 if spec == '-' {
622 pad = false;
623 spec = chars.next().ok_or('-')?;
624 }
625 let num = |out: &mut String, v: i64, w: usize| {
626 if pad {
627 out.push_str(&format!("{v:0w$}"));
628 } else {
629 out.push_str(&v.to_string());
630 }
631 };
632 match spec {
633 'Y' => out.push_str(&year.to_string()),
634 'y' => num(&mut out, year.rem_euclid(100), 2),
635 'm' => num(&mut out, month, 2),
636 'd' => num(&mut out, day, 2),
637 'e' => out.push_str(&format!("{day:2}")),
639 'H' => num(&mut out, hour, 2),
640 'M' => num(&mut out, minute, 2),
641 'S' => num(&mut out, second, 2),
642 'j' => num(&mut out, yday, 3),
643 'B' => out.push_str(MONTHS[(month - 1) as usize]),
644 'b' => out.push_str(&MONTHS[(month - 1) as usize][..3]),
645 'A' => out.push_str(WEEKDAYS[weekday]),
646 'a' => out.push_str(&WEEKDAYS[weekday][..3]),
647 'F' => out.push_str(&format!("{year:04}-{month:02}-{day:02}")),
648 'T' => out.push_str(&format!("{hour:02}:{minute:02}:{second:02}")),
649 '%' => out.push('%'),
650 other => return Err(other),
651 }
652 }
653 Ok(out)
654}
655
656fn builtin_message_text(m: &Value) -> String {
661 let mut out = match m.get("content") {
662 Some(Value::String(s)) => s.clone(),
663 Some(Value::Array(parts)) => parts
664 .iter()
665 .filter_map(|p| p.get("text").and_then(Value::as_str))
666 .collect::<Vec<_>>()
667 .join(""),
668 _ => String::new(),
669 };
670 if let Some(Value::Array(calls)) = m.get("tool_calls") {
671 for call in calls {
672 let f = call.get("function");
673 let name = f
674 .and_then(|f| f.get("name"))
675 .and_then(Value::as_str)
676 .unwrap_or("");
677 let args = f
678 .and_then(|f| f.get("arguments"))
679 .map(|a| match a {
680 Value::String(s) => s.clone(),
681 other => other.to_string(),
682 })
683 .unwrap_or_else(|| "{}".to_string());
684 out.push_str(&format!(
685 "<tool_call>{{\"name\": \"{name}\", \"arguments\": {args}}}</tool_call>"
686 ));
687 }
688 }
689 out
690}
691
692fn builtin_role(m: &Value) -> &str {
693 m.get("role").and_then(Value::as_str).unwrap_or("user")
694}
695
696fn render_builtin(b: BuiltinTemplate, messages: &[Value], opts: &RenderOptions) -> String {
697 let mut out = String::new();
698 match b {
699 BuiltinTemplate::ChatMl => {
700 for m in messages {
701 out.push_str("<|im_start|>");
702 out.push_str(builtin_role(m));
703 out.push('\n');
704 out.push_str(&builtin_message_text(m));
705 out.push_str("<|im_end|>\n");
706 }
707 if opts.add_generation_prompt {
708 out.push_str("<|im_start|>assistant\n");
709 }
710 }
711 BuiltinTemplate::Plain => {
712 let lines: Vec<String> = messages
713 .iter()
714 .map(|m| format!("{}: {}", builtin_role(m), builtin_message_text(m)))
715 .collect();
716 out.push_str(&lines.join("\n"));
717 }
718 }
719 out
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use serde_json::json;
726
727 fn msg(role: &str, content: &str) -> Value {
728 json!({"role": role, "content": content})
729 }
730
731 fn opts() -> RenderOptions {
732 RenderOptions {
733 add_generation_prompt: true,
734 bos_token: Some("<s>".into()),
735 eos_token: Some("</s>".into()),
736 ..Default::default()
737 }
738 }
739
740 #[test]
745 fn renders_bos_token_and_the_real_mistral_inst_framing() {
746 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 %}";
747 let t = ChatTemplate::from_jinja(src).unwrap();
748 let out = t
749 .render(
750 &[
751 msg("user", "hi"),
752 msg("assistant", "hello"),
753 msg("user", "2+2?"),
754 ],
755 &opts(),
756 )
757 .unwrap();
758 assert_eq!(out, "<s>[INST] hi [/INST]hello</s>[INST] 2+2? [/INST]");
759 }
760
761 #[test]
764 fn mistral_is_not_plain_role_labelled_lines() {
765 let src = "{{ bos_token }}{% for message in messages %}{% if message['role'] == 'user' %}{{ '[INST] ' + message['content'] + ' [/INST]' }}{% endif %}{% endfor %}";
766 let out = ChatTemplate::from_jinja(src)
767 .unwrap()
768 .render(&[msg("user", "hi")], &opts())
769 .unwrap();
770 assert!(!out.contains("user: hi"), "{out}");
771 assert!(out.contains("[INST]"), "{out}");
772 }
773
774 #[test]
779 fn renders_a_system_message_with_the_real_gemma3_template() {
780 let src = GEMMA3_TEMPLATE;
781 let t = ChatTemplate::from_jinja(src).unwrap();
782 let out = t
783 .render(
784 &[msg("system", "be brief"), msg("user", "hi")],
785 &RenderOptions {
786 add_generation_prompt: true,
787 bos_token: Some("<bos>".into()),
788 ..Default::default()
789 },
790 )
791 .unwrap();
792 assert_eq!(
793 out,
794 "<bos><start_of_turn>user\nbe brief\n\nhi<end_of_turn>\n<start_of_turn>model\n"
795 );
796 }
797
798 #[test]
801 fn gemma3_emits_the_image_placeholder_for_content_parts() {
802 let out = ChatTemplate::from_jinja(GEMMA3_TEMPLATE)
803 .unwrap()
804 .render(
805 &[json!({"role": "user", "content": [
806 {"type": "image"},
807 {"type": "text", "text": "what is this?"}
808 ]})],
809 &RenderOptions {
810 add_generation_prompt: true,
811 bos_token: Some("<bos>".into()),
812 ..Default::default()
813 },
814 )
815 .unwrap();
816 assert_eq!(
817 out,
818 "<bos><start_of_turn>user\n<start_of_image>what is this?<end_of_turn>\n<start_of_turn>model\n"
819 );
820 }
821
822 #[test]
827 fn renders_a_tool_call_block_with_the_real_qwen25_template() {
828 let t = ChatTemplate::from_jinja(QWEN25_TEMPLATE).unwrap();
829 assert!(t.handles_tools());
830 let out = t
831 .render(
832 &[
833 msg("user", "weather in Paris?"),
834 json!({"role": "assistant", "content": "", "tool_calls": [
835 {"type": "function", "function": {"name": "get_weather", "arguments": {"city": "Paris"}}}
836 ]}),
837 json!({"role": "tool", "content": "18C"}),
838 ],
839 &RenderOptions {
840 add_generation_prompt: true,
841 tools: vec![json!({"type": "function", "function": {
842 "name": "get_weather",
843 "description": "Current weather",
844 "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
845 }})],
846 ..Default::default()
847 },
848 )
849 .unwrap();
850 assert_eq!(
851 out,
852 concat!(
853 "<|im_start|>system\n",
854 "You are Qwen, created by Alibaba Cloud. You are a helpful assistant.\n\n",
855 "# Tools\n\n",
856 "You may call one or more functions to assist with the user query.\n\n",
857 "You are provided with function signatures within <tools></tools> XML tags:\n",
858 "<tools>\n",
859 "{\"function\": {\"description\": \"Current weather\", \"name\": \"get_weather\", ",
862 "\"parameters\": {\"properties\": {\"city\": {\"type\": \"string\"}}, ",
863 "\"type\": \"object\"}}, \"type\": \"function\"}\n",
864 "</tools>\n\n",
865 "For each function call, return a json object with function name and arguments ",
866 "within <tool_call></tool_call> XML tags:\n",
867 "<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n",
868 "</tool_call><|im_end|>\n",
869 "<|im_start|>user\nweather in Paris?<|im_end|>\n",
870 "<|im_start|>assistant\n",
871 "<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Paris\"}}\n",
872 "</tool_call><|im_end|>\n",
873 "<|im_start|>user\n<tool_response>\n18C\n</tool_response><|im_end|>\n",
874 "<|im_start|>assistant\n",
875 )
876 );
877 }
878
879 #[test]
884 fn add_generation_prompt_is_honoured_both_ways() {
885 let t = ChatTemplate::from_jinja(GEMMA3_TEMPLATE).unwrap();
886 let with = t
887 .render(
888 &[msg("user", "hi")],
889 &RenderOptions {
890 add_generation_prompt: true,
891 ..Default::default()
892 },
893 )
894 .unwrap();
895 let without = t
896 .render(
897 &[msg("user", "hi")],
898 &RenderOptions {
899 add_generation_prompt: false,
900 ..Default::default()
901 },
902 )
903 .unwrap();
904 assert_eq!(
905 with,
906 "<start_of_turn>user\nhi<end_of_turn>\n<start_of_turn>model\n"
907 );
908 assert_eq!(without, "<start_of_turn>user\nhi<end_of_turn>\n");
909 }
910
911 #[test]
918 fn llama31_binds_tools_to_null_so_a_plain_chat_gets_no_tool_preamble() {
919 let out = ChatTemplate::from_jinja(LLAMA31_TEMPLATE)
920 .unwrap()
921 .render(
922 &[msg("system", "be brief"), msg("user", "hi")],
923 &RenderOptions {
924 add_generation_prompt: true,
925 bos_token: Some("<|begin_of_text|>".into()),
926 ..Default::default()
927 },
928 )
929 .unwrap();
930 assert_eq!(
931 out,
932 concat!(
933 "<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n",
934 "Cutting Knowledge Date: December 2023\n",
935 "Today Date: 26 Jul 2024\n\n",
936 "be brief<|eot_id|>",
937 "<|start_header_id|>user<|end_header_id|>\n\nhi<|eot_id|>",
938 "<|start_header_id|>assistant<|end_header_id|>\n\n",
939 )
940 );
941 assert!(!out.contains("Environment: ipython"), "{out}");
942 }
943
944 #[test]
947 fn raise_exception_fails_the_render_and_keeps_the_message() {
948 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 %}";
949 let err = ChatTemplate::from_jinja(src)
950 .unwrap()
951 .render(&[msg("assistant", "oops")], &opts())
952 .unwrap_err();
953 let text = err.to_string();
954 assert!(matches!(err, TemplateError::Render(_)), "{text}");
955 assert!(text.contains("roles must alternate"), "{text}");
956 }
957
958 #[test]
959 fn strftime_now_stamps_a_pinned_clock() {
960 std::env::set_var("FERROX_TEST_CHAT_TEMPLATE_NOW", "1720096496");
962 let out = ChatTemplate::from_jinja(
963 "{{ strftime_now(\"%d %b %Y\") }}|{{ strftime_now('%A %F %T %j %-d') }}",
964 )
965 .unwrap()
966 .render(&[], &opts())
967 .unwrap();
968 std::env::remove_var("FERROX_TEST_CHAT_TEMPLATE_NOW");
969 assert_eq!(out, "04 Jul 2024|Thursday 2024-07-04 12:34:56 186 4");
970 }
971
972 #[test]
973 fn strftime_now_refuses_an_unimplemented_specifier() {
974 let err = ChatTemplate::from_jinja("{{ strftime_now('%Z') }}")
975 .unwrap()
976 .render(&[], &opts())
977 .unwrap_err();
978 assert!(
979 err.to_string().contains("unsupported format specifier"),
980 "{err}"
981 );
982 }
983
984 #[test]
988 fn whitespace_control_is_respected() {
989 let out = ChatTemplate::from_jinja(
990 "{%- for m in messages -%}\n {{- m['role'] -}}\n{%- endfor -%}",
991 )
992 .unwrap()
993 .render(&[msg("user", "x"), msg("assistant", "y")], &opts())
994 .unwrap();
995 assert_eq!(out, "userassistant");
996 }
997
998 #[test]
1002 fn namespace_writes_escape_loop_scope() {
1003 let out = ChatTemplate::from_jinja(
1004 "{%- set ns = namespace(n=0) -%}{%- for m in messages -%}{%- set ns.n = ns.n + 1 -%}{%- endfor -%}{{ ns.n }}",
1005 )
1006 .unwrap()
1007 .render(&[msg("user", "a"), msg("user", "b"), msg("user", "c")], &opts())
1008 .unwrap();
1009 assert_eq!(out, "3");
1010 }
1011
1012 #[test]
1014 fn an_unsupported_construct_fails_loudly() {
1015 let err = ChatTemplate::from_jinja("{{ messages | no_such_filter }}")
1016 .unwrap()
1017 .render(&[msg("user", "hi")], &opts())
1018 .unwrap_err();
1019 let text = err.to_string();
1020 assert!(matches!(err, TemplateError::Render(_)), "{text}");
1021 assert!(text.contains("no_such_filter"), "{text}");
1022 }
1023
1024 #[test]
1025 fn a_template_that_does_not_compile_is_recorded_not_replaced() {
1026 let t = ChatTemplate::from_gguf_metadata(
1027 Some("{% for m in messages %}{{ m }}"),
1028 Some("llama"),
1029 false,
1030 );
1031 assert!(!t.is_jinja());
1032 let err = t.render(&[msg("user", "hi")], &opts()).unwrap_err();
1033 assert!(matches!(err, TemplateError::Compile(_)), "{err}");
1034 assert!(t.describe().starts_with("BROKEN"), "{}", t.describe());
1036 }
1037
1038 #[test]
1041 fn chat_template_kwargs_reach_the_template() {
1042 let src = "{%- if enable_thinking -%}THINK{%- else -%}PLAIN{%- endif -%}";
1043 let t = ChatTemplate::from_jinja(src).unwrap();
1044 let mut extra = serde_json::Map::new();
1045 extra.insert("enable_thinking".into(), Value::Bool(true));
1046 let on = t
1047 .render(
1048 &[msg("user", "hi")],
1049 &RenderOptions {
1050 extra,
1051 ..Default::default()
1052 },
1053 )
1054 .unwrap();
1055 let off = t
1056 .render(&[msg("user", "hi")], &RenderOptions::default())
1057 .unwrap();
1058 assert_eq!((on.as_str(), off.as_str()), ("THINK", "PLAIN"));
1059 }
1060
1061 #[test]
1062 fn chat_template_kwargs_cannot_shadow_messages_or_tools() {
1063 let mut extra = serde_json::Map::new();
1064 extra.insert(
1065 "messages".into(),
1066 json!([{"role": "user", "content": "INJECTED"}]),
1067 );
1068 extra.insert("add_generation_prompt".into(), Value::Bool(true));
1069 let out = ChatTemplate::from_jinja(
1070 "{%- for m in messages -%}{{ m['content'] }}{%- endfor -%}|{{ add_generation_prompt }}",
1071 )
1072 .unwrap()
1073 .render(
1074 &[msg("user", "real")],
1075 &RenderOptions {
1076 add_generation_prompt: false,
1077 extra,
1078 ..Default::default()
1079 },
1080 )
1081 .unwrap();
1082 assert_eq!(out, "real|false");
1083 }
1084
1085 #[test]
1093 fn gemma4_thinking_injection_is_reachable_now() {
1094 let t = ChatTemplate::from_jinja(GEMMA4_TEMPLATE_CORE).unwrap();
1095 let mut extra = serde_json::Map::new();
1096 extra.insert("enable_thinking".into(), Value::Bool(true));
1097 let thinking = t
1098 .render(
1099 &[msg("user", "hi")],
1100 &RenderOptions {
1101 add_generation_prompt: true,
1102 bos_token: Some("<bos>".into()),
1103 extra,
1104 ..Default::default()
1105 },
1106 )
1107 .unwrap();
1108 assert_eq!(
1109 thinking,
1110 "<bos><|turn>system\n<|think|>\n<turn|>\n<|turn>user\nhi<turn|>\n<|turn>model\n"
1111 );
1112 let plain = t
1113 .render(
1114 &[msg("user", "hi")],
1115 &RenderOptions {
1116 add_generation_prompt: true,
1117 bos_token: Some("<bos>".into()),
1118 ..Default::default()
1119 },
1120 )
1121 .unwrap();
1122 assert_eq!(plain, "<bos><|turn>user\nhi<turn|>\n<|turn>model\n");
1123 }
1124
1125 #[test]
1129 fn gemma4_strip_thinking_removes_replayed_reasoning() {
1130 let out = ChatTemplate::from_jinja(GEMMA4_TEMPLATE_CORE)
1131 .unwrap()
1132 .render(
1133 &[
1134 msg("user", "hi"),
1135 msg(
1136 "assistant",
1137 "<|channel>thought\nlet me think<channel|>the answer is 4",
1138 ),
1139 msg("user", "again?"),
1140 ],
1141 &RenderOptions {
1142 add_generation_prompt: true,
1143 bos_token: Some("<bos>".into()),
1144 ..Default::default()
1145 },
1146 )
1147 .unwrap();
1148 assert!(!out.contains("let me think"), "{out}");
1149 assert!(
1150 out.contains("<|turn>model\nthe answer is 4<turn|>\n"),
1151 "{out}"
1152 );
1153 }
1154
1155 #[test]
1158 fn a_checkpoint_with_no_template_gets_chatml_or_plain() {
1159 assert!(matches!(
1160 &*ChatTemplate::from_gguf_metadata(None, Some("olmoe"), false).0,
1161 Kind::Builtin(BuiltinTemplate::ChatMl)
1162 ));
1163 assert!(matches!(
1164 &*ChatTemplate::from_gguf_metadata(Some(" "), Some("olmoe"), false).0,
1165 Kind::Builtin(BuiltinTemplate::ChatMl)
1166 ));
1167 assert!(matches!(
1168 &*ChatTemplate::from_gguf_metadata(None, Some("olmoe"), true).0,
1169 Kind::Builtin(BuiltinTemplate::Plain)
1170 ));
1171 assert!(matches!(
1172 &*ChatTemplate::from_gguf_metadata(None, None, false).0,
1173 Kind::Builtin(BuiltinTemplate::Plain)
1174 ));
1175 }
1176
1177 #[test]
1178 fn builtin_chatml_and_plain_render_as_before() {
1179 let msgs = [msg("system", "be helpful"), msg("user", "hi")];
1180 assert_eq!(
1181 ChatTemplate::builtin(BuiltinTemplate::ChatMl)
1182 .render(&msgs, &opts())
1183 .unwrap(),
1184 "<|im_start|>system\nbe helpful<|im_end|>\n<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\n"
1185 );
1186 assert_eq!(
1187 ChatTemplate::builtin(BuiltinTemplate::Plain)
1188 .render(&msgs, &opts())
1189 .unwrap(),
1190 "system: be helpful\nuser: hi"
1191 );
1192 }
1193
1194 #[test]
1195 fn builtin_renders_replayed_tool_calls_as_marker_text() {
1196 let msgs = [json!({"role": "assistant", "tool_calls": [
1197 {"function": {"name": "f", "arguments": "{\"a\": 1}"}}
1198 ]})];
1199 assert_eq!(
1200 ChatTemplate::builtin(BuiltinTemplate::Plain)
1201 .render(&msgs, &opts())
1202 .unwrap(),
1203 "assistant: <tool_call>{\"name\": \"f\", \"arguments\": {\"a\": 1}}</tool_call>"
1204 );
1205 }
1206
1207 #[test]
1208 fn handles_tools_is_a_property_of_the_template_not_a_guess() {
1209 assert!(ChatTemplate::from_jinja(QWEN25_TEMPLATE)
1210 .unwrap()
1211 .handles_tools());
1212 assert!(!ChatTemplate::from_jinja(GEMMA3_TEMPLATE)
1213 .unwrap()
1214 .handles_tools());
1215 assert!(!ChatTemplate::builtin(BuiltinTemplate::ChatMl).handles_tools());
1216 }
1217
1218 #[test]
1219 fn utc_calendar_math_matches_known_dates() {
1220 assert_eq!(
1221 format_utc(0, "%F %T %A %j").unwrap(),
1222 "1970-01-01 00:00:00 Thursday 001"
1223 );
1224 assert_eq!(
1225 format_utc(951_782_400, "%F %A %j").unwrap(),
1226 "2000-02-29 Tuesday 060"
1227 );
1228 assert_eq!(
1229 format_utc(1_709_164_800, "%F %A %j").unwrap(),
1230 "2024-02-29 Thursday 060"
1231 );
1232 assert_eq!(
1233 format_utc(1_767_225_599, "%F %T %j").unwrap(),
1234 "2025-12-31 23:59:59 365"
1235 );
1236 assert_eq!(
1237 format_utc(-86_400, "%F %A").unwrap(),
1238 "1969-12-31 Wednesday"
1239 );
1240 }
1241
1242 const GEMMA3_TEMPLATE: &str = include_str!("../tests/templates/gemma-3-1b-it.jinja");
1247 const QWEN25_TEMPLATE: &str = include_str!("../tests/templates/qwen2.5-instruct.jinja");
1249 const GEMMA4_TEMPLATE_CORE: &str = include_str!("../tests/templates/gemma-4-E2B-it.jinja");
1256 const LLAMA31_TEMPLATE: &str = include_str!("../tests/templates/llama-3.1-8b-instruct.jinja");
1258}