Skip to main content

ferrox_models/
chat_template.rs

1//! Chat-template rendering driven by the GGUF's own
2//! `tokenizer.chat_template` Jinja2 string.
3//!
4//! # Why this exists
5//!
6//! The previous implementation (`ferrox-server`'s `chat_template.rs`, and
7//! a near-identical copy in `ferrox-cli`'s `run.rs`) sniffed the template
8//! string for literal markers — `<|im_start|>`, `<|start_header_id|>`,
9//! `<start_of_turn>` — and picked one of six hand-written renderers. That
10//! has three failure modes, all of them silent:
11//!
12//! 1. **Every unrecognised family renders as `Plain`.** Mistral-Instruct's
13//!    real template is `[INST] … [/INST]`, which matches no marker, so a
14//!    Mistral checkpoint was served `user: hi` — a prompt shape it has
15//!    never seen. Same for Phi-3/Phi-4 (`<|user|>…<|end|>` uses the
16//!    `<|user|>` marker but not the `</s>`-terminated framing the
17//!    `GenericRoleMarkers` renderer emits), Yi, and DeepSeek-R1.
18//! 2. **The tool-calling half of every template is unreachable.** No
19//!    hand-written renderer ever consulted `tools`, so the `<tool_call>`
20//!    / `<|tool▁calls▁begin|>` / Gemma `<|tool>` grammars a model was
21//!    actually trained on could not be produced.
22//! 3. **A recognised family is not the same as an implemented one.**
23//!    `ChatTemplate::Gemma4` matched gemma-4's `<|turn>` marker and then
24//!    rendered a three-line approximation of an 18 KB template: no
25//!    thinking-channel injection, no `strip_thinking` on replayed
26//!    assistant turns, no multimodal placeholders, no tool blocks.
27//!
28//! So this module evaluates the template instead of recognising it.
29//! [`ChatTemplate::from_gguf_metadata`] compiles the checkpoint's own
30//! Jinja source with [`minijinja`]; the hand-written renderers survive
31//! only as [`BuiltinTemplate`], used for checkpoints that ship **no**
32//! template at all (llama.cpp's `--jinja` does the same thing, defaulting
33//! to ChatML) and for the server's synthetic-weights demo path.
34//!
35//! # Failing loudly
36//!
37//! A template that does not compile, or that uses a filter/test/function
38//! this evaluator does not provide, produces a [`TemplateError`] that
39//! propagates to the caller as a request failure. It does **not** fall
40//! back to a hand-written renderer: silently serving a Mistral checkpoint
41//! ChatML framing is exactly the class of bug this module exists to
42//! delete, and the repo's rule is to land the refusal when the math is
43//! not there. The one thing that *is* a fallback is "the checkpoint
44//! carries no template", which is a genuine absence rather than a
45//! guess.
46//!
47//! Known Jinja constructs and how they are handled:
48//!
49//! | Construct | Status |
50//! |---|---|
51//! | `{%- … -%}` whitespace control | supported by minijinja |
52//! | `{% macro %}` / recursive macros | supported |
53//! | `{% set ns = namespace(...) %}` and loop-scope writes through it | supported |
54//! | `{% set x %}…{% endset %}` block set | supported |
55//! | `loop.index0` / `loop.last` / `loop.first` | supported |
56//! | `raise_exception(msg)` | provided here; aborts the render with the message |
57//! | `strftime_now(fmt)` | provided here, UTC, subset of `strftime` (see [`strftime_now`]) |
58//! | `dictsort`, `map`, `default`, `trim`, `reject`, `join`, slicing | minijinja builtins |
59//! | `tojson` | reimplemented here to match jinja2's `json.dumps(sort_keys=True)` byte for byte |
60//! | Python methods `.get()`, `.split()`, `.strip()/.lstrip()/.rstrip()` | provided here (see [`python_method`]) |
61//! | anything else | **hard error**, never silently empty |
62//!
63//! The one deliberate difference from `jinja2`: undefined variables are
64//! *lenient* (falsy in a condition, empty when printed) rather than
65//! `StrictUndefined`, because that is what HuggingFace's
66//! `apply_chat_template` uses and templates rely on it — e.g. gemma-3
67//! tests `{%- if add_generation_prompt -%}` without the caller having to
68//! define it.
69
70use std::sync::Arc;
71
72use minijinja::value::Value as JinjaValue;
73use serde_json::Value;
74
75/// Everything that can go wrong between a GGUF's template string and a
76/// rendered prompt. Every variant is a refusal, not a fallback.
77#[derive(Debug, Clone, thiserror::Error)]
78pub enum TemplateError {
79    /// `tokenizer.chat_template` is not valid Jinja2, or uses syntax
80    /// minijinja does not parse.
81    #[error("chat template does not compile: {0}")]
82    Compile(String),
83    /// The template compiled but the render failed: an unknown filter,
84    /// test or function; an explicit `raise_exception(...)`; a type
85    /// error inside the template.
86    #[error("chat template failed to render: {0}")]
87    Render(String),
88}
89
90/// Hand-written renderers, kept only for checkpoints that ship no
91/// `tokenizer.chat_template` at all.
92///
93/// These are the six variants the sniffing implementation used. They are
94/// no longer *selected* by sniffing — a checkpoint either has a template
95/// (and it is evaluated) or it does not (and [`BuiltinTemplate::ChatMl`]
96/// or [`BuiltinTemplate::Plain`] applies, matching llama.cpp `--jinja`).
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum BuiltinTemplate {
99    /// `<|im_start|>{role}\n{content}<|im_end|>\n`, ending with
100    /// `<|im_start|>assistant\n`. llama.cpp's `CHATML_TEMPLATE_SRC`
101    /// default for a real checkpoint with no template of its own.
102    ChatMl,
103    /// `{role}: {content}` lines, no special tokens — for byte/synthetic
104    /// tokenizers where no real vocabulary exists to carry markers.
105    Plain,
106}
107
108enum Kind {
109    /// The checkpoint's own template, compiled.
110    Jinja(Box<JinjaTemplate>),
111    /// The checkpoint's own template, which did not compile. Kept as an
112    /// error rather than replaced by a guess, so the failure surfaces at
113    /// the request instead of as wrong-looking output.
114    Broken(TemplateError),
115    /// No template in the checkpoint.
116    Builtin(BuiltinTemplate),
117}
118
119struct JinjaTemplate {
120    env: minijinja::Environment<'static>,
121    source: String,
122}
123
124/// A compiled chat template. Cheap to clone (`Arc` inside) because every
125/// `*Loaded` struct in `ferrox-server` carries one and hands it to each
126/// request.
127#[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/// Everything a template can read besides `messages`.
141///
142/// `extra` is the OpenAI-extension `chat_template_kwargs` passthrough:
143/// whatever the client puts there becomes a top-level template variable,
144/// which is how `enable_thinking` (Qwen3, gemma-4), `thinking`
145/// (DeepSeek), and `preserve_thinking` are actually driven. Values in
146/// `extra` never shadow `messages`/`tools`/`add_generation_prompt`.
147#[derive(Debug, Clone, Default)]
148pub struct RenderOptions {
149    pub add_generation_prompt: bool,
150    /// The vocabulary's BOS text (`<s>`, `<|begin_of_text|>`, `<bos>`).
151    /// Templates that print `{{ bos_token }}` own BOS insertion; see
152    /// `ferrox-server`'s `generate` for why that does not double-add.
153    pub bos_token: Option<String>,
154    pub eos_token: Option<String>,
155    /// OpenAI `tools` array, verbatim. Passed to every template; only
156    /// templates that mention `tools` do anything with it (see
157    /// [`ChatTemplate::handles_tools`]).
158    pub tools: Vec<Value>,
159    pub extra: serde_json::Map<String, Value>,
160}
161
162impl ChatTemplate {
163    /// Compiles `source` as Jinja2. Errors are returned, never swallowed.
164    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    /// The load-time entry point: what a GGUF's metadata says.
179    ///
180    /// * a non-empty `tokenizer.chat_template` is compiled, and a compile
181    ///   failure is *recorded* (so the load still succeeds and
182    ///   `/v1/completions` still works) but makes every chat render fail
183    ///   with the compiler's message;
184    /// * no template + a real tokenizer ⇒ ChatML, matching llama.cpp
185    ///   `--jinja`'s `CHATML_TEMPLATE_SRC` default;
186    /// * no template + a byte/synthetic tokenizer ⇒ `Plain`, since there
187    ///   is no real vocabulary for markers to live in.
188    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    /// True when this is the checkpoint's own compiled template.
204    pub fn is_jinja(&self) -> bool {
205        matches!(&*self.0, Kind::Jinja(_))
206    }
207
208    /// The compiled Jinja source, for callers that need to inspect it.
209    pub fn source(&self) -> Option<&str> {
210        match &*self.0 {
211            Kind::Jinja(t) => Some(&t.source),
212            _ => None,
213        }
214    }
215
216    /// Whether the template itself renders `tools`.
217    ///
218    /// Templates that never mention `tools` cannot express a tool call,
219    /// so a caller offering tools to such a checkpoint has to fall back
220    /// to describing them in a system message (`ferrox-server`'s
221    /// `tool_preamble`). This is a textual check on the template source,
222    /// which is what llama.cpp's `common/chat.cpp` does too
223    /// (`caps.supports_tools` is probed by rendering, but the cheap
224    /// source check is what gates it here).
225    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    /// Short human-readable identity, for the load-time log line.
233    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    /// Renders `messages` (OpenAI-shaped JSON objects) into a prompt.
242    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                // `chat_template_kwargs` first, so it can never shadow the
257                // structural variables below.
258                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                // `tools` is always bound, as JSON `null` when the
267                // request offered none. Leaving it *undefined* is a real
268                // bug: Llama-3.1's template gates its whole ipython
269                // tool-calling preamble on `{%- if tools is not none %}`,
270                // and an undefined value is not none, so a plain chat
271                // request got a tool-calling system prompt it never asked
272                // for. HuggingFace's `apply_chat_template` passes
273                // `tools=None` explicitly for the same reason.
274                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
297/// minijinja reports the interesting part of a failure in the *cause*
298/// chain (an unknown filter, or a `raise_exception` message), and the
299/// `Display` of the top error alone often reads as a bare
300/// "invalid operation". Flatten the whole chain so a refusal names what
301/// the template actually asked for.
302fn 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    // HuggingFace's `apply_chat_template` uses jinja2's default
318    // `Undefined`, not `StrictUndefined`: templates freely test
319    // `{% if add_generation_prompt %}` or `{% if tools %}` without the
320    // caller defining them. `Lenient` is minijinja's equivalent —
321    // undefined is falsy and prints empty, but any *operation* on it
322    // (indexing, arithmetic, calling) is still an error.
323    env.set_undefined_behavior(minijinja::UndefinedBehavior::Lenient);
324    // Real templates are one giant expression; the default recursion
325    // limit is fine, but gemma-4's `format_parameters` recurses through
326    // nested JSON schemas, so keep the default rather than lowering it.
327    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
334/// `{{ tool | tojson }}` — how every tool-calling template serialises a
335/// function schema into the prompt, so its exact byte output is part of
336/// the prompt the model was trained on.
337///
338/// Overrides minijinja's builtin, which emits `{"a":1}`. jinja2's
339/// `tojson` is `json.dumps` with `sort_keys=True` (jinja2's default
340/// policy) and `json.dumps`' default `", "` / `": "` separators, i.e.
341/// `{"a": 1}`. Matching that is the difference between the prompt
342/// HuggingFace produces and a near-miss.
343///
344/// One disclosed deviation from jinja2: no `htmlsafe_json_dumps`
345/// escaping of `< > & '` into `<`-style escapes. llama.cpp's minja
346/// does not do it either, and it is llama.cpp that this engine is
347/// checked against.
348fn 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            // jinja2's default policy is `json.dumps(..., sort_keys=True)`.
364            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
391/// jinja2 runs on Python, so templates call Python *methods* on the
392/// values the caller passed in — `message.get('tool_calls')`,
393/// `content.split('</think>')[-1].lstrip('\n')`. minijinja has no such
394/// methods (they are not Jinja, they are Python leaking through), so
395/// they arrive here.
396///
397/// This implements the five the real templates in `tests/templates/`
398/// actually use, with Python's semantics including the optional
399/// `strip(chars)` argument. Anything else keeps minijinja's
400/// `UnknownMethod` error, which surfaces as a [`TemplateError::Render`]
401/// naming the method — a refusal, not an empty string.
402fn 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        // dict.get(key[, default])
421        "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        // str.split(sep) -- Python's whitespace split when sep is absent.
442        "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
482/// `{{ raise_exception("...") }}` — the standard HuggingFace escape
483/// hatch for "this conversation is not representable in this template"
484/// (mistral and gemma-3 both use it to reject non-alternating roles).
485/// Aborts the render; the message reaches the client.
486fn 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
493/// `{{ strftime_now("%d %b %Y") }}` — Llama-3.1's template stamps
494/// today's date into its system preamble with it.
495///
496/// UTC, and a deliberately small `strftime` subset: `%Y %y %m %d %e %H
497/// %M %S %j %B %b %A %a %F %T %%`, plus the `-` no-pad flag
498/// (`%-d`, `%-m`). Anything else is an error rather than a silently
499/// wrong date — a model told the wrong year is a real quality bug and it
500/// would never show up as a crash.
501///
502/// `FERROX_CHAT_TEMPLATE_NOW` (Unix seconds) pins the clock, which is
503/// how the regression tests below assert an exact string.
504fn 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
553/// Civil date from a Unix timestamp (Howard Hinnant's `civil_from_days`),
554/// then a `strftime` subset. Returns `Err(spec)` naming the first
555/// unsupported specifier.
556fn 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    // 1970-01-01 was a Thursday, hence WEEKDAYS's rotation.
561    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    // Day-of-year needs the calendar year's own Jan 1.
575    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 is space-padded day-of-month.
605            '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
623/// Text a message contributes to a builtin render: `content` as a
624/// string (or the concatenated `text` parts of an OpenAI content array),
625/// plus any `tool_calls` re-rendered as the `<tool_call>{…}</tool_call>`
626/// marker text a model is asked to emit for a *new* call.
627fn 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    // ---- the four constructs the plan named ------------------------
708
709    /// `{{ bos_token }}`: the template, not the loader, decides where BOS
710    /// goes. Mistral-7B-Instruct-v0.2's real GGUF template, verbatim.
711    #[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    /// The sniffing implementation matched *no* marker in that template
729    /// and rendered `user: hi` instead. This is the bug, pinned.
730    #[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    /// A system message: gemma-3's real GGUF template folds it into the
742    /// first user turn, which the hand-written `Gemma` renderer only
743    /// approximated (it always joined with `\n\n`, and never emitted
744    /// `<bos>` or the multimodal `<start_of_image>` arm).
745    #[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    /// Multimodal content parts reach `<start_of_image>` — which the
766    /// hand-written renderer dropped on the floor.
767    #[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    /// A tool-call block: Qwen2.5's real GGUF template, the `tools`
790    /// preamble plus a replayed assistant `tool_calls` turn plus a
791    /// `role: tool` result. None of this was reachable before — no
792    /// hand-written renderer read `tools` at all.
793    #[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                // `tojson`: sorted keys and `", "` / `": "` separators,
827                // exactly as jinja2's `json.dumps(sort_keys=True)` does.
828                "{\"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    /// `add_generation_prompt` is honoured both ways. The hand-written
847    /// renderers appended the assistant header unconditionally, so a
848    /// caller could not ask for a prefix-only render (what a
849    /// prefill/scoring path or a "continue this reply" request needs).
850    #[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    /// `add_generation_prompt` + `{{ bos_token }}` + a system message on
879    /// the real Llama-3.1-8B-Instruct template, which is also the
880    /// regression for a bug this rewrite introduced and then fixed:
881    /// leaving `tools` *undefined* rather than binding it to `null` made
882    /// `{%- if tools is not none %}` true, so every plain chat request
883    /// got Llama's ipython tool-calling preamble.
884    #[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    // ---- sharp edges the plan named --------------------------------
912
913    #[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        // 2024-07-04T12:34:56Z
928        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    /// Whitespace control (`{%- … -%}`) is what makes gemma-3 render as
952    /// one unbroken line despite being written across 40 indented lines.
953    /// If it were ignored the prompt would be full of stray newlines.
954    #[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    /// `namespace()` is the standard workaround for Jinja loop scoping —
966    /// a plain `{% set %}` inside a `{% for %}` does not escape it.
967    /// gemma-4's real template uses six namespaces.
968    #[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    /// An unknown filter must be a refusal, not an empty string.
980    #[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        // Specifically NOT a silent fallback to a hand-written renderer.
1002        assert!(t.describe().starts_with("BROKEN"), "{}", t.describe());
1003    }
1004
1005    // ---- chat_template_kwargs passthrough --------------------------
1006
1007    #[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    // ---- gemma-4: the variant the plan says was never implemented ---
1053
1054    /// The hand-written `ChatTemplate::Gemma4` rendered
1055    /// `<|turn>user\n…<turn|>\n<|turn>model\n` and nothing else. The real
1056    /// template injects a `<|think|>` channel into the first system turn
1057    /// when `enable_thinking` is set — driven by `chat_template_kwargs`,
1058    /// which had no path to it at all before.
1059    #[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    /// `strip_thinking`: a replayed assistant turn must have its
1093    /// `<|channel>…<channel|>` reasoning removed before it goes back into
1094    /// the prompt. The hand-written renderer replayed it verbatim.
1095    #[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    // ---- builtins (no template in the checkpoint) -------------------
1123
1124    #[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    // ---- real template strings, verbatim from local GGUFs -----------
1210
1211    /// `tokenizer.chat_template` of `models/gemma-3-1b-it-Q8_0.gguf`,
1212    /// read out of the file's metadata, not paraphrased.
1213    const GEMMA3_TEMPLATE: &str = include_str!("../tests/templates/gemma-3-1b-it.jinja");
1214    /// `tokenizer.chat_template` of `models/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf`.
1215    const QWEN25_TEMPLATE: &str = include_str!("../tests/templates/qwen2.5-instruct.jinja");
1216    /// `tokenizer.chat_template` of `models/gemma-4-E2B-it-Q4_K_M.gguf`,
1217    /// all 18 KB of it: six macros, six namespaces, recursive
1218    /// `format_parameters`, `{% set … %}{% endset %}` block capture,
1219    /// string slicing and `.split()`. This is the template the plan
1220    /// records as "checked, and `ChatTemplate::Gemma4` does not
1221    /// implement it".
1222    const GEMMA4_TEMPLATE_CORE: &str = include_str!("../tests/templates/gemma-4-E2B-it.jinja");
1223    /// `tokenizer.chat_template` of `models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf`.
1224    const LLAMA31_TEMPLATE: &str = include_str!("../tests/templates/llama-3.1-8b-instruct.jinja");
1225}