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.add_function("raise_exception", raise_exception);
328 env.add_function("strftime_now", strftime_now);
329 env.add_filter("tojson", tojson);
330 env.set_unknown_method_callback(python_method);
331 env
332}
333
334fn tojson(value: JinjaValue) -> Result<String, minijinja::Error> {
349 let json: Value = serde_json::to_value(&value).map_err(|e| {
350 minijinja::Error::new(
351 minijinja::ErrorKind::InvalidOperation,
352 format!("tojson: value is not serialisable: {e}"),
353 )
354 })?;
355 let mut out = String::new();
356 write_python_json(&json, &mut out);
357 Ok(out)
358}
359
360fn write_python_json(v: &Value, out: &mut String) {
361 match v {
362 Value::Object(map) => {
363 let mut keys: Vec<&String> = map.keys().collect();
365 keys.sort();
366 out.push('{');
367 for (i, k) in keys.iter().enumerate() {
368 if i > 0 {
369 out.push_str(", ");
370 }
371 out.push_str(&Value::String((*k).clone()).to_string());
372 out.push_str(": ");
373 write_python_json(&map[*k], out);
374 }
375 out.push('}');
376 }
377 Value::Array(items) => {
378 out.push('[');
379 for (i, item) in items.iter().enumerate() {
380 if i > 0 {
381 out.push_str(", ");
382 }
383 write_python_json(item, out);
384 }
385 out.push(']');
386 }
387 other => out.push_str(&other.to_string()),
388 }
389}
390
391fn python_method(
403 _state: &minijinja::State,
404 value: &JinjaValue,
405 method: &str,
406 args: &[JinjaValue],
407) -> Result<JinjaValue, minijinja::Error> {
408 fn unknown() -> minijinja::Error {
409 minijinja::Error::from(minijinja::ErrorKind::UnknownMethod)
410 }
411 fn as_str(v: &JinjaValue) -> Result<&str, minijinja::Error> {
412 v.as_str().ok_or_else(|| {
413 minijinja::Error::new(
414 minijinja::ErrorKind::InvalidOperation,
415 "expected a string argument",
416 )
417 })
418 }
419 match method {
420 "get" => {
422 if value.as_object().is_none() {
423 return Err(unknown());
424 }
425 let (key, default) = match args {
426 [k] => (k, JinjaValue::from(())),
427 [k, d] => (k, d.clone()),
428 _ => {
429 return Err(minijinja::Error::new(
430 minijinja::ErrorKind::InvalidOperation,
431 "get() takes 1 or 2 arguments",
432 ))
433 }
434 };
435 Ok(value
436 .get_item(key)
437 .ok()
438 .filter(|v| !v.is_undefined())
439 .unwrap_or(default))
440 }
441 "split" => {
443 let s = value.as_str().ok_or_else(unknown)?;
444 let parts: Vec<JinjaValue> = match args {
445 [] => s.split_whitespace().map(JinjaValue::from).collect(),
446 [sep] => s.split(as_str(sep)?).map(JinjaValue::from).collect(),
447 _ => {
448 return Err(minijinja::Error::new(
449 minijinja::ErrorKind::InvalidOperation,
450 "ferrox implements split() with at most one separator argument",
451 ))
452 }
453 };
454 Ok(JinjaValue::from(parts))
455 }
456 "strip" | "lstrip" | "rstrip" => {
457 let s = value.as_str().ok_or_else(unknown)?;
458 let chars: Option<Vec<char>> = match args {
459 [] => None,
460 [c] => Some(as_str(c)?.chars().collect()),
461 _ => {
462 return Err(minijinja::Error::new(
463 minijinja::ErrorKind::InvalidOperation,
464 "strip() takes at most one argument",
465 ))
466 }
467 };
468 let pred = |c: char| match &chars {
469 Some(set) => set.contains(&c),
470 None => c.is_whitespace(),
471 };
472 Ok(JinjaValue::from(match method {
473 "strip" => s.trim_matches(pred),
474 "lstrip" => s.trim_start_matches(pred),
475 _ => s.trim_end_matches(pred),
476 }))
477 }
478 _ => Err(unknown()),
479 }
480}
481
482fn raise_exception(msg: String) -> Result<JinjaValue, minijinja::Error> {
487 Err(minijinja::Error::new(
488 minijinja::ErrorKind::InvalidOperation,
489 format!("template raised: {msg}"),
490 ))
491}
492
493fn strftime_now(fmt: String) -> Result<String, minijinja::Error> {
505 let secs = match std::env::var("FERROX_CHAT_TEMPLATE_NOW") {
506 Ok(v) => v.trim().parse::<i64>().map_err(|_| {
507 minijinja::Error::new(
508 minijinja::ErrorKind::InvalidOperation,
509 "FERROX_CHAT_TEMPLATE_NOW must be Unix seconds",
510 )
511 })?,
512 Err(_) => std::time::SystemTime::now()
513 .duration_since(std::time::UNIX_EPOCH)
514 .map(|d| d.as_secs() as i64)
515 .unwrap_or(0),
516 };
517 format_utc(secs, &fmt).map_err(|spec| {
518 minijinja::Error::new(
519 minijinja::ErrorKind::InvalidOperation,
520 format!(
521 "strftime_now: unsupported format specifier `%{spec}` in {fmt:?} -- \
522 ferrox implements a subset (%Y %y %m %d %e %H %M %S %j %B %b %A %a %F %T %%) \
523 and refuses rather than stamping a wrong date into the prompt"
524 ),
525 )
526 })
527}
528
529const MONTHS: [&str; 12] = [
530 "January",
531 "February",
532 "March",
533 "April",
534 "May",
535 "June",
536 "July",
537 "August",
538 "September",
539 "October",
540 "November",
541 "December",
542];
543const WEEKDAYS: [&str; 7] = [
544 "Thursday",
545 "Friday",
546 "Saturday",
547 "Sunday",
548 "Monday",
549 "Tuesday",
550 "Wednesday",
551];
552
553fn format_utc(secs: i64, fmt: &str) -> Result<String, char> {
557 let days = secs.div_euclid(86_400);
558 let tod = secs.rem_euclid(86_400);
559 let (hour, minute, second) = (tod / 3600, (tod % 3600) / 60, tod % 60);
560 let weekday = days.rem_euclid(7) as usize;
562
563 let z = days + 719_468;
564 let era = z.div_euclid(146_097);
565 let doe = z.rem_euclid(146_097);
566 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
567 let y = yoe + era * 400;
568 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
569 let mp = (5 * doy + 2) / 153;
570 let day = doy - (153 * mp + 2) / 5 + 1;
571 let month = if mp < 10 { mp + 3 } else { mp - 9 };
572 let year = if month <= 2 { y + 1 } else { y };
573
574 let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
576 const CUM: [i64; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
577 let yday = CUM[(month - 1) as usize] + day + i64::from(leap && month > 2);
578
579 let mut out = String::with_capacity(fmt.len() + 8);
580 let mut chars = fmt.chars().peekable();
581 while let Some(c) = chars.next() {
582 if c != '%' {
583 out.push(c);
584 continue;
585 }
586 let mut pad = true;
587 let mut spec = chars.next().ok_or('%')?;
588 if spec == '-' {
589 pad = false;
590 spec = chars.next().ok_or('-')?;
591 }
592 let num = |out: &mut String, v: i64, w: usize| {
593 if pad {
594 out.push_str(&format!("{v:0w$}"));
595 } else {
596 out.push_str(&v.to_string());
597 }
598 };
599 match spec {
600 'Y' => out.push_str(&year.to_string()),
601 'y' => num(&mut out, year.rem_euclid(100), 2),
602 'm' => num(&mut out, month, 2),
603 'd' => num(&mut out, day, 2),
604 'e' => out.push_str(&format!("{day:2}")),
606 'H' => num(&mut out, hour, 2),
607 'M' => num(&mut out, minute, 2),
608 'S' => num(&mut out, second, 2),
609 'j' => num(&mut out, yday, 3),
610 'B' => out.push_str(MONTHS[(month - 1) as usize]),
611 'b' => out.push_str(&MONTHS[(month - 1) as usize][..3]),
612 'A' => out.push_str(WEEKDAYS[weekday]),
613 'a' => out.push_str(&WEEKDAYS[weekday][..3]),
614 'F' => out.push_str(&format!("{year:04}-{month:02}-{day:02}")),
615 'T' => out.push_str(&format!("{hour:02}:{minute:02}:{second:02}")),
616 '%' => out.push('%'),
617 other => return Err(other),
618 }
619 }
620 Ok(out)
621}
622
623fn builtin_message_text(m: &Value) -> String {
628 let mut out = match m.get("content") {
629 Some(Value::String(s)) => s.clone(),
630 Some(Value::Array(parts)) => parts
631 .iter()
632 .filter_map(|p| p.get("text").and_then(Value::as_str))
633 .collect::<Vec<_>>()
634 .join(""),
635 _ => String::new(),
636 };
637 if let Some(Value::Array(calls)) = m.get("tool_calls") {
638 for call in calls {
639 let f = call.get("function");
640 let name = f
641 .and_then(|f| f.get("name"))
642 .and_then(Value::as_str)
643 .unwrap_or("");
644 let args = f
645 .and_then(|f| f.get("arguments"))
646 .map(|a| match a {
647 Value::String(s) => s.clone(),
648 other => other.to_string(),
649 })
650 .unwrap_or_else(|| "{}".to_string());
651 out.push_str(&format!(
652 "<tool_call>{{\"name\": \"{name}\", \"arguments\": {args}}}</tool_call>"
653 ));
654 }
655 }
656 out
657}
658
659fn builtin_role(m: &Value) -> &str {
660 m.get("role").and_then(Value::as_str).unwrap_or("user")
661}
662
663fn render_builtin(b: BuiltinTemplate, messages: &[Value], opts: &RenderOptions) -> String {
664 let mut out = String::new();
665 match b {
666 BuiltinTemplate::ChatMl => {
667 for m in messages {
668 out.push_str("<|im_start|>");
669 out.push_str(builtin_role(m));
670 out.push('\n');
671 out.push_str(&builtin_message_text(m));
672 out.push_str("<|im_end|>\n");
673 }
674 if opts.add_generation_prompt {
675 out.push_str("<|im_start|>assistant\n");
676 }
677 }
678 BuiltinTemplate::Plain => {
679 let lines: Vec<String> = messages
680 .iter()
681 .map(|m| format!("{}: {}", builtin_role(m), builtin_message_text(m)))
682 .collect();
683 out.push_str(&lines.join("\n"));
684 }
685 }
686 out
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692 use serde_json::json;
693
694 fn msg(role: &str, content: &str) -> Value {
695 json!({"role": role, "content": content})
696 }
697
698 fn opts() -> RenderOptions {
699 RenderOptions {
700 add_generation_prompt: true,
701 bos_token: Some("<s>".into()),
702 eos_token: Some("</s>".into()),
703 ..Default::default()
704 }
705 }
706
707 #[test]
712 fn renders_bos_token_and_the_real_mistral_inst_framing() {
713 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 %}";
714 let t = ChatTemplate::from_jinja(src).unwrap();
715 let out = t
716 .render(
717 &[
718 msg("user", "hi"),
719 msg("assistant", "hello"),
720 msg("user", "2+2?"),
721 ],
722 &opts(),
723 )
724 .unwrap();
725 assert_eq!(out, "<s>[INST] hi [/INST]hello</s>[INST] 2+2? [/INST]");
726 }
727
728 #[test]
731 fn mistral_is_not_plain_role_labelled_lines() {
732 let src = "{{ bos_token }}{% for message in messages %}{% if message['role'] == 'user' %}{{ '[INST] ' + message['content'] + ' [/INST]' }}{% endif %}{% endfor %}";
733 let out = ChatTemplate::from_jinja(src)
734 .unwrap()
735 .render(&[msg("user", "hi")], &opts())
736 .unwrap();
737 assert!(!out.contains("user: hi"), "{out}");
738 assert!(out.contains("[INST]"), "{out}");
739 }
740
741 #[test]
746 fn renders_a_system_message_with_the_real_gemma3_template() {
747 let src = GEMMA3_TEMPLATE;
748 let t = ChatTemplate::from_jinja(src).unwrap();
749 let out = t
750 .render(
751 &[msg("system", "be brief"), msg("user", "hi")],
752 &RenderOptions {
753 add_generation_prompt: true,
754 bos_token: Some("<bos>".into()),
755 ..Default::default()
756 },
757 )
758 .unwrap();
759 assert_eq!(
760 out,
761 "<bos><start_of_turn>user\nbe brief\n\nhi<end_of_turn>\n<start_of_turn>model\n"
762 );
763 }
764
765 #[test]
768 fn gemma3_emits_the_image_placeholder_for_content_parts() {
769 let out = ChatTemplate::from_jinja(GEMMA3_TEMPLATE)
770 .unwrap()
771 .render(
772 &[json!({"role": "user", "content": [
773 {"type": "image"},
774 {"type": "text", "text": "what is this?"}
775 ]})],
776 &RenderOptions {
777 add_generation_prompt: true,
778 bos_token: Some("<bos>".into()),
779 ..Default::default()
780 },
781 )
782 .unwrap();
783 assert_eq!(
784 out,
785 "<bos><start_of_turn>user\n<start_of_image>what is this?<end_of_turn>\n<start_of_turn>model\n"
786 );
787 }
788
789 #[test]
794 fn renders_a_tool_call_block_with_the_real_qwen25_template() {
795 let t = ChatTemplate::from_jinja(QWEN25_TEMPLATE).unwrap();
796 assert!(t.handles_tools());
797 let out = t
798 .render(
799 &[
800 msg("user", "weather in Paris?"),
801 json!({"role": "assistant", "content": "", "tool_calls": [
802 {"type": "function", "function": {"name": "get_weather", "arguments": {"city": "Paris"}}}
803 ]}),
804 json!({"role": "tool", "content": "18C"}),
805 ],
806 &RenderOptions {
807 add_generation_prompt: true,
808 tools: vec![json!({"type": "function", "function": {
809 "name": "get_weather",
810 "description": "Current weather",
811 "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
812 }})],
813 ..Default::default()
814 },
815 )
816 .unwrap();
817 assert_eq!(
818 out,
819 concat!(
820 "<|im_start|>system\n",
821 "You are Qwen, created by Alibaba Cloud. You are a helpful assistant.\n\n",
822 "# Tools\n\n",
823 "You may call one or more functions to assist with the user query.\n\n",
824 "You are provided with function signatures within <tools></tools> XML tags:\n",
825 "<tools>\n",
826 "{\"function\": {\"description\": \"Current weather\", \"name\": \"get_weather\", ",
829 "\"parameters\": {\"properties\": {\"city\": {\"type\": \"string\"}}, ",
830 "\"type\": \"object\"}}, \"type\": \"function\"}\n",
831 "</tools>\n\n",
832 "For each function call, return a json object with function name and arguments ",
833 "within <tool_call></tool_call> XML tags:\n",
834 "<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n",
835 "</tool_call><|im_end|>\n",
836 "<|im_start|>user\nweather in Paris?<|im_end|>\n",
837 "<|im_start|>assistant\n",
838 "<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Paris\"}}\n",
839 "</tool_call><|im_end|>\n",
840 "<|im_start|>user\n<tool_response>\n18C\n</tool_response><|im_end|>\n",
841 "<|im_start|>assistant\n",
842 )
843 );
844 }
845
846 #[test]
851 fn add_generation_prompt_is_honoured_both_ways() {
852 let t = ChatTemplate::from_jinja(GEMMA3_TEMPLATE).unwrap();
853 let with = t
854 .render(
855 &[msg("user", "hi")],
856 &RenderOptions {
857 add_generation_prompt: true,
858 ..Default::default()
859 },
860 )
861 .unwrap();
862 let without = t
863 .render(
864 &[msg("user", "hi")],
865 &RenderOptions {
866 add_generation_prompt: false,
867 ..Default::default()
868 },
869 )
870 .unwrap();
871 assert_eq!(
872 with,
873 "<start_of_turn>user\nhi<end_of_turn>\n<start_of_turn>model\n"
874 );
875 assert_eq!(without, "<start_of_turn>user\nhi<end_of_turn>\n");
876 }
877
878 #[test]
885 fn llama31_binds_tools_to_null_so_a_plain_chat_gets_no_tool_preamble() {
886 let out = ChatTemplate::from_jinja(LLAMA31_TEMPLATE)
887 .unwrap()
888 .render(
889 &[msg("system", "be brief"), msg("user", "hi")],
890 &RenderOptions {
891 add_generation_prompt: true,
892 bos_token: Some("<|begin_of_text|>".into()),
893 ..Default::default()
894 },
895 )
896 .unwrap();
897 assert_eq!(
898 out,
899 concat!(
900 "<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n",
901 "Cutting Knowledge Date: December 2023\n",
902 "Today Date: 26 Jul 2024\n\n",
903 "be brief<|eot_id|>",
904 "<|start_header_id|>user<|end_header_id|>\n\nhi<|eot_id|>",
905 "<|start_header_id|>assistant<|end_header_id|>\n\n",
906 )
907 );
908 assert!(!out.contains("Environment: ipython"), "{out}");
909 }
910
911 #[test]
914 fn raise_exception_fails_the_render_and_keeps_the_message() {
915 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 %}";
916 let err = ChatTemplate::from_jinja(src)
917 .unwrap()
918 .render(&[msg("assistant", "oops")], &opts())
919 .unwrap_err();
920 let text = err.to_string();
921 assert!(matches!(err, TemplateError::Render(_)), "{text}");
922 assert!(text.contains("roles must alternate"), "{text}");
923 }
924
925 #[test]
926 fn strftime_now_stamps_a_pinned_clock() {
927 std::env::set_var("FERROX_CHAT_TEMPLATE_NOW", "1720096496");
929 let out = ChatTemplate::from_jinja(
930 "{{ strftime_now(\"%d %b %Y\") }}|{{ strftime_now('%A %F %T %j %-d') }}",
931 )
932 .unwrap()
933 .render(&[], &opts())
934 .unwrap();
935 std::env::remove_var("FERROX_CHAT_TEMPLATE_NOW");
936 assert_eq!(out, "04 Jul 2024|Thursday 2024-07-04 12:34:56 186 4");
937 }
938
939 #[test]
940 fn strftime_now_refuses_an_unimplemented_specifier() {
941 let err = ChatTemplate::from_jinja("{{ strftime_now('%Z') }}")
942 .unwrap()
943 .render(&[], &opts())
944 .unwrap_err();
945 assert!(
946 err.to_string().contains("unsupported format specifier"),
947 "{err}"
948 );
949 }
950
951 #[test]
955 fn whitespace_control_is_respected() {
956 let out = ChatTemplate::from_jinja(
957 "{%- for m in messages -%}\n {{- m['role'] -}}\n{%- endfor -%}",
958 )
959 .unwrap()
960 .render(&[msg("user", "x"), msg("assistant", "y")], &opts())
961 .unwrap();
962 assert_eq!(out, "userassistant");
963 }
964
965 #[test]
969 fn namespace_writes_escape_loop_scope() {
970 let out = ChatTemplate::from_jinja(
971 "{%- set ns = namespace(n=0) -%}{%- for m in messages -%}{%- set ns.n = ns.n + 1 -%}{%- endfor -%}{{ ns.n }}",
972 )
973 .unwrap()
974 .render(&[msg("user", "a"), msg("user", "b"), msg("user", "c")], &opts())
975 .unwrap();
976 assert_eq!(out, "3");
977 }
978
979 #[test]
981 fn an_unsupported_construct_fails_loudly() {
982 let err = ChatTemplate::from_jinja("{{ messages | no_such_filter }}")
983 .unwrap()
984 .render(&[msg("user", "hi")], &opts())
985 .unwrap_err();
986 let text = err.to_string();
987 assert!(matches!(err, TemplateError::Render(_)), "{text}");
988 assert!(text.contains("no_such_filter"), "{text}");
989 }
990
991 #[test]
992 fn a_template_that_does_not_compile_is_recorded_not_replaced() {
993 let t = ChatTemplate::from_gguf_metadata(
994 Some("{% for m in messages %}{{ m }}"),
995 Some("llama"),
996 false,
997 );
998 assert!(!t.is_jinja());
999 let err = t.render(&[msg("user", "hi")], &opts()).unwrap_err();
1000 assert!(matches!(err, TemplateError::Compile(_)), "{err}");
1001 assert!(t.describe().starts_with("BROKEN"), "{}", t.describe());
1003 }
1004
1005 #[test]
1008 fn chat_template_kwargs_reach_the_template() {
1009 let src = "{%- if enable_thinking -%}THINK{%- else -%}PLAIN{%- endif -%}";
1010 let t = ChatTemplate::from_jinja(src).unwrap();
1011 let mut extra = serde_json::Map::new();
1012 extra.insert("enable_thinking".into(), Value::Bool(true));
1013 let on = t
1014 .render(
1015 &[msg("user", "hi")],
1016 &RenderOptions {
1017 extra,
1018 ..Default::default()
1019 },
1020 )
1021 .unwrap();
1022 let off = t
1023 .render(&[msg("user", "hi")], &RenderOptions::default())
1024 .unwrap();
1025 assert_eq!((on.as_str(), off.as_str()), ("THINK", "PLAIN"));
1026 }
1027
1028 #[test]
1029 fn chat_template_kwargs_cannot_shadow_messages_or_tools() {
1030 let mut extra = serde_json::Map::new();
1031 extra.insert(
1032 "messages".into(),
1033 json!([{"role": "user", "content": "INJECTED"}]),
1034 );
1035 extra.insert("add_generation_prompt".into(), Value::Bool(true));
1036 let out = ChatTemplate::from_jinja(
1037 "{%- for m in messages -%}{{ m['content'] }}{%- endfor -%}|{{ add_generation_prompt }}",
1038 )
1039 .unwrap()
1040 .render(
1041 &[msg("user", "real")],
1042 &RenderOptions {
1043 add_generation_prompt: false,
1044 extra,
1045 ..Default::default()
1046 },
1047 )
1048 .unwrap();
1049 assert_eq!(out, "real|false");
1050 }
1051
1052 #[test]
1060 fn gemma4_thinking_injection_is_reachable_now() {
1061 let t = ChatTemplate::from_jinja(GEMMA4_TEMPLATE_CORE).unwrap();
1062 let mut extra = serde_json::Map::new();
1063 extra.insert("enable_thinking".into(), Value::Bool(true));
1064 let thinking = t
1065 .render(
1066 &[msg("user", "hi")],
1067 &RenderOptions {
1068 add_generation_prompt: true,
1069 bos_token: Some("<bos>".into()),
1070 extra,
1071 ..Default::default()
1072 },
1073 )
1074 .unwrap();
1075 assert_eq!(
1076 thinking,
1077 "<bos><|turn>system\n<|think|>\n<turn|>\n<|turn>user\nhi<turn|>\n<|turn>model\n"
1078 );
1079 let plain = t
1080 .render(
1081 &[msg("user", "hi")],
1082 &RenderOptions {
1083 add_generation_prompt: true,
1084 bos_token: Some("<bos>".into()),
1085 ..Default::default()
1086 },
1087 )
1088 .unwrap();
1089 assert_eq!(plain, "<bos><|turn>user\nhi<turn|>\n<|turn>model\n");
1090 }
1091
1092 #[test]
1096 fn gemma4_strip_thinking_removes_replayed_reasoning() {
1097 let out = ChatTemplate::from_jinja(GEMMA4_TEMPLATE_CORE)
1098 .unwrap()
1099 .render(
1100 &[
1101 msg("user", "hi"),
1102 msg(
1103 "assistant",
1104 "<|channel>thought\nlet me think<channel|>the answer is 4",
1105 ),
1106 msg("user", "again?"),
1107 ],
1108 &RenderOptions {
1109 add_generation_prompt: true,
1110 bos_token: Some("<bos>".into()),
1111 ..Default::default()
1112 },
1113 )
1114 .unwrap();
1115 assert!(!out.contains("let me think"), "{out}");
1116 assert!(
1117 out.contains("<|turn>model\nthe answer is 4<turn|>\n"),
1118 "{out}"
1119 );
1120 }
1121
1122 #[test]
1125 fn a_checkpoint_with_no_template_gets_chatml_or_plain() {
1126 assert!(matches!(
1127 &*ChatTemplate::from_gguf_metadata(None, Some("olmoe"), false).0,
1128 Kind::Builtin(BuiltinTemplate::ChatMl)
1129 ));
1130 assert!(matches!(
1131 &*ChatTemplate::from_gguf_metadata(Some(" "), Some("olmoe"), false).0,
1132 Kind::Builtin(BuiltinTemplate::ChatMl)
1133 ));
1134 assert!(matches!(
1135 &*ChatTemplate::from_gguf_metadata(None, Some("olmoe"), true).0,
1136 Kind::Builtin(BuiltinTemplate::Plain)
1137 ));
1138 assert!(matches!(
1139 &*ChatTemplate::from_gguf_metadata(None, None, false).0,
1140 Kind::Builtin(BuiltinTemplate::Plain)
1141 ));
1142 }
1143
1144 #[test]
1145 fn builtin_chatml_and_plain_render_as_before() {
1146 let msgs = [msg("system", "be helpful"), msg("user", "hi")];
1147 assert_eq!(
1148 ChatTemplate::builtin(BuiltinTemplate::ChatMl)
1149 .render(&msgs, &opts())
1150 .unwrap(),
1151 "<|im_start|>system\nbe helpful<|im_end|>\n<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\n"
1152 );
1153 assert_eq!(
1154 ChatTemplate::builtin(BuiltinTemplate::Plain)
1155 .render(&msgs, &opts())
1156 .unwrap(),
1157 "system: be helpful\nuser: hi"
1158 );
1159 }
1160
1161 #[test]
1162 fn builtin_renders_replayed_tool_calls_as_marker_text() {
1163 let msgs = [json!({"role": "assistant", "tool_calls": [
1164 {"function": {"name": "f", "arguments": "{\"a\": 1}"}}
1165 ]})];
1166 assert_eq!(
1167 ChatTemplate::builtin(BuiltinTemplate::Plain)
1168 .render(&msgs, &opts())
1169 .unwrap(),
1170 "assistant: <tool_call>{\"name\": \"f\", \"arguments\": {\"a\": 1}}</tool_call>"
1171 );
1172 }
1173
1174 #[test]
1175 fn handles_tools_is_a_property_of_the_template_not_a_guess() {
1176 assert!(ChatTemplate::from_jinja(QWEN25_TEMPLATE)
1177 .unwrap()
1178 .handles_tools());
1179 assert!(!ChatTemplate::from_jinja(GEMMA3_TEMPLATE)
1180 .unwrap()
1181 .handles_tools());
1182 assert!(!ChatTemplate::builtin(BuiltinTemplate::ChatMl).handles_tools());
1183 }
1184
1185 #[test]
1186 fn utc_calendar_math_matches_known_dates() {
1187 assert_eq!(
1188 format_utc(0, "%F %T %A %j").unwrap(),
1189 "1970-01-01 00:00:00 Thursday 001"
1190 );
1191 assert_eq!(
1192 format_utc(951_782_400, "%F %A %j").unwrap(),
1193 "2000-02-29 Tuesday 060"
1194 );
1195 assert_eq!(
1196 format_utc(1_709_164_800, "%F %A %j").unwrap(),
1197 "2024-02-29 Thursday 060"
1198 );
1199 assert_eq!(
1200 format_utc(1_767_225_599, "%F %T %j").unwrap(),
1201 "2025-12-31 23:59:59 365"
1202 );
1203 assert_eq!(
1204 format_utc(-86_400, "%F %A").unwrap(),
1205 "1969-12-31 Wednesday"
1206 );
1207 }
1208
1209 const GEMMA3_TEMPLATE: &str = include_str!("../tests/templates/gemma-3-1b-it.jinja");
1214 const QWEN25_TEMPLATE: &str = include_str!("../tests/templates/qwen2.5-instruct.jinja");
1216 const GEMMA4_TEMPLATE_CORE: &str = include_str!("../tests/templates/gemma-4-E2B-it.jinja");
1223 const LLAMA31_TEMPLATE: &str = include_str!("../tests/templates/llama-3.1-8b-instruct.jinja");
1225}