use std::sync::Arc;
use minijinja::value::Value as JinjaValue;
use serde_json::Value;
#[derive(Debug, Clone, thiserror::Error)]
pub enum TemplateError {
#[error("chat template does not compile: {0}")]
Compile(String),
#[error("chat template failed to render: {0}")]
Render(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuiltinTemplate {
ChatMl,
Plain,
}
enum Kind {
Jinja(Box<JinjaTemplate>),
Broken(TemplateError),
Builtin(BuiltinTemplate),
}
struct JinjaTemplate {
env: minijinja::Environment<'static>,
source: String,
}
#[derive(Clone)]
pub struct ChatTemplate(Arc<Kind>);
impl std::fmt::Debug for ChatTemplate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &*self.0 {
Kind::Jinja(t) => write!(f, "Jinja({} bytes)", t.source.len()),
Kind::Broken(e) => write!(f, "Broken({e})"),
Kind::Builtin(b) => write!(f, "Builtin({b:?})"),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RenderOptions {
pub add_generation_prompt: bool,
pub bos_token: Option<String>,
pub eos_token: Option<String>,
pub tools: Vec<Value>,
pub extra: serde_json::Map<String, Value>,
}
impl ChatTemplate {
pub fn from_jinja(source: &str) -> Result<Self, TemplateError> {
let mut env = new_environment();
env.add_template_owned("chat".to_string(), source.to_string())
.map_err(|e| TemplateError::Compile(format_jinja_error(&e)))?;
Ok(Self(Arc::new(Kind::Jinja(Box::new(JinjaTemplate {
env,
source: source.to_string(),
})))))
}
pub fn builtin(b: BuiltinTemplate) -> Self {
Self(Arc::new(Kind::Builtin(b)))
}
pub fn from_gguf_metadata(
chat_template: Option<&str>,
arch: Option<&str>,
byte_tokenizer: bool,
) -> Self {
match chat_template.filter(|t| !t.trim().is_empty()) {
Some(t) => match Self::from_jinja(t) {
Ok(tmpl) => tmpl,
Err(e) => Self(Arc::new(Kind::Broken(e))),
},
None if byte_tokenizer || arch.is_none() => Self::builtin(BuiltinTemplate::Plain),
None => Self::builtin(BuiltinTemplate::ChatMl),
}
}
pub fn is_jinja(&self) -> bool {
matches!(&*self.0, Kind::Jinja(_))
}
pub fn source(&self) -> Option<&str> {
match &*self.0 {
Kind::Jinja(t) => Some(&t.source),
_ => None,
}
}
pub fn handles_tools(&self) -> bool {
match &*self.0 {
Kind::Jinja(t) => t.source.contains("tools"),
Kind::Broken(_) | Kind::Builtin(_) => false,
}
}
pub fn describe(&self) -> String {
match &*self.0 {
Kind::Jinja(t) => format!("jinja ({} bytes from the GGUF)", t.source.len()),
Kind::Broken(e) => format!("BROKEN: {e}"),
Kind::Builtin(b) => format!("builtin {b:?} (checkpoint ships no chat template)"),
}
}
pub fn render(
&self,
messages: &[Value],
opts: &RenderOptions,
) -> Result<String, TemplateError> {
match &*self.0 {
Kind::Broken(e) => Err(e.clone()),
Kind::Builtin(b) => Ok(render_builtin(*b, messages, opts)),
Kind::Jinja(t) => {
let tmpl = t
.env
.get_template("chat")
.map_err(|e| TemplateError::Compile(format_jinja_error(&e)))?;
let mut ctx = serde_json::Map::new();
for (k, v) in &opts.extra {
ctx.insert(k.clone(), v.clone());
}
ctx.insert("messages".into(), Value::Array(messages.to_vec()));
ctx.insert(
"add_generation_prompt".into(),
Value::Bool(opts.add_generation_prompt),
);
ctx.insert(
"tools".into(),
if opts.tools.is_empty() {
Value::Null
} else {
Value::Array(opts.tools.clone())
},
);
for (name, tok) in [
("bos_token", &opts.bos_token),
("eos_token", &opts.eos_token),
] {
if let Some(tok) = tok {
ctx.insert(name.into(), Value::String(tok.clone()));
}
}
tmpl.render(JinjaValue::from_serialize(Value::Object(ctx)))
.map_err(|e| TemplateError::Render(format_jinja_error(&e)))
}
}
}
}
fn format_jinja_error(err: &minijinja::Error) -> String {
let mut out = err.to_string();
if let Some(line) = err.line() {
out.push_str(&format!(" (line {line})"));
}
let mut src = std::error::Error::source(err);
while let Some(e) = src {
out.push_str(&format!(": {e}"));
src = std::error::Error::source(e);
}
out
}
fn new_environment() -> minijinja::Environment<'static> {
let mut env = minijinja::Environment::new();
env.set_undefined_behavior(minijinja::UndefinedBehavior::Lenient);
env.add_function("raise_exception", raise_exception);
env.add_function("strftime_now", strftime_now);
env.add_filter("tojson", tojson);
env.set_unknown_method_callback(python_method);
env
}
fn tojson(value: JinjaValue) -> Result<String, minijinja::Error> {
let json: Value = serde_json::to_value(&value).map_err(|e| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!("tojson: value is not serialisable: {e}"),
)
})?;
let mut out = String::new();
write_python_json(&json, &mut out);
Ok(out)
}
fn write_python_json(v: &Value, out: &mut String) {
match v {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
out.push('{');
for (i, k) in keys.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(&Value::String((*k).clone()).to_string());
out.push_str(": ");
write_python_json(&map[*k], out);
}
out.push('}');
}
Value::Array(items) => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
write_python_json(item, out);
}
out.push(']');
}
other => out.push_str(&other.to_string()),
}
}
fn python_method(
_state: &minijinja::State,
value: &JinjaValue,
method: &str,
args: &[JinjaValue],
) -> Result<JinjaValue, minijinja::Error> {
fn unknown() -> minijinja::Error {
minijinja::Error::from(minijinja::ErrorKind::UnknownMethod)
}
fn as_str(v: &JinjaValue) -> Result<&str, minijinja::Error> {
v.as_str().ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"expected a string argument",
)
})
}
match method {
"get" => {
if value.as_object().is_none() {
return Err(unknown());
}
let (key, default) = match args {
[k] => (k, JinjaValue::from(())),
[k, d] => (k, d.clone()),
_ => {
return Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"get() takes 1 or 2 arguments",
))
}
};
Ok(value
.get_item(key)
.ok()
.filter(|v| !v.is_undefined())
.unwrap_or(default))
}
"split" => {
let s = value.as_str().ok_or_else(unknown)?;
let parts: Vec<JinjaValue> = match args {
[] => s.split_whitespace().map(JinjaValue::from).collect(),
[sep] => s.split(as_str(sep)?).map(JinjaValue::from).collect(),
_ => {
return Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"ferrox implements split() with at most one separator argument",
))
}
};
Ok(JinjaValue::from(parts))
}
"strip" | "lstrip" | "rstrip" => {
let s = value.as_str().ok_or_else(unknown)?;
let chars: Option<Vec<char>> = match args {
[] => None,
[c] => Some(as_str(c)?.chars().collect()),
_ => {
return Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"strip() takes at most one argument",
))
}
};
let pred = |c: char| match &chars {
Some(set) => set.contains(&c),
None => c.is_whitespace(),
};
Ok(JinjaValue::from(match method {
"strip" => s.trim_matches(pred),
"lstrip" => s.trim_start_matches(pred),
_ => s.trim_end_matches(pred),
}))
}
_ => Err(unknown()),
}
}
fn raise_exception(msg: String) -> Result<JinjaValue, minijinja::Error> {
Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!("template raised: {msg}"),
))
}
fn strftime_now(fmt: String) -> Result<String, minijinja::Error> {
let secs = match std::env::var("FERROX_CHAT_TEMPLATE_NOW") {
Ok(v) => v.trim().parse::<i64>().map_err(|_| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"FERROX_CHAT_TEMPLATE_NOW must be Unix seconds",
)
})?,
Err(_) => std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0),
};
format_utc(secs, &fmt).map_err(|spec| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
"strftime_now: unsupported format specifier `%{spec}` in {fmt:?} -- \
ferrox implements a subset (%Y %y %m %d %e %H %M %S %j %B %b %A %a %F %T %%) \
and refuses rather than stamping a wrong date into the prompt"
),
)
})
}
const MONTHS: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const WEEKDAYS: [&str; 7] = [
"Thursday",
"Friday",
"Saturday",
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
];
fn format_utc(secs: i64, fmt: &str) -> Result<String, char> {
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
let (hour, minute, second) = (tod / 3600, (tod % 3600) / 60, tod % 60);
let weekday = days.rem_euclid(7) as usize;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if month <= 2 { y + 1 } else { y };
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
const CUM: [i64; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
let yday = CUM[(month - 1) as usize] + day + i64::from(leap && month > 2);
let mut out = String::with_capacity(fmt.len() + 8);
let mut chars = fmt.chars().peekable();
while let Some(c) = chars.next() {
if c != '%' {
out.push(c);
continue;
}
let mut pad = true;
let mut spec = chars.next().ok_or('%')?;
if spec == '-' {
pad = false;
spec = chars.next().ok_or('-')?;
}
let num = |out: &mut String, v: i64, w: usize| {
if pad {
out.push_str(&format!("{v:0w$}"));
} else {
out.push_str(&v.to_string());
}
};
match spec {
'Y' => out.push_str(&year.to_string()),
'y' => num(&mut out, year.rem_euclid(100), 2),
'm' => num(&mut out, month, 2),
'd' => num(&mut out, day, 2),
'e' => out.push_str(&format!("{day:2}")),
'H' => num(&mut out, hour, 2),
'M' => num(&mut out, minute, 2),
'S' => num(&mut out, second, 2),
'j' => num(&mut out, yday, 3),
'B' => out.push_str(MONTHS[(month - 1) as usize]),
'b' => out.push_str(&MONTHS[(month - 1) as usize][..3]),
'A' => out.push_str(WEEKDAYS[weekday]),
'a' => out.push_str(&WEEKDAYS[weekday][..3]),
'F' => out.push_str(&format!("{year:04}-{month:02}-{day:02}")),
'T' => out.push_str(&format!("{hour:02}:{minute:02}:{second:02}")),
'%' => out.push('%'),
other => return Err(other),
}
}
Ok(out)
}
fn builtin_message_text(m: &Value) -> String {
let mut out = match m.get("content") {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(parts)) => parts
.iter()
.filter_map(|p| p.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join(""),
_ => String::new(),
};
if let Some(Value::Array(calls)) = m.get("tool_calls") {
for call in calls {
let f = call.get("function");
let name = f
.and_then(|f| f.get("name"))
.and_then(Value::as_str)
.unwrap_or("");
let args = f
.and_then(|f| f.get("arguments"))
.map(|a| match a {
Value::String(s) => s.clone(),
other => other.to_string(),
})
.unwrap_or_else(|| "{}".to_string());
out.push_str(&format!(
"<tool_call>{{\"name\": \"{name}\", \"arguments\": {args}}}</tool_call>"
));
}
}
out
}
fn builtin_role(m: &Value) -> &str {
m.get("role").and_then(Value::as_str).unwrap_or("user")
}
fn render_builtin(b: BuiltinTemplate, messages: &[Value], opts: &RenderOptions) -> String {
let mut out = String::new();
match b {
BuiltinTemplate::ChatMl => {
for m in messages {
out.push_str("<|im_start|>");
out.push_str(builtin_role(m));
out.push('\n');
out.push_str(&builtin_message_text(m));
out.push_str("<|im_end|>\n");
}
if opts.add_generation_prompt {
out.push_str("<|im_start|>assistant\n");
}
}
BuiltinTemplate::Plain => {
let lines: Vec<String> = messages
.iter()
.map(|m| format!("{}: {}", builtin_role(m), builtin_message_text(m)))
.collect();
out.push_str(&lines.join("\n"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn msg(role: &str, content: &str) -> Value {
json!({"role": role, "content": content})
}
fn opts() -> RenderOptions {
RenderOptions {
add_generation_prompt: true,
bos_token: Some("<s>".into()),
eos_token: Some("</s>".into()),
..Default::default()
}
}
#[test]
fn renders_bos_token_and_the_real_mistral_inst_framing() {
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 %}";
let t = ChatTemplate::from_jinja(src).unwrap();
let out = t
.render(
&[
msg("user", "hi"),
msg("assistant", "hello"),
msg("user", "2+2?"),
],
&opts(),
)
.unwrap();
assert_eq!(out, "<s>[INST] hi [/INST]hello</s>[INST] 2+2? [/INST]");
}
#[test]
fn mistral_is_not_plain_role_labelled_lines() {
let src = "{{ bos_token }}{% for message in messages %}{% if message['role'] == 'user' %}{{ '[INST] ' + message['content'] + ' [/INST]' }}{% endif %}{% endfor %}";
let out = ChatTemplate::from_jinja(src)
.unwrap()
.render(&[msg("user", "hi")], &opts())
.unwrap();
assert!(!out.contains("user: hi"), "{out}");
assert!(out.contains("[INST]"), "{out}");
}
#[test]
fn renders_a_system_message_with_the_real_gemma3_template() {
let src = GEMMA3_TEMPLATE;
let t = ChatTemplate::from_jinja(src).unwrap();
let out = t
.render(
&[msg("system", "be brief"), msg("user", "hi")],
&RenderOptions {
add_generation_prompt: true,
bos_token: Some("<bos>".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(
out,
"<bos><start_of_turn>user\nbe brief\n\nhi<end_of_turn>\n<start_of_turn>model\n"
);
}
#[test]
fn gemma3_emits_the_image_placeholder_for_content_parts() {
let out = ChatTemplate::from_jinja(GEMMA3_TEMPLATE)
.unwrap()
.render(
&[json!({"role": "user", "content": [
{"type": "image"},
{"type": "text", "text": "what is this?"}
]})],
&RenderOptions {
add_generation_prompt: true,
bos_token: Some("<bos>".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(
out,
"<bos><start_of_turn>user\n<start_of_image>what is this?<end_of_turn>\n<start_of_turn>model\n"
);
}
#[test]
fn renders_a_tool_call_block_with_the_real_qwen25_template() {
let t = ChatTemplate::from_jinja(QWEN25_TEMPLATE).unwrap();
assert!(t.handles_tools());
let out = t
.render(
&[
msg("user", "weather in Paris?"),
json!({"role": "assistant", "content": "", "tool_calls": [
{"type": "function", "function": {"name": "get_weather", "arguments": {"city": "Paris"}}}
]}),
json!({"role": "tool", "content": "18C"}),
],
&RenderOptions {
add_generation_prompt: true,
tools: vec![json!({"type": "function", "function": {
"name": "get_weather",
"description": "Current weather",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
}})],
..Default::default()
},
)
.unwrap();
assert_eq!(
out,
concat!(
"<|im_start|>system\n",
"You are Qwen, created by Alibaba Cloud. You are a helpful assistant.\n\n",
"# Tools\n\n",
"You may call one or more functions to assist with the user query.\n\n",
"You are provided with function signatures within <tools></tools> XML tags:\n",
"<tools>\n",
"{\"function\": {\"description\": \"Current weather\", \"name\": \"get_weather\", ",
"\"parameters\": {\"properties\": {\"city\": {\"type\": \"string\"}}, ",
"\"type\": \"object\"}}, \"type\": \"function\"}\n",
"</tools>\n\n",
"For each function call, return a json object with function name and arguments ",
"within <tool_call></tool_call> XML tags:\n",
"<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n",
"</tool_call><|im_end|>\n",
"<|im_start|>user\nweather in Paris?<|im_end|>\n",
"<|im_start|>assistant\n",
"<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Paris\"}}\n",
"</tool_call><|im_end|>\n",
"<|im_start|>user\n<tool_response>\n18C\n</tool_response><|im_end|>\n",
"<|im_start|>assistant\n",
)
);
}
#[test]
fn add_generation_prompt_is_honoured_both_ways() {
let t = ChatTemplate::from_jinja(GEMMA3_TEMPLATE).unwrap();
let with = t
.render(
&[msg("user", "hi")],
&RenderOptions {
add_generation_prompt: true,
..Default::default()
},
)
.unwrap();
let without = t
.render(
&[msg("user", "hi")],
&RenderOptions {
add_generation_prompt: false,
..Default::default()
},
)
.unwrap();
assert_eq!(
with,
"<start_of_turn>user\nhi<end_of_turn>\n<start_of_turn>model\n"
);
assert_eq!(without, "<start_of_turn>user\nhi<end_of_turn>\n");
}
#[test]
fn llama31_binds_tools_to_null_so_a_plain_chat_gets_no_tool_preamble() {
let out = ChatTemplate::from_jinja(LLAMA31_TEMPLATE)
.unwrap()
.render(
&[msg("system", "be brief"), msg("user", "hi")],
&RenderOptions {
add_generation_prompt: true,
bos_token: Some("<|begin_of_text|>".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(
out,
concat!(
"<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n",
"Cutting Knowledge Date: December 2023\n",
"Today Date: 26 Jul 2024\n\n",
"be brief<|eot_id|>",
"<|start_header_id|>user<|end_header_id|>\n\nhi<|eot_id|>",
"<|start_header_id|>assistant<|end_header_id|>\n\n",
)
);
assert!(!out.contains("Environment: ipython"), "{out}");
}
#[test]
fn raise_exception_fails_the_render_and_keeps_the_message() {
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 %}";
let err = ChatTemplate::from_jinja(src)
.unwrap()
.render(&[msg("assistant", "oops")], &opts())
.unwrap_err();
let text = err.to_string();
assert!(matches!(err, TemplateError::Render(_)), "{text}");
assert!(text.contains("roles must alternate"), "{text}");
}
#[test]
fn strftime_now_stamps_a_pinned_clock() {
std::env::set_var("FERROX_CHAT_TEMPLATE_NOW", "1720096496");
let out = ChatTemplate::from_jinja(
"{{ strftime_now(\"%d %b %Y\") }}|{{ strftime_now('%A %F %T %j %-d') }}",
)
.unwrap()
.render(&[], &opts())
.unwrap();
std::env::remove_var("FERROX_CHAT_TEMPLATE_NOW");
assert_eq!(out, "04 Jul 2024|Thursday 2024-07-04 12:34:56 186 4");
}
#[test]
fn strftime_now_refuses_an_unimplemented_specifier() {
let err = ChatTemplate::from_jinja("{{ strftime_now('%Z') }}")
.unwrap()
.render(&[], &opts())
.unwrap_err();
assert!(
err.to_string().contains("unsupported format specifier"),
"{err}"
);
}
#[test]
fn whitespace_control_is_respected() {
let out = ChatTemplate::from_jinja(
"{%- for m in messages -%}\n {{- m['role'] -}}\n{%- endfor -%}",
)
.unwrap()
.render(&[msg("user", "x"), msg("assistant", "y")], &opts())
.unwrap();
assert_eq!(out, "userassistant");
}
#[test]
fn namespace_writes_escape_loop_scope() {
let out = ChatTemplate::from_jinja(
"{%- set ns = namespace(n=0) -%}{%- for m in messages -%}{%- set ns.n = ns.n + 1 -%}{%- endfor -%}{{ ns.n }}",
)
.unwrap()
.render(&[msg("user", "a"), msg("user", "b"), msg("user", "c")], &opts())
.unwrap();
assert_eq!(out, "3");
}
#[test]
fn an_unsupported_construct_fails_loudly() {
let err = ChatTemplate::from_jinja("{{ messages | no_such_filter }}")
.unwrap()
.render(&[msg("user", "hi")], &opts())
.unwrap_err();
let text = err.to_string();
assert!(matches!(err, TemplateError::Render(_)), "{text}");
assert!(text.contains("no_such_filter"), "{text}");
}
#[test]
fn a_template_that_does_not_compile_is_recorded_not_replaced() {
let t = ChatTemplate::from_gguf_metadata(
Some("{% for m in messages %}{{ m }}"),
Some("llama"),
false,
);
assert!(!t.is_jinja());
let err = t.render(&[msg("user", "hi")], &opts()).unwrap_err();
assert!(matches!(err, TemplateError::Compile(_)), "{err}");
assert!(t.describe().starts_with("BROKEN"), "{}", t.describe());
}
#[test]
fn chat_template_kwargs_reach_the_template() {
let src = "{%- if enable_thinking -%}THINK{%- else -%}PLAIN{%- endif -%}";
let t = ChatTemplate::from_jinja(src).unwrap();
let mut extra = serde_json::Map::new();
extra.insert("enable_thinking".into(), Value::Bool(true));
let on = t
.render(
&[msg("user", "hi")],
&RenderOptions {
extra,
..Default::default()
},
)
.unwrap();
let off = t
.render(&[msg("user", "hi")], &RenderOptions::default())
.unwrap();
assert_eq!((on.as_str(), off.as_str()), ("THINK", "PLAIN"));
}
#[test]
fn chat_template_kwargs_cannot_shadow_messages_or_tools() {
let mut extra = serde_json::Map::new();
extra.insert(
"messages".into(),
json!([{"role": "user", "content": "INJECTED"}]),
);
extra.insert("add_generation_prompt".into(), Value::Bool(true));
let out = ChatTemplate::from_jinja(
"{%- for m in messages -%}{{ m['content'] }}{%- endfor -%}|{{ add_generation_prompt }}",
)
.unwrap()
.render(
&[msg("user", "real")],
&RenderOptions {
add_generation_prompt: false,
extra,
..Default::default()
},
)
.unwrap();
assert_eq!(out, "real|false");
}
#[test]
fn gemma4_thinking_injection_is_reachable_now() {
let t = ChatTemplate::from_jinja(GEMMA4_TEMPLATE_CORE).unwrap();
let mut extra = serde_json::Map::new();
extra.insert("enable_thinking".into(), Value::Bool(true));
let thinking = t
.render(
&[msg("user", "hi")],
&RenderOptions {
add_generation_prompt: true,
bos_token: Some("<bos>".into()),
extra,
..Default::default()
},
)
.unwrap();
assert_eq!(
thinking,
"<bos><|turn>system\n<|think|>\n<turn|>\n<|turn>user\nhi<turn|>\n<|turn>model\n"
);
let plain = t
.render(
&[msg("user", "hi")],
&RenderOptions {
add_generation_prompt: true,
bos_token: Some("<bos>".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(plain, "<bos><|turn>user\nhi<turn|>\n<|turn>model\n");
}
#[test]
fn gemma4_strip_thinking_removes_replayed_reasoning() {
let out = ChatTemplate::from_jinja(GEMMA4_TEMPLATE_CORE)
.unwrap()
.render(
&[
msg("user", "hi"),
msg(
"assistant",
"<|channel>thought\nlet me think<channel|>the answer is 4",
),
msg("user", "again?"),
],
&RenderOptions {
add_generation_prompt: true,
bos_token: Some("<bos>".into()),
..Default::default()
},
)
.unwrap();
assert!(!out.contains("let me think"), "{out}");
assert!(
out.contains("<|turn>model\nthe answer is 4<turn|>\n"),
"{out}"
);
}
#[test]
fn a_checkpoint_with_no_template_gets_chatml_or_plain() {
assert!(matches!(
&*ChatTemplate::from_gguf_metadata(None, Some("olmoe"), false).0,
Kind::Builtin(BuiltinTemplate::ChatMl)
));
assert!(matches!(
&*ChatTemplate::from_gguf_metadata(Some(" "), Some("olmoe"), false).0,
Kind::Builtin(BuiltinTemplate::ChatMl)
));
assert!(matches!(
&*ChatTemplate::from_gguf_metadata(None, Some("olmoe"), true).0,
Kind::Builtin(BuiltinTemplate::Plain)
));
assert!(matches!(
&*ChatTemplate::from_gguf_metadata(None, None, false).0,
Kind::Builtin(BuiltinTemplate::Plain)
));
}
#[test]
fn builtin_chatml_and_plain_render_as_before() {
let msgs = [msg("system", "be helpful"), msg("user", "hi")];
assert_eq!(
ChatTemplate::builtin(BuiltinTemplate::ChatMl)
.render(&msgs, &opts())
.unwrap(),
"<|im_start|>system\nbe helpful<|im_end|>\n<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\n"
);
assert_eq!(
ChatTemplate::builtin(BuiltinTemplate::Plain)
.render(&msgs, &opts())
.unwrap(),
"system: be helpful\nuser: hi"
);
}
#[test]
fn builtin_renders_replayed_tool_calls_as_marker_text() {
let msgs = [json!({"role": "assistant", "tool_calls": [
{"function": {"name": "f", "arguments": "{\"a\": 1}"}}
]})];
assert_eq!(
ChatTemplate::builtin(BuiltinTemplate::Plain)
.render(&msgs, &opts())
.unwrap(),
"assistant: <tool_call>{\"name\": \"f\", \"arguments\": {\"a\": 1}}</tool_call>"
);
}
#[test]
fn handles_tools_is_a_property_of_the_template_not_a_guess() {
assert!(ChatTemplate::from_jinja(QWEN25_TEMPLATE)
.unwrap()
.handles_tools());
assert!(!ChatTemplate::from_jinja(GEMMA3_TEMPLATE)
.unwrap()
.handles_tools());
assert!(!ChatTemplate::builtin(BuiltinTemplate::ChatMl).handles_tools());
}
#[test]
fn utc_calendar_math_matches_known_dates() {
assert_eq!(
format_utc(0, "%F %T %A %j").unwrap(),
"1970-01-01 00:00:00 Thursday 001"
);
assert_eq!(
format_utc(951_782_400, "%F %A %j").unwrap(),
"2000-02-29 Tuesday 060"
);
assert_eq!(
format_utc(1_709_164_800, "%F %A %j").unwrap(),
"2024-02-29 Thursday 060"
);
assert_eq!(
format_utc(1_767_225_599, "%F %T %j").unwrap(),
"2025-12-31 23:59:59 365"
);
assert_eq!(
format_utc(-86_400, "%F %A").unwrap(),
"1969-12-31 Wednesday"
);
}
const GEMMA3_TEMPLATE: &str = include_str!("../tests/templates/gemma-3-1b-it.jinja");
const QWEN25_TEMPLATE: &str = include_str!("../tests/templates/qwen2.5-instruct.jinja");
const GEMMA4_TEMPLATE_CORE: &str = include_str!("../tests/templates/gemma-4-E2B-it.jinja");
const LLAMA31_TEMPLATE: &str = include_str!("../tests/templates/llama-3.1-8b-instruct.jinja");
}