use std::fmt::Write as _;
use std::time::{SystemTime, UNIX_EPOCH};
use minijinja::value::{Kwargs, Value};
use minijinja::{Environment, ErrorKind};
use serde_json::Value as Json;
use crate::{Error, Result};
const NAME: &str = "chat_template";
const TEMPLATE_FUEL: u64 = 2_000_000;
pub(crate) struct RenderArgs<'a> {
pub messages: &'a Json,
pub tools: Option<&'a Json>,
pub add_generation_prompt: bool,
pub bos_token: &'a str,
pub eos_token: &'a str,
pub extra: Option<&'a serde_json::Map<String, Json>>,
}
pub(crate) struct ChatTemplate {
env: Environment<'static>,
}
pub(crate) fn is_jinja(s: &str) -> bool {
s.contains("{{") || s.contains("{%")
}
impl ChatTemplate {
pub fn new(source: &str) -> Result<Self> {
let mut env = Environment::new();
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
env.set_fuel(Some(TEMPLATE_FUEL));
env.add_filter("tojson", tojson);
env.add_test("iterable", |v: Value| {
use minijinja::value::ValueKind::*;
matches!(v.kind(), Seq | Map | Iterable | Plain) && v.try_iter().is_ok()
});
env.add_function("raise_exception", raise_exception);
env.add_function("strftime_now", strftime_now);
env.add_template_owned(NAME, rewrite_hf_extensions(source)).map_err(|e| Error::ChatTemplate(describe(&e)))?;
Ok(Self { env })
}
pub fn render(&self, args: &RenderArgs) -> Result<String> {
let mut ctx = serde_json::Map::new();
if let Some(extra) = args.extra {
ctx.extend(extra.clone());
}
ctx.insert("messages".into(), args.messages.clone());
ctx.insert("add_generation_prompt".into(), Json::Bool(args.add_generation_prompt));
ctx.insert("bos_token".into(), Json::String(args.bos_token.into()));
ctx.insert("eos_token".into(), Json::String(args.eos_token.into()));
ctx.insert("tools".into(), args.tools.cloned().unwrap_or(Json::Null));
let tmpl = self.env.get_template(NAME).map_err(|e| Error::ChatTemplate(describe(&e)))?;
tmpl.render(Value::from_serialize(&ctx)).map_err(|e| Error::ChatTemplate(describe(&e)))
}
}
fn rewrite_hf_extensions(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut rest = src;
while let Some(start) = rest.find("{%") {
let Some(close) = rest[start + 2..].find("%}") else { break };
let len = close + 2;
let tag = &rest[start..start + len + 2];
let inner = &tag[2..tag.len() - 2];
let (lead, trail) = (inner.starts_with('-'), inner.ends_with('-'));
let word = inner.trim_matches('-').trim();
let replacement = match word {
"generation" => Some("if true"),
"endgeneration" => Some("endif"),
_ => None,
};
out.push_str(&rest[..start]);
match replacement {
Some(r) => {
let _ = write!(out, "{{%{} {r} {}%}}", if lead { "-" } else { "" }, if trail { "-" } else { "" });
}
None => out.push_str(tag),
}
rest = &rest[start + len + 2..];
}
out.push_str(rest);
out
}
fn describe(e: &minijinja::Error) -> String {
let mut msg = e.to_string();
let mut source = std::error::Error::source(e);
while let Some(s) = source {
let _ = write!(msg, ": {s}");
source = s.source();
}
msg
}
fn raise_exception(msg: String) -> std::result::Result<Value, minijinja::Error> {
Err(minijinja::Error::new(ErrorKind::InvalidOperation, format!("template raised: {msg}")))
}
fn tojson(value: Value, kwargs: Kwargs) -> std::result::Result<Value, minijinja::Error> {
let indent: Option<usize> = kwargs.get("indent")?;
let _: Option<bool> = kwargs.get("ensure_ascii")?;
let _: Option<bool> = kwargs.get("sort_keys")?;
kwargs.assert_all_used()?;
let json =
serde_json::to_value(&value).map_err(|e| minijinja::Error::new(ErrorKind::InvalidOperation, e.to_string()))?;
let mut out = String::new();
write_py_json(&json, indent, 0, &mut out);
Ok(Value::from_safe_string(out))
}
pub(crate) fn py_json(v: &Json) -> String {
let mut out = String::new();
write_py_json(v, None, 0, &mut out);
out
}
fn write_py_json(v: &Json, indent: Option<usize>, level: usize, out: &mut String) {
let newline = |out: &mut String, level: usize| {
if let Some(n) = indent {
out.push('\n');
out.push_str(&" ".repeat(n * level));
}
};
let item_sep = if indent.is_some() { "," } else { ", " };
match v {
Json::Array(items) if !items.is_empty() => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push_str(item_sep);
}
newline(out, level + 1);
write_py_json(item, indent, level + 1, out);
}
newline(out, level);
out.push(']');
}
Json::Object(map) if !map.is_empty() => {
out.push('{');
for (i, (k, item)) in map.iter().enumerate() {
if i > 0 {
out.push_str(item_sep);
}
newline(out, level + 1);
out.push_str(&Json::String(k.clone()).to_string());
out.push_str(": ");
write_py_json(item, indent, level + 1, out);
}
newline(out, level);
out.push('}');
}
other => out.push_str(&other.to_string()),
}
}
fn strftime_now(fmt: String) -> String {
let secs = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) as i64;
strftime(&fmt, secs)
}
fn strftime(fmt: &str, unix: i64) -> String {
const MONTHS: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const DAYS: [&str; 7] = ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"];
let days = unix.div_euclid(86_400);
let tod = unix.rem_euclid(86_400);
let (y, m, d) = civil_from_days(days);
let (hour, min, sec) = (tod / 3600, tod / 60 % 60, tod % 60);
let yday = days - days_from_civil(y, 1, 1) + 1;
let weekday = DAYS[days.rem_euclid(7) as usize];
let month = MONTHS[(m - 1) as usize];
let mut out = String::new();
let mut chars = fmt.chars();
while let Some(c) = chars.next() {
if c != '%' {
out.push(c);
continue;
}
let _ = match chars.next() {
Some('Y') => write!(out, "{y}"),
Some('y') => write!(out, "{:02}", y.rem_euclid(100)),
Some('m') => write!(out, "{m:02}"),
Some('d') => write!(out, "{d:02}"),
Some('e') => write!(out, "{d:2}"),
Some('H') => write!(out, "{hour:02}"),
Some('I') => write!(out, "{:02}", if hour % 12 == 0 { 12 } else { hour % 12 }),
Some('p') => write!(out, "{}", if hour < 12 { "AM" } else { "PM" }),
Some('M') => write!(out, "{min:02}"),
Some('S') => write!(out, "{sec:02}"),
Some('j') => write!(out, "{yday:03}"),
Some('b') => write!(out, "{}", &month[..3]),
Some('B') => write!(out, "{month}"),
Some('a') => write!(out, "{}", &weekday[..3]),
Some('A') => write!(out, "{weekday}"),
Some('%') => write!(out, "%"),
Some(other) => write!(out, "%{other}"),
None => write!(out, "%"),
};
}
out
}
fn civil_from_days(z: i64) -> (i64, i64, i64) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(yoe + era * 400 + i64::from(m <= 2), m, d)
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn render(src: &str, messages: Json) -> Result<String> {
ChatTemplate::new(src)?.render(&RenderArgs {
messages: &messages,
tools: None,
add_generation_prompt: true,
bos_token: "<s>",
eos_token: "</s>",
extra: None,
})
}
#[test]
fn strftime_known_date() {
assert_eq!(strftime("%d %b %Y %H:%M:%S %A %j %%", 1_791_378_309), "07 Oct 2026 13:05:09 Wednesday 280 %");
assert_eq!(strftime("%Y-%m-%d", 0), "1970-01-01");
}
#[test]
fn tojson_python_style() {
let data = json!({"a": [1, "é<b>"], "b": {}});
let out = render("{{ messages | tojson }}", data.clone()).unwrap();
assert_eq!(out, r#"{"a": [1, "é<b>"], "b": {}}"#);
let out = render("{{ messages | tojson(indent=2) }}", data).unwrap();
assert_eq!(out, "{\n \"a\": [\n 1,\n \"é<b>\"\n ],\n \"b\": {}\n}");
}
#[test]
fn pycompat_and_namespace() {
let src = "{% set ns = namespace(n=0) %}{% for m in messages[::-1] %}{% set ns.n = ns.n + 1 %}\
{{ m.content.strip().split(',')|first }}{% endfor %}{{ ns.n }}";
let out = render(src, json!([{"role": "user", "content": " a,b "}, {"role": "user", "content": "c"}])).unwrap();
assert_eq!(out, "ca2");
}
#[test]
fn raise_exception_is_error() {
let err = render("{{ raise_exception('nope') }}", json!([])).unwrap_err();
assert!(err.to_string().contains("nope"), "{err}");
}
#[test]
fn llama_cpp_template_corpus() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../offgrid-sys/llama.cpp/models/templates");
let messages = json!([
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hi there"},
{"role": "assistant", "content": "Hello! How can I help?"},
{"role": "user", "content": "What is 2+2?"}
]);
let mut entries: Vec<_> = std::fs::read_dir(&dir).expect("template corpus").flatten().collect();
entries.sort_by_key(|e| e.file_name());
let (mut ok, mut failed) = (0, Vec::new());
for entry in entries {
let path = entry.path();
if path.extension().is_none_or(|e| e != "jinja") {
continue;
}
let src = std::fs::read_to_string(&path).unwrap();
let name = path.file_name().unwrap().to_string_lossy().into_owned();
match render(&src, messages.clone()) {
Ok(out) if out.contains("What is 2+2?") => ok += 1,
Ok(out) => failed.push(format!("{name}: output lacks last message: {out:?}")),
Err(e) => failed.push(format!("{name}: {e}")),
}
}
let expected =
["google-gemma-2-2b-it.jinja", "fireworks-ai-llama-3-firefunction-v2.jinja", "IFM-K2-Horizon.jinja"];
failed.retain(|f| !expected.iter().any(|e| f.starts_with(e)));
for f in &failed {
println!("FAIL {f}");
}
println!("{ok} rendered, {} unexpected failures", failed.len());
assert!(failed.is_empty());
assert!(ok >= 69, "corpus shrank? only {ok} rendered");
}
#[test]
fn iterable_test_lists_not_strings() {
let out = render(
"{{ none is iterable }} {{ [] is iterable }} {{ 'ab' is iterable }} {{ {} is iterable }} {{ 3 is iterable }}",
json!([]),
)
.unwrap();
assert_eq!(out, "False True False True False");
}
#[test]
fn malformed_tags_do_not_panic() {
for src in ["{%}", "{%%}", "a{%", "{% generation", "{%-}", "%}{%"] {
let _ = rewrite_hf_extensions(src);
let _ = ChatTemplate::new(src); }
assert_eq!(rewrite_hf_extensions("{%}x"), "{%}x");
}
#[test]
fn generation_tags_rewritten() {
assert_eq!(rewrite_hf_extensions("a{%- generation -%}b{% endgeneration %}c"), "a{%- if true -%}b{% endif %}c");
let out = render(
"{% for m in messages %}{% generation %}{{ m.content }}{% endgeneration %}{% endfor %}",
json!([{"role": "user", "content": "x"}]),
)
.unwrap();
assert_eq!(out, "x");
}
#[test]
fn runaway_template_is_stopped() {
let t = std::time::Instant::now();
let err = render("{% for i in range(99999) %}{% for j in range(99999) %}{% endfor %}{% endfor %}", json!([]))
.unwrap_err();
assert!(err.to_string().contains("fuel"), "{err}");
assert!(t.elapsed() < std::time::Duration::from_secs(5));
}
}