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 frink_gguf::TensorSource) -> bool {
226 let Some(frink_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 "frink 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 "startswith" | "endswith" => {
558 let s = value.as_str().ok_or_else(unknown)?;
559 let [needle] = args else {
560 return Err(minijinja::Error::new(
561 minijinja::ErrorKind::InvalidOperation,
562 format!("{method}() takes exactly one argument"),
563 ));
564 };
565 let test = |n: &str| {
566 if method == "startswith" {
567 s.starts_with(n)
568 } else {
569 s.ends_with(n)
570 }
571 };
572 let hit = if let Some(n) = needle.as_str() {
573 test(n)
574 } else if let Ok(iter) = needle.try_iter() {
575 let mut any = false;
576 for n in iter {
577 any |= test(as_str(&n)?);
578 }
579 any
580 } else {
581 return Err(minijinja::Error::new(
582 minijinja::ErrorKind::InvalidOperation,
583 format!("{method}() takes a string or a sequence of strings"),
584 ));
585 };
586 Ok(JinjaValue::from(hit))
587 }
588 _ => Err(unknown()),
589 }
590}
591
592fn raise_exception(msg: String) -> Result<JinjaValue, minijinja::Error> {
597 Err(minijinja::Error::new(
598 minijinja::ErrorKind::InvalidOperation,
599 format!("template raised: {msg}"),
600 ))
601}
602
603fn strftime_now(fmt: String) -> Result<String, minijinja::Error> {
615 let secs = match std::env::var("FRINK_TEST_CHAT_TEMPLATE_NOW") {
616 Ok(v) => v.trim().parse::<i64>().map_err(|_| {
617 minijinja::Error::new(
618 minijinja::ErrorKind::InvalidOperation,
619 "FRINK_TEST_CHAT_TEMPLATE_NOW must be Unix seconds",
620 )
621 })?,
622 Err(_) => std::time::SystemTime::now()
623 .duration_since(std::time::UNIX_EPOCH)
624 .map(|d| d.as_secs() as i64)
625 .unwrap_or(0),
626 };
627 format_utc(secs, &fmt).map_err(|spec| {
628 minijinja::Error::new(
629 minijinja::ErrorKind::InvalidOperation,
630 format!(
631 "strftime_now: unsupported format specifier `%{spec}` in {fmt:?} -- \
632 frink implements a subset (%Y %y %m %d %e %H %M %S %j %B %b %A %a %F %T %%) \
633 and refuses rather than stamping a wrong date into the prompt"
634 ),
635 )
636 })
637}
638
639const MONTHS: [&str; 12] = [
640 "January",
641 "February",
642 "March",
643 "April",
644 "May",
645 "June",
646 "July",
647 "August",
648 "September",
649 "October",
650 "November",
651 "December",
652];
653const WEEKDAYS: [&str; 7] = [
654 "Thursday",
655 "Friday",
656 "Saturday",
657 "Sunday",
658 "Monday",
659 "Tuesday",
660 "Wednesday",
661];
662
663fn format_utc(secs: i64, fmt: &str) -> Result<String, char> {
667 let days = secs.div_euclid(86_400);
668 let tod = secs.rem_euclid(86_400);
669 let (hour, minute, second) = (tod / 3600, (tod % 3600) / 60, tod % 60);
670 let weekday = days.rem_euclid(7) as usize;
672
673 let z = days + 719_468;
674 let era = z.div_euclid(146_097);
675 let doe = z.rem_euclid(146_097);
676 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
677 let y = yoe + era * 400;
678 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
679 let mp = (5 * doy + 2) / 153;
680 let day = doy - (153 * mp + 2) / 5 + 1;
681 let month = if mp < 10 { mp + 3 } else { mp - 9 };
682 let year = if month <= 2 { y + 1 } else { y };
683
684 let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
686 const CUM: [i64; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
687 let yday = CUM[(month - 1) as usize] + day + i64::from(leap && month > 2);
688
689 let mut out = String::with_capacity(fmt.len() + 8);
690 let mut chars = fmt.chars().peekable();
691 while let Some(c) = chars.next() {
692 if c != '%' {
693 out.push(c);
694 continue;
695 }
696 let mut pad = true;
697 let mut spec = chars.next().ok_or('%')?;
698 if spec == '-' {
699 pad = false;
700 spec = chars.next().ok_or('-')?;
701 }
702 let num = |out: &mut String, v: i64, w: usize| {
703 if pad {
704 out.push_str(&format!("{v:0w$}"));
705 } else {
706 out.push_str(&v.to_string());
707 }
708 };
709 match spec {
710 'Y' => out.push_str(&year.to_string()),
711 'y' => num(&mut out, year.rem_euclid(100), 2),
712 'm' => num(&mut out, month, 2),
713 'd' => num(&mut out, day, 2),
714 'e' => out.push_str(&format!("{day:2}")),
716 'H' => num(&mut out, hour, 2),
717 'M' => num(&mut out, minute, 2),
718 'S' => num(&mut out, second, 2),
719 'j' => num(&mut out, yday, 3),
720 'B' => out.push_str(MONTHS[(month - 1) as usize]),
721 'b' => out.push_str(&MONTHS[(month - 1) as usize][..3]),
722 'A' => out.push_str(WEEKDAYS[weekday]),
723 'a' => out.push_str(&WEEKDAYS[weekday][..3]),
724 'F' => out.push_str(&format!("{year:04}-{month:02}-{day:02}")),
725 'T' => out.push_str(&format!("{hour:02}:{minute:02}:{second:02}")),
726 '%' => out.push('%'),
727 other => return Err(other),
728 }
729 }
730 Ok(out)
731}
732
733fn builtin_message_text(m: &Value) -> String {
738 let mut out = match m.get("content") {
739 Some(Value::String(s)) => s.clone(),
740 Some(Value::Array(parts)) => parts
741 .iter()
742 .filter_map(|p| p.get("text").and_then(Value::as_str))
743 .collect::<Vec<_>>()
744 .join(""),
745 _ => String::new(),
746 };
747 if let Some(Value::Array(calls)) = m.get("tool_calls") {
748 for call in calls {
749 let f = call.get("function");
750 let name = f
751 .and_then(|f| f.get("name"))
752 .and_then(Value::as_str)
753 .unwrap_or("");
754 let args = f
755 .and_then(|f| f.get("arguments"))
756 .map(|a| match a {
757 Value::String(s) => s.clone(),
758 other => other.to_string(),
759 })
760 .unwrap_or_else(|| "{}".to_string());
761 out.push_str(&format!(
762 "<tool_call>{{\"name\": \"{name}\", \"arguments\": {args}}}</tool_call>"
763 ));
764 }
765 }
766 out
767}
768
769fn builtin_role(m: &Value) -> &str {
770 m.get("role").and_then(Value::as_str).unwrap_or("user")
771}
772
773fn render_builtin(b: BuiltinTemplate, messages: &[Value], opts: &RenderOptions) -> String {
774 let mut out = String::new();
775 match b {
776 BuiltinTemplate::ChatMl => {
777 for m in messages {
778 out.push_str("<|im_start|>");
779 out.push_str(builtin_role(m));
780 out.push('\n');
781 out.push_str(&builtin_message_text(m));
782 out.push_str("<|im_end|>\n");
783 }
784 if opts.add_generation_prompt {
785 out.push_str("<|im_start|>assistant\n");
786 }
787 }
788 BuiltinTemplate::Plain => {
789 let lines: Vec<String> = messages
790 .iter()
791 .map(|m| format!("{}: {}", builtin_role(m), builtin_message_text(m)))
792 .collect();
793 out.push_str(&lines.join("\n"));
794 }
795 }
796 out
797}
798
799#[cfg(test)]
800mod tests {
801
802 #[test]
813 fn a_vocab_without_chatml_markers_falls_back_to_plain() {
814 let without = ChatTemplate::from_gguf_metadata(None, Some("olmoe"), false, false);
815 let with = ChatTemplate::from_gguf_metadata(None, Some("olmoe"), false, true);
816 assert_ne!(
817 without.describe(),
818 with.describe(),
819 "the ChatML fallback must depend on the markers actually existing"
820 );
821 assert!(
822 !without.describe().to_lowercase().contains("chatml"),
823 "got {} for a vocab with no ChatML tokens",
824 without.describe()
825 );
826 }
827 use super::*;
828 use serde_json::json;
829
830 fn msg(role: &str, content: &str) -> Value {
831 json!({"role": role, "content": content})
832 }
833
834 fn opts() -> RenderOptions {
835 RenderOptions {
836 add_generation_prompt: true,
837 bos_token: Some("<s>".into()),
838 eos_token: Some("</s>".into()),
839 ..Default::default()
840 }
841 }
842
843 #[test]
848 fn renders_bos_token_and_the_real_mistral_inst_framing() {
849 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 %}";
850 let t = ChatTemplate::from_jinja(src).unwrap();
851 let out = t
852 .render(
853 &[
854 msg("user", "hi"),
855 msg("assistant", "hello"),
856 msg("user", "2+2?"),
857 ],
858 &opts(),
859 )
860 .unwrap();
861 assert_eq!(out, "<s>[INST] hi [/INST]hello</s>[INST] 2+2? [/INST]");
862 }
863
864 #[test]
867 fn mistral_is_not_plain_role_labelled_lines() {
868 let src = "{{ bos_token }}{% for message in messages %}{% if message['role'] == 'user' %}{{ '[INST] ' + message['content'] + ' [/INST]' }}{% endif %}{% endfor %}";
869 let out = ChatTemplate::from_jinja(src)
870 .unwrap()
871 .render(&[msg("user", "hi")], &opts())
872 .unwrap();
873 assert!(!out.contains("user: hi"), "{out}");
874 assert!(out.contains("[INST]"), "{out}");
875 }
876
877 #[test]
882 fn renders_a_system_message_with_the_real_gemma3_template() {
883 let src = GEMMA3_TEMPLATE;
884 let t = ChatTemplate::from_jinja(src).unwrap();
885 let out = t
886 .render(
887 &[msg("system", "be brief"), msg("user", "hi")],
888 &RenderOptions {
889 add_generation_prompt: true,
890 bos_token: Some("<bos>".into()),
891 ..Default::default()
892 },
893 )
894 .unwrap();
895 assert_eq!(
896 out,
897 "<bos><start_of_turn>user\nbe brief\n\nhi<end_of_turn>\n<start_of_turn>model\n"
898 );
899 }
900
901 #[test]
904 fn gemma3_emits_the_image_placeholder_for_content_parts() {
905 let out = ChatTemplate::from_jinja(GEMMA3_TEMPLATE)
906 .unwrap()
907 .render(
908 &[json!({"role": "user", "content": [
909 {"type": "image"},
910 {"type": "text", "text": "what is this?"}
911 ]})],
912 &RenderOptions {
913 add_generation_prompt: true,
914 bos_token: Some("<bos>".into()),
915 ..Default::default()
916 },
917 )
918 .unwrap();
919 assert_eq!(
920 out,
921 "<bos><start_of_turn>user\n<start_of_image>what is this?<end_of_turn>\n<start_of_turn>model\n"
922 );
923 }
924
925 #[test]
930 fn renders_a_tool_call_block_with_the_real_qwen25_template() {
931 let t = ChatTemplate::from_jinja(QWEN25_TEMPLATE).unwrap();
932 assert!(t.handles_tools());
933 let out = t
934 .render(
935 &[
936 msg("user", "weather in Paris?"),
937 json!({"role": "assistant", "content": "", "tool_calls": [
938 {"type": "function", "function": {"name": "get_weather", "arguments": {"city": "Paris"}}}
939 ]}),
940 json!({"role": "tool", "content": "18C"}),
941 ],
942 &RenderOptions {
943 add_generation_prompt: true,
944 tools: vec![json!({"type": "function", "function": {
945 "name": "get_weather",
946 "description": "Current weather",
947 "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
948 }})],
949 ..Default::default()
950 },
951 )
952 .unwrap();
953 assert_eq!(
954 out,
955 concat!(
956 "<|im_start|>system\n",
957 "You are Qwen, created by Alibaba Cloud. You are a helpful assistant.\n\n",
958 "# Tools\n\n",
959 "You may call one or more functions to assist with the user query.\n\n",
960 "You are provided with function signatures within <tools></tools> XML tags:\n",
961 "<tools>\n",
962 "{\"function\": {\"description\": \"Current weather\", \"name\": \"get_weather\", ",
965 "\"parameters\": {\"properties\": {\"city\": {\"type\": \"string\"}}, ",
966 "\"type\": \"object\"}}, \"type\": \"function\"}\n",
967 "</tools>\n\n",
968 "For each function call, return a json object with function name and arguments ",
969 "within <tool_call></tool_call> XML tags:\n",
970 "<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n",
971 "</tool_call><|im_end|>\n",
972 "<|im_start|>user\nweather in Paris?<|im_end|>\n",
973 "<|im_start|>assistant\n",
974 "<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Paris\"}}\n",
975 "</tool_call><|im_end|>\n",
976 "<|im_start|>user\n<tool_response>\n18C\n</tool_response><|im_end|>\n",
977 "<|im_start|>assistant\n",
978 )
979 );
980 }
981
982 #[test]
987 fn add_generation_prompt_is_honoured_both_ways() {
988 let t = ChatTemplate::from_jinja(GEMMA3_TEMPLATE).unwrap();
989 let with = t
990 .render(
991 &[msg("user", "hi")],
992 &RenderOptions {
993 add_generation_prompt: true,
994 ..Default::default()
995 },
996 )
997 .unwrap();
998 let without = t
999 .render(
1000 &[msg("user", "hi")],
1001 &RenderOptions {
1002 add_generation_prompt: false,
1003 ..Default::default()
1004 },
1005 )
1006 .unwrap();
1007 assert_eq!(
1008 with,
1009 "<start_of_turn>user\nhi<end_of_turn>\n<start_of_turn>model\n"
1010 );
1011 assert_eq!(without, "<start_of_turn>user\nhi<end_of_turn>\n");
1012 }
1013
1014 #[test]
1021 fn llama31_binds_tools_to_null_so_a_plain_chat_gets_no_tool_preamble() {
1022 let out = ChatTemplate::from_jinja(LLAMA31_TEMPLATE)
1023 .unwrap()
1024 .render(
1025 &[msg("system", "be brief"), msg("user", "hi")],
1026 &RenderOptions {
1027 add_generation_prompt: true,
1028 bos_token: Some("<|begin_of_text|>".into()),
1029 ..Default::default()
1030 },
1031 )
1032 .unwrap();
1033 assert_eq!(
1034 out,
1035 concat!(
1036 "<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n",
1037 "Cutting Knowledge Date: December 2023\n",
1038 "Today Date: 26 Jul 2024\n\n",
1039 "be brief<|eot_id|>",
1040 "<|start_header_id|>user<|end_header_id|>\n\nhi<|eot_id|>",
1041 "<|start_header_id|>assistant<|end_header_id|>\n\n",
1042 )
1043 );
1044 assert!(!out.contains("Environment: ipython"), "{out}");
1045 }
1046
1047 #[test]
1050 fn raise_exception_fails_the_render_and_keeps_the_message() {
1051 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 %}";
1052 let err = ChatTemplate::from_jinja(src)
1053 .unwrap()
1054 .render(&[msg("assistant", "oops")], &opts())
1055 .unwrap_err();
1056 let text = err.to_string();
1057 assert!(matches!(err, TemplateError::Render(_)), "{text}");
1058 assert!(text.contains("roles must alternate"), "{text}");
1059 }
1060
1061 #[test]
1062 fn strftime_now_stamps_a_pinned_clock() {
1063 std::env::set_var("FRINK_TEST_CHAT_TEMPLATE_NOW", "1720096496");
1065 let out = ChatTemplate::from_jinja(
1066 "{{ strftime_now(\"%d %b %Y\") }}|{{ strftime_now('%A %F %T %j %-d') }}",
1067 )
1068 .unwrap()
1069 .render(&[], &opts())
1070 .unwrap();
1071 std::env::remove_var("FRINK_TEST_CHAT_TEMPLATE_NOW");
1072 assert_eq!(out, "04 Jul 2024|Thursday 2024-07-04 12:34:56 186 4");
1073 }
1074
1075 #[test]
1076 fn strftime_now_refuses_an_unimplemented_specifier() {
1077 let err = ChatTemplate::from_jinja("{{ strftime_now('%Z') }}")
1078 .unwrap()
1079 .render(&[], &opts())
1080 .unwrap_err();
1081 assert!(
1082 err.to_string().contains("unsupported format specifier"),
1083 "{err}"
1084 );
1085 }
1086
1087 #[test]
1091 fn whitespace_control_is_respected() {
1092 let out = ChatTemplate::from_jinja(
1093 "{%- for m in messages -%}\n {{- m['role'] -}}\n{%- endfor -%}",
1094 )
1095 .unwrap()
1096 .render(&[msg("user", "x"), msg("assistant", "y")], &opts())
1097 .unwrap();
1098 assert_eq!(out, "userassistant");
1099 }
1100
1101 #[test]
1105 fn namespace_writes_escape_loop_scope() {
1106 let out = ChatTemplate::from_jinja(
1107 "{%- set ns = namespace(n=0) -%}{%- for m in messages -%}{%- set ns.n = ns.n + 1 -%}{%- endfor -%}{{ ns.n }}",
1108 )
1109 .unwrap()
1110 .render(&[msg("user", "a"), msg("user", "b"), msg("user", "c")], &opts())
1111 .unwrap();
1112 assert_eq!(out, "3");
1113 }
1114
1115 #[test]
1117 fn an_unsupported_construct_fails_loudly() {
1118 let err = ChatTemplate::from_jinja("{{ messages | no_such_filter }}")
1119 .unwrap()
1120 .render(&[msg("user", "hi")], &opts())
1121 .unwrap_err();
1122 let text = err.to_string();
1123 assert!(matches!(err, TemplateError::Render(_)), "{text}");
1124 assert!(text.contains("no_such_filter"), "{text}");
1125 }
1126
1127 #[test]
1128 fn a_template_that_does_not_compile_is_recorded_not_replaced() {
1129 let t = ChatTemplate::from_gguf_metadata(
1130 Some("{% for m in messages %}{{ m }}"),
1131 Some("llama"),
1132 false,
1133 true,
1134 );
1135 assert!(!t.is_jinja());
1136 let err = t.render(&[msg("user", "hi")], &opts()).unwrap_err();
1137 assert!(matches!(err, TemplateError::Compile(_)), "{err}");
1138 assert!(t.describe().starts_with("BROKEN"), "{}", t.describe());
1140 }
1141
1142 #[test]
1145 fn chat_template_kwargs_reach_the_template() {
1146 let src = "{%- if enable_thinking -%}THINK{%- else -%}PLAIN{%- endif -%}";
1147 let t = ChatTemplate::from_jinja(src).unwrap();
1148 let mut extra = serde_json::Map::new();
1149 extra.insert("enable_thinking".into(), Value::Bool(true));
1150 let on = t
1151 .render(
1152 &[msg("user", "hi")],
1153 &RenderOptions {
1154 extra,
1155 ..Default::default()
1156 },
1157 )
1158 .unwrap();
1159 let off = t
1160 .render(&[msg("user", "hi")], &RenderOptions::default())
1161 .unwrap();
1162 assert_eq!((on.as_str(), off.as_str()), ("THINK", "PLAIN"));
1163 }
1164
1165 #[test]
1166 fn chat_template_kwargs_cannot_shadow_messages_or_tools() {
1167 let mut extra = serde_json::Map::new();
1168 extra.insert(
1169 "messages".into(),
1170 json!([{"role": "user", "content": "INJECTED"}]),
1171 );
1172 extra.insert("add_generation_prompt".into(), Value::Bool(true));
1173 let out = ChatTemplate::from_jinja(
1174 "{%- for m in messages -%}{{ m['content'] }}{%- endfor -%}|{{ add_generation_prompt }}",
1175 )
1176 .unwrap()
1177 .render(
1178 &[msg("user", "real")],
1179 &RenderOptions {
1180 add_generation_prompt: false,
1181 extra,
1182 ..Default::default()
1183 },
1184 )
1185 .unwrap();
1186 assert_eq!(out, "real|false");
1187 }
1188
1189 #[test]
1197 fn gemma4_thinking_injection_is_reachable_now() {
1198 let t = ChatTemplate::from_jinja(GEMMA4_TEMPLATE_CORE).unwrap();
1199 let mut extra = serde_json::Map::new();
1200 extra.insert("enable_thinking".into(), Value::Bool(true));
1201 let thinking = t
1202 .render(
1203 &[msg("user", "hi")],
1204 &RenderOptions {
1205 add_generation_prompt: true,
1206 bos_token: Some("<bos>".into()),
1207 extra,
1208 ..Default::default()
1209 },
1210 )
1211 .unwrap();
1212 assert_eq!(
1213 thinking,
1214 "<bos><|turn>system\n<|think|>\n<turn|>\n<|turn>user\nhi<turn|>\n<|turn>model\n"
1215 );
1216 let plain = t
1217 .render(
1218 &[msg("user", "hi")],
1219 &RenderOptions {
1220 add_generation_prompt: true,
1221 bos_token: Some("<bos>".into()),
1222 ..Default::default()
1223 },
1224 )
1225 .unwrap();
1226 assert_eq!(plain, "<bos><|turn>user\nhi<turn|>\n<|turn>model\n");
1227 }
1228
1229 #[test]
1233 fn gemma4_strip_thinking_removes_replayed_reasoning() {
1234 let out = ChatTemplate::from_jinja(GEMMA4_TEMPLATE_CORE)
1235 .unwrap()
1236 .render(
1237 &[
1238 msg("user", "hi"),
1239 msg(
1240 "assistant",
1241 "<|channel>thought\nlet me think<channel|>the answer is 4",
1242 ),
1243 msg("user", "again?"),
1244 ],
1245 &RenderOptions {
1246 add_generation_prompt: true,
1247 bos_token: Some("<bos>".into()),
1248 ..Default::default()
1249 },
1250 )
1251 .unwrap();
1252 assert!(!out.contains("let me think"), "{out}");
1253 assert!(
1254 out.contains("<|turn>model\nthe answer is 4<turn|>\n"),
1255 "{out}"
1256 );
1257 }
1258
1259 #[test]
1262 fn a_checkpoint_with_no_template_gets_chatml_or_plain() {
1263 assert!(matches!(
1264 &*ChatTemplate::from_gguf_metadata(None, Some("olmoe"), false, true).0,
1265 Kind::Builtin(BuiltinTemplate::ChatMl)
1266 ));
1267 assert!(matches!(
1268 &*ChatTemplate::from_gguf_metadata(Some(" "), Some("olmoe"), false, true).0,
1269 Kind::Builtin(BuiltinTemplate::ChatMl)
1270 ));
1271 assert!(matches!(
1272 &*ChatTemplate::from_gguf_metadata(None, Some("olmoe"), true, true).0,
1273 Kind::Builtin(BuiltinTemplate::Plain)
1274 ));
1275 assert!(matches!(
1276 &*ChatTemplate::from_gguf_metadata(None, None, false, true).0,
1277 Kind::Builtin(BuiltinTemplate::Plain)
1278 ));
1279 }
1280
1281 #[test]
1282 fn builtin_chatml_and_plain_render_as_before() {
1283 let msgs = [msg("system", "be helpful"), msg("user", "hi")];
1284 assert_eq!(
1285 ChatTemplate::builtin(BuiltinTemplate::ChatMl)
1286 .render(&msgs, &opts())
1287 .unwrap(),
1288 "<|im_start|>system\nbe helpful<|im_end|>\n<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\n"
1289 );
1290 assert_eq!(
1291 ChatTemplate::builtin(BuiltinTemplate::Plain)
1292 .render(&msgs, &opts())
1293 .unwrap(),
1294 "system: be helpful\nuser: hi"
1295 );
1296 }
1297
1298 #[test]
1299 fn builtin_renders_replayed_tool_calls_as_marker_text() {
1300 let msgs = [json!({"role": "assistant", "tool_calls": [
1301 {"function": {"name": "f", "arguments": "{\"a\": 1}"}}
1302 ]})];
1303 assert_eq!(
1304 ChatTemplate::builtin(BuiltinTemplate::Plain)
1305 .render(&msgs, &opts())
1306 .unwrap(),
1307 "assistant: <tool_call>{\"name\": \"f\", \"arguments\": {\"a\": 1}}</tool_call>"
1308 );
1309 }
1310
1311 #[test]
1312 fn handles_tools_is_a_property_of_the_template_not_a_guess() {
1313 assert!(ChatTemplate::from_jinja(QWEN25_TEMPLATE)
1314 .unwrap()
1315 .handles_tools());
1316 assert!(!ChatTemplate::from_jinja(GEMMA3_TEMPLATE)
1317 .unwrap()
1318 .handles_tools());
1319 assert!(!ChatTemplate::builtin(BuiltinTemplate::ChatMl).handles_tools());
1320 }
1321
1322 #[test]
1326 fn python_startswith_and_endswith_take_a_string_or_a_tuple() {
1327 let t = ChatTemplate::from_jinja(
1328 "{% for m in messages %}{{ m.content.startswith('<tool_response>') }}\
1329 {{ m.content.endswith(('a', '</tool_response>')) }}\
1330 {{ m.content.startswith(('x', 'y')) }};{% endfor %}",
1331 )
1332 .unwrap();
1333 let out = t
1334 .render(
1335 &[serde_json::json!({"role": "user", "content": "<tool_response>ok</tool_response>"})],
1336 &RenderOptions::default(),
1337 )
1338 .unwrap();
1339 assert_eq!(out, "truetruefalse;");
1340 }
1341
1342 #[test]
1343 fn utc_calendar_math_matches_known_dates() {
1344 assert_eq!(
1345 format_utc(0, "%F %T %A %j").unwrap(),
1346 "1970-01-01 00:00:00 Thursday 001"
1347 );
1348 assert_eq!(
1349 format_utc(951_782_400, "%F %A %j").unwrap(),
1350 "2000-02-29 Tuesday 060"
1351 );
1352 assert_eq!(
1353 format_utc(1_709_164_800, "%F %A %j").unwrap(),
1354 "2024-02-29 Thursday 060"
1355 );
1356 assert_eq!(
1357 format_utc(1_767_225_599, "%F %T %j").unwrap(),
1358 "2025-12-31 23:59:59 365"
1359 );
1360 assert_eq!(
1361 format_utc(-86_400, "%F %A").unwrap(),
1362 "1969-12-31 Wednesday"
1363 );
1364 }
1365
1366 const GEMMA3_TEMPLATE: &str = include_str!("../tests/templates/gemma-3-1b-it.jinja");
1371 const QWEN25_TEMPLATE: &str = include_str!("../tests/templates/qwen2.5-instruct.jinja");
1373 const GEMMA4_TEMPLATE_CORE: &str = include_str!("../tests/templates/gemma-4-E2B-it.jinja");
1380 const LLAMA31_TEMPLATE: &str = include_str!("../tests/templates/llama-3.1-8b-instruct.jinja");
1382}