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; `json.dumps` separators, keys sorted (see [`tojson`]) |
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    // The two whitespace flags a chat template is authored against, and
325    // the only two settings in this function whose absence is *silent*.
326    //
327    // HuggingFace compiles every chat template with
328    // `ImmutableSandboxedEnvironment(trim_blocks=True, lstrip_blocks=True)`
329    // and llama.cpp's own Jinja engine hardcodes the same pair for chat
330    // templates (`common/jinja/lexer.cpp:112-118`: "default config for
331    // chat template: lstrip_blocks = true, trim_blocks = true").
332    // minijinja defaults both to `false`, matching stock jinja2 rather
333    // than either engine that actually renders these strings.
334    //
335    // A template written with explicit `{%- … -%}` markers is unaffected,
336    // which is why most of `tests/templates/` renders identically either
337    // way and this went unnoticed. TinyLlama-1.1B-Chat's real template is
338    // not written that way: without these two flags its three-turn render
339    // is `\n\n<|user|>\n…</s>\n\n\n\n\n<|assistant|>\n…`, thirteen bytes of
340    // stray blank line that the checkpoint was never trained on and that
341    // llama.cpp does not emit. `whitespace_control_matches_huggingface_and_llama_cpp`
342    // pins it.
343    env.set_trim_blocks(true);
344    env.set_lstrip_blocks(true);
345    // Real templates are one giant expression; the default recursion
346    // limit is fine, but gemma-4's `format_parameters` recurses through
347    // nested JSON schemas, so keep the default rather than lowering it.
348    env.add_function("raise_exception", raise_exception);
349    env.add_function("strftime_now", strftime_now);
350    env.add_filter("tojson", tojson);
351    env.set_unknown_method_callback(python_method);
352    env
353}
354
355/// `{{ tool | tojson }}` — how every tool-calling template serialises a
356/// function schema into the prompt, so its exact byte output is part of
357/// the prompt the model was trained on.
358///
359/// Overrides minijinja's builtin, which emits `{"a":1}`. Both reference
360/// engines use `json.dumps`' default `", "` / `": "` separators, i.e.
361/// `{"a": 1}`, and matching that is the difference between the prompt
362/// HuggingFace produces and a near-miss.
363///
364/// Two disclosed deviations, both deliberate:
365///
366/// 1. **Key order.** This sorts, which is *stock* jinja2's default
367///    policy (`policies["json.dumps_kwargs"] = {"sort_keys": True}`).
368///    Neither engine that actually renders chat templates does:
369///    transformers replaces the filter with
370///    `json.dumps(..., sort_keys=False)`, and llama.cpp's refuses
371///    `sort_keys=true` outright (`common/jinja/value.cpp:251`). Ferrox
372///    cannot follow them today for a reason below this module:
373///    `serde_json::Map` is a `BTreeMap` unless the whole workspace turns
374///    on `serde_json/preserve_order`, so a tool schema arrives here
375///    already sorted and the author's key order is gone before `tojson`
376///    ever sees it. The visible effect is the order of the keys inside a
377///    `<tools>` block, not their content.
378/// 2. No `htmlsafe_json_dumps` escaping of `< > & '` into `<`-style
379///    escapes. llama.cpp does not do it either, and it is llama.cpp that
380///    this engine is checked against.
381fn tojson(value: JinjaValue) -> Result<String, minijinja::Error> {
382    let json: Value = serde_json::to_value(&value).map_err(|e| {
383        minijinja::Error::new(
384            minijinja::ErrorKind::InvalidOperation,
385            format!("tojson: value is not serialisable: {e}"),
386        )
387    })?;
388    let mut out = String::new();
389    write_python_json(&json, &mut out);
390    Ok(out)
391}
392
393fn write_python_json(v: &Value, out: &mut String) {
394    match v {
395        Value::Object(map) => {
396            // jinja2's default policy is `json.dumps(..., sort_keys=True)`.
397            let mut keys: Vec<&String> = map.keys().collect();
398            keys.sort();
399            out.push('{');
400            for (i, k) in keys.iter().enumerate() {
401                if i > 0 {
402                    out.push_str(", ");
403                }
404                out.push_str(&Value::String((*k).clone()).to_string());
405                out.push_str(": ");
406                write_python_json(&map[*k], out);
407            }
408            out.push('}');
409        }
410        Value::Array(items) => {
411            out.push('[');
412            for (i, item) in items.iter().enumerate() {
413                if i > 0 {
414                    out.push_str(", ");
415                }
416                write_python_json(item, out);
417            }
418            out.push(']');
419        }
420        other => out.push_str(&other.to_string()),
421    }
422}
423
424/// jinja2 runs on Python, so templates call Python *methods* on the
425/// values the caller passed in — `message.get('tool_calls')`,
426/// `content.split('</think>')[-1].lstrip('\n')`. minijinja has no such
427/// methods (they are not Jinja, they are Python leaking through), so
428/// they arrive here.
429///
430/// This implements the five the real templates in `tests/templates/`
431/// actually use, with Python's semantics including the optional
432/// `strip(chars)` argument. Anything else keeps minijinja's
433/// `UnknownMethod` error, which surfaces as a [`TemplateError::Render`]
434/// naming the method — a refusal, not an empty string.
435fn python_method(
436    _state: &minijinja::State,
437    value: &JinjaValue,
438    method: &str,
439    args: &[JinjaValue],
440) -> Result<JinjaValue, minijinja::Error> {
441    fn unknown() -> minijinja::Error {
442        minijinja::Error::from(minijinja::ErrorKind::UnknownMethod)
443    }
444    fn as_str(v: &JinjaValue) -> Result<&str, minijinja::Error> {
445        v.as_str().ok_or_else(|| {
446            minijinja::Error::new(
447                minijinja::ErrorKind::InvalidOperation,
448                "expected a string argument",
449            )
450        })
451    }
452    match method {
453        // dict.get(key[, default])
454        "get" => {
455            if value.as_object().is_none() {
456                return Err(unknown());
457            }
458            let (key, default) = match args {
459                [k] => (k, JinjaValue::from(())),
460                [k, d] => (k, d.clone()),
461                _ => {
462                    return Err(minijinja::Error::new(
463                        minijinja::ErrorKind::InvalidOperation,
464                        "get() takes 1 or 2 arguments",
465                    ))
466                }
467            };
468            Ok(value
469                .get_item(key)
470                .ok()
471                .filter(|v| !v.is_undefined())
472                .unwrap_or(default))
473        }
474        // str.split(sep) -- Python's whitespace split when sep is absent.
475        "split" => {
476            let s = value.as_str().ok_or_else(unknown)?;
477            let parts: Vec<JinjaValue> = match args {
478                [] => s.split_whitespace().map(JinjaValue::from).collect(),
479                [sep] => s.split(as_str(sep)?).map(JinjaValue::from).collect(),
480                _ => {
481                    return Err(minijinja::Error::new(
482                        minijinja::ErrorKind::InvalidOperation,
483                        "ferrox implements split() with at most one separator argument",
484                    ))
485                }
486            };
487            Ok(JinjaValue::from(parts))
488        }
489        "strip" | "lstrip" | "rstrip" => {
490            let s = value.as_str().ok_or_else(unknown)?;
491            let chars: Option<Vec<char>> = match args {
492                [] => None,
493                [c] => Some(as_str(c)?.chars().collect()),
494                _ => {
495                    return Err(minijinja::Error::new(
496                        minijinja::ErrorKind::InvalidOperation,
497                        "strip() takes at most one argument",
498                    ))
499                }
500            };
501            let pred = |c: char| match &chars {
502                Some(set) => set.contains(&c),
503                None => c.is_whitespace(),
504            };
505            Ok(JinjaValue::from(match method {
506                "strip" => s.trim_matches(pred),
507                "lstrip" => s.trim_start_matches(pred),
508                _ => s.trim_end_matches(pred),
509            }))
510        }
511        _ => Err(unknown()),
512    }
513}
514
515/// `{{ raise_exception("...") }}` — the standard HuggingFace escape
516/// hatch for "this conversation is not representable in this template"
517/// (mistral and gemma-3 both use it to reject non-alternating roles).
518/// Aborts the render; the message reaches the client.
519fn raise_exception(msg: String) -> Result<JinjaValue, minijinja::Error> {
520    Err(minijinja::Error::new(
521        minijinja::ErrorKind::InvalidOperation,
522        format!("template raised: {msg}"),
523    ))
524}
525
526/// `{{ strftime_now("%d %b %Y") }}` — Llama-3.1's template stamps
527/// today's date into its system preamble with it.
528///
529/// UTC, and a deliberately small `strftime` subset: `%Y %y %m %d %e %H
530/// %M %S %j %B %b %A %a %F %T %%`, plus the `-` no-pad flag
531/// (`%-d`, `%-m`). Anything else is an error rather than a silently
532/// wrong date — a model told the wrong year is a real quality bug and it
533/// would never show up as a crash.
534///
535/// `FERROX_TEST_CHAT_TEMPLATE_NOW` (Unix seconds) pins the clock, which is
536/// how the regression tests below assert an exact string.
537fn strftime_now(fmt: String) -> Result<String, minijinja::Error> {
538    let secs = match std::env::var("FERROX_TEST_CHAT_TEMPLATE_NOW") {
539        Ok(v) => v.trim().parse::<i64>().map_err(|_| {
540            minijinja::Error::new(
541                minijinja::ErrorKind::InvalidOperation,
542                "FERROX_TEST_CHAT_TEMPLATE_NOW must be Unix seconds",
543            )
544        })?,
545        Err(_) => std::time::SystemTime::now()
546            .duration_since(std::time::UNIX_EPOCH)
547            .map(|d| d.as_secs() as i64)
548            .unwrap_or(0),
549    };
550    format_utc(secs, &fmt).map_err(|spec| {
551        minijinja::Error::new(
552            minijinja::ErrorKind::InvalidOperation,
553            format!(
554                "strftime_now: unsupported format specifier `%{spec}` in {fmt:?} -- \
555                 ferrox implements a subset (%Y %y %m %d %e %H %M %S %j %B %b %A %a %F %T %%) \
556                 and refuses rather than stamping a wrong date into the prompt"
557            ),
558        )
559    })
560}
561
562const MONTHS: [&str; 12] = [
563    "January",
564    "February",
565    "March",
566    "April",
567    "May",
568    "June",
569    "July",
570    "August",
571    "September",
572    "October",
573    "November",
574    "December",
575];
576const WEEKDAYS: [&str; 7] = [
577    "Thursday",
578    "Friday",
579    "Saturday",
580    "Sunday",
581    "Monday",
582    "Tuesday",
583    "Wednesday",
584];
585
586/// Civil date from a Unix timestamp (Howard Hinnant's `civil_from_days`),
587/// then a `strftime` subset. Returns `Err(spec)` naming the first
588/// unsupported specifier.
589fn format_utc(secs: i64, fmt: &str) -> Result<String, char> {
590    let days = secs.div_euclid(86_400);
591    let tod = secs.rem_euclid(86_400);
592    let (hour, minute, second) = (tod / 3600, (tod % 3600) / 60, tod % 60);
593    // 1970-01-01 was a Thursday, hence WEEKDAYS's rotation.
594    let weekday = days.rem_euclid(7) as usize;
595
596    let z = days + 719_468;
597    let era = z.div_euclid(146_097);
598    let doe = z.rem_euclid(146_097);
599    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
600    let y = yoe + era * 400;
601    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
602    let mp = (5 * doy + 2) / 153;
603    let day = doy - (153 * mp + 2) / 5 + 1;
604    let month = if mp < 10 { mp + 3 } else { mp - 9 };
605    let year = if month <= 2 { y + 1 } else { y };
606
607    // Day-of-year needs the calendar year's own Jan 1.
608    let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
609    const CUM: [i64; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
610    let yday = CUM[(month - 1) as usize] + day + i64::from(leap && month > 2);
611
612    let mut out = String::with_capacity(fmt.len() + 8);
613    let mut chars = fmt.chars().peekable();
614    while let Some(c) = chars.next() {
615        if c != '%' {
616            out.push(c);
617            continue;
618        }
619        let mut pad = true;
620        let mut spec = chars.next().ok_or('%')?;
621        if spec == '-' {
622            pad = false;
623            spec = chars.next().ok_or('-')?;
624        }
625        let num = |out: &mut String, v: i64, w: usize| {
626            if pad {
627                out.push_str(&format!("{v:0w$}"));
628            } else {
629                out.push_str(&v.to_string());
630            }
631        };
632        match spec {
633            'Y' => out.push_str(&year.to_string()),
634            'y' => num(&mut out, year.rem_euclid(100), 2),
635            'm' => num(&mut out, month, 2),
636            'd' => num(&mut out, day, 2),
637            // %e is space-padded day-of-month.
638            'e' => out.push_str(&format!("{day:2}")),
639            'H' => num(&mut out, hour, 2),
640            'M' => num(&mut out, minute, 2),
641            'S' => num(&mut out, second, 2),
642            'j' => num(&mut out, yday, 3),
643            'B' => out.push_str(MONTHS[(month - 1) as usize]),
644            'b' => out.push_str(&MONTHS[(month - 1) as usize][..3]),
645            'A' => out.push_str(WEEKDAYS[weekday]),
646            'a' => out.push_str(&WEEKDAYS[weekday][..3]),
647            'F' => out.push_str(&format!("{year:04}-{month:02}-{day:02}")),
648            'T' => out.push_str(&format!("{hour:02}:{minute:02}:{second:02}")),
649            '%' => out.push('%'),
650            other => return Err(other),
651        }
652    }
653    Ok(out)
654}
655
656/// Text a message contributes to a builtin render: `content` as a
657/// string (or the concatenated `text` parts of an OpenAI content array),
658/// plus any `tool_calls` re-rendered as the `<tool_call>{…}</tool_call>`
659/// marker text a model is asked to emit for a *new* call.
660fn builtin_message_text(m: &Value) -> String {
661    let mut out = match m.get("content") {
662        Some(Value::String(s)) => s.clone(),
663        Some(Value::Array(parts)) => parts
664            .iter()
665            .filter_map(|p| p.get("text").and_then(Value::as_str))
666            .collect::<Vec<_>>()
667            .join(""),
668        _ => String::new(),
669    };
670    if let Some(Value::Array(calls)) = m.get("tool_calls") {
671        for call in calls {
672            let f = call.get("function");
673            let name = f
674                .and_then(|f| f.get("name"))
675                .and_then(Value::as_str)
676                .unwrap_or("");
677            let args = f
678                .and_then(|f| f.get("arguments"))
679                .map(|a| match a {
680                    Value::String(s) => s.clone(),
681                    other => other.to_string(),
682                })
683                .unwrap_or_else(|| "{}".to_string());
684            out.push_str(&format!(
685                "<tool_call>{{\"name\": \"{name}\", \"arguments\": {args}}}</tool_call>"
686            ));
687        }
688    }
689    out
690}
691
692fn builtin_role(m: &Value) -> &str {
693    m.get("role").and_then(Value::as_str).unwrap_or("user")
694}
695
696fn render_builtin(b: BuiltinTemplate, messages: &[Value], opts: &RenderOptions) -> String {
697    let mut out = String::new();
698    match b {
699        BuiltinTemplate::ChatMl => {
700            for m in messages {
701                out.push_str("<|im_start|>");
702                out.push_str(builtin_role(m));
703                out.push('\n');
704                out.push_str(&builtin_message_text(m));
705                out.push_str("<|im_end|>\n");
706            }
707            if opts.add_generation_prompt {
708                out.push_str("<|im_start|>assistant\n");
709            }
710        }
711        BuiltinTemplate::Plain => {
712            let lines: Vec<String> = messages
713                .iter()
714                .map(|m| format!("{}: {}", builtin_role(m), builtin_message_text(m)))
715                .collect();
716            out.push_str(&lines.join("\n"));
717        }
718    }
719    out
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725    use serde_json::json;
726
727    fn msg(role: &str, content: &str) -> Value {
728        json!({"role": role, "content": content})
729    }
730
731    fn opts() -> RenderOptions {
732        RenderOptions {
733            add_generation_prompt: true,
734            bos_token: Some("<s>".into()),
735            eos_token: Some("</s>".into()),
736            ..Default::default()
737        }
738    }
739
740    // ---- the four constructs the plan named ------------------------
741
742    /// `{{ bos_token }}`: the template, not the loader, decides where BOS
743    /// goes. Mistral-7B-Instruct-v0.2's real GGUF template, verbatim.
744    #[test]
745    fn renders_bos_token_and_the_real_mistral_inst_framing() {
746        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 %}";
747        let t = ChatTemplate::from_jinja(src).unwrap();
748        let out = t
749            .render(
750                &[
751                    msg("user", "hi"),
752                    msg("assistant", "hello"),
753                    msg("user", "2+2?"),
754                ],
755                &opts(),
756            )
757            .unwrap();
758        assert_eq!(out, "<s>[INST] hi [/INST]hello</s>[INST] 2+2? [/INST]");
759    }
760
761    /// The sniffing implementation matched *no* marker in that template
762    /// and rendered `user: hi` instead. This is the bug, pinned.
763    #[test]
764    fn mistral_is_not_plain_role_labelled_lines() {
765        let src = "{{ bos_token }}{% for message in messages %}{% if message['role'] == 'user' %}{{ '[INST] ' + message['content'] + ' [/INST]' }}{% endif %}{% endfor %}";
766        let out = ChatTemplate::from_jinja(src)
767            .unwrap()
768            .render(&[msg("user", "hi")], &opts())
769            .unwrap();
770        assert!(!out.contains("user: hi"), "{out}");
771        assert!(out.contains("[INST]"), "{out}");
772    }
773
774    /// A system message: gemma-3's real GGUF template folds it into the
775    /// first user turn, which the hand-written `Gemma` renderer only
776    /// approximated (it always joined with `\n\n`, and never emitted
777    /// `<bos>` or the multimodal `<start_of_image>` arm).
778    #[test]
779    fn renders_a_system_message_with_the_real_gemma3_template() {
780        let src = GEMMA3_TEMPLATE;
781        let t = ChatTemplate::from_jinja(src).unwrap();
782        let out = t
783            .render(
784                &[msg("system", "be brief"), msg("user", "hi")],
785                &RenderOptions {
786                    add_generation_prompt: true,
787                    bos_token: Some("<bos>".into()),
788                    ..Default::default()
789                },
790            )
791            .unwrap();
792        assert_eq!(
793            out,
794            "<bos><start_of_turn>user\nbe brief\n\nhi<end_of_turn>\n<start_of_turn>model\n"
795        );
796    }
797
798    /// Multimodal content parts reach `<start_of_image>` — which the
799    /// hand-written renderer dropped on the floor.
800    #[test]
801    fn gemma3_emits_the_image_placeholder_for_content_parts() {
802        let out = ChatTemplate::from_jinja(GEMMA3_TEMPLATE)
803            .unwrap()
804            .render(
805                &[json!({"role": "user", "content": [
806                    {"type": "image"},
807                    {"type": "text", "text": "what is this?"}
808                ]})],
809                &RenderOptions {
810                    add_generation_prompt: true,
811                    bos_token: Some("<bos>".into()),
812                    ..Default::default()
813                },
814            )
815            .unwrap();
816        assert_eq!(
817            out,
818            "<bos><start_of_turn>user\n<start_of_image>what is this?<end_of_turn>\n<start_of_turn>model\n"
819        );
820    }
821
822    /// A tool-call block: Qwen2.5's real GGUF template, the `tools`
823    /// preamble plus a replayed assistant `tool_calls` turn plus a
824    /// `role: tool` result. None of this was reachable before — no
825    /// hand-written renderer read `tools` at all.
826    #[test]
827    fn renders_a_tool_call_block_with_the_real_qwen25_template() {
828        let t = ChatTemplate::from_jinja(QWEN25_TEMPLATE).unwrap();
829        assert!(t.handles_tools());
830        let out = t
831            .render(
832                &[
833                    msg("user", "weather in Paris?"),
834                    json!({"role": "assistant", "content": "", "tool_calls": [
835                        {"type": "function", "function": {"name": "get_weather", "arguments": {"city": "Paris"}}}
836                    ]}),
837                    json!({"role": "tool", "content": "18C"}),
838                ],
839                &RenderOptions {
840                    add_generation_prompt: true,
841                    tools: vec![json!({"type": "function", "function": {
842                        "name": "get_weather",
843                        "description": "Current weather",
844                        "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
845                    }})],
846                    ..Default::default()
847                },
848            )
849            .unwrap();
850        assert_eq!(
851            out,
852            concat!(
853                "<|im_start|>system\n",
854                "You are Qwen, created by Alibaba Cloud. You are a helpful assistant.\n\n",
855                "# Tools\n\n",
856                "You may call one or more functions to assist with the user query.\n\n",
857                "You are provided with function signatures within <tools></tools> XML tags:\n",
858                "<tools>\n",
859                // `tojson`: sorted keys and `", "` / `": "` separators,
860                // exactly as jinja2's `json.dumps(sort_keys=True)` does.
861                "{\"function\": {\"description\": \"Current weather\", \"name\": \"get_weather\", ",
862                "\"parameters\": {\"properties\": {\"city\": {\"type\": \"string\"}}, ",
863                "\"type\": \"object\"}}, \"type\": \"function\"}\n",
864                "</tools>\n\n",
865                "For each function call, return a json object with function name and arguments ",
866                "within <tool_call></tool_call> XML tags:\n",
867                "<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n",
868                "</tool_call><|im_end|>\n",
869                "<|im_start|>user\nweather in Paris?<|im_end|>\n",
870                "<|im_start|>assistant\n",
871                "<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Paris\"}}\n",
872                "</tool_call><|im_end|>\n",
873                "<|im_start|>user\n<tool_response>\n18C\n</tool_response><|im_end|>\n",
874                "<|im_start|>assistant\n",
875            )
876        );
877    }
878
879    /// `add_generation_prompt` is honoured both ways. The hand-written
880    /// renderers appended the assistant header unconditionally, so a
881    /// caller could not ask for a prefix-only render (what a
882    /// prefill/scoring path or a "continue this reply" request needs).
883    #[test]
884    fn add_generation_prompt_is_honoured_both_ways() {
885        let t = ChatTemplate::from_jinja(GEMMA3_TEMPLATE).unwrap();
886        let with = t
887            .render(
888                &[msg("user", "hi")],
889                &RenderOptions {
890                    add_generation_prompt: true,
891                    ..Default::default()
892                },
893            )
894            .unwrap();
895        let without = t
896            .render(
897                &[msg("user", "hi")],
898                &RenderOptions {
899                    add_generation_prompt: false,
900                    ..Default::default()
901                },
902            )
903            .unwrap();
904        assert_eq!(
905            with,
906            "<start_of_turn>user\nhi<end_of_turn>\n<start_of_turn>model\n"
907        );
908        assert_eq!(without, "<start_of_turn>user\nhi<end_of_turn>\n");
909    }
910
911    /// `add_generation_prompt` + `{{ bos_token }}` + a system message on
912    /// the real Llama-3.1-8B-Instruct template, which is also the
913    /// regression for a bug this rewrite introduced and then fixed:
914    /// leaving `tools` *undefined* rather than binding it to `null` made
915    /// `{%- if tools is not none %}` true, so every plain chat request
916    /// got Llama's ipython tool-calling preamble.
917    #[test]
918    fn llama31_binds_tools_to_null_so_a_plain_chat_gets_no_tool_preamble() {
919        let out = ChatTemplate::from_jinja(LLAMA31_TEMPLATE)
920            .unwrap()
921            .render(
922                &[msg("system", "be brief"), msg("user", "hi")],
923                &RenderOptions {
924                    add_generation_prompt: true,
925                    bos_token: Some("<|begin_of_text|>".into()),
926                    ..Default::default()
927                },
928            )
929            .unwrap();
930        assert_eq!(
931            out,
932            concat!(
933                "<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n",
934                "Cutting Knowledge Date: December 2023\n",
935                "Today Date: 26 Jul 2024\n\n",
936                "be brief<|eot_id|>",
937                "<|start_header_id|>user<|end_header_id|>\n\nhi<|eot_id|>",
938                "<|start_header_id|>assistant<|end_header_id|>\n\n",
939            )
940        );
941        assert!(!out.contains("Environment: ipython"), "{out}");
942    }
943
944    // ---- sharp edges the plan named --------------------------------
945
946    #[test]
947    fn raise_exception_fails_the_render_and_keeps_the_message() {
948        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 %}";
949        let err = ChatTemplate::from_jinja(src)
950            .unwrap()
951            .render(&[msg("assistant", "oops")], &opts())
952            .unwrap_err();
953        let text = err.to_string();
954        assert!(matches!(err, TemplateError::Render(_)), "{text}");
955        assert!(text.contains("roles must alternate"), "{text}");
956    }
957
958    #[test]
959    fn strftime_now_stamps_a_pinned_clock() {
960        // 2024-07-04T12:34:56Z
961        std::env::set_var("FERROX_TEST_CHAT_TEMPLATE_NOW", "1720096496");
962        let out = ChatTemplate::from_jinja(
963            "{{ strftime_now(\"%d %b %Y\") }}|{{ strftime_now('%A %F %T %j %-d') }}",
964        )
965        .unwrap()
966        .render(&[], &opts())
967        .unwrap();
968        std::env::remove_var("FERROX_TEST_CHAT_TEMPLATE_NOW");
969        assert_eq!(out, "04 Jul 2024|Thursday 2024-07-04 12:34:56 186 4");
970    }
971
972    #[test]
973    fn strftime_now_refuses_an_unimplemented_specifier() {
974        let err = ChatTemplate::from_jinja("{{ strftime_now('%Z') }}")
975            .unwrap()
976            .render(&[], &opts())
977            .unwrap_err();
978        assert!(
979            err.to_string().contains("unsupported format specifier"),
980            "{err}"
981        );
982    }
983
984    /// Whitespace control (`{%- … -%}`) is what makes gemma-3 render as
985    /// one unbroken line despite being written across 40 indented lines.
986    /// If it were ignored the prompt would be full of stray newlines.
987    #[test]
988    fn whitespace_control_is_respected() {
989        let out = ChatTemplate::from_jinja(
990            "{%- for m in messages -%}\n    {{- m['role'] -}}\n{%- endfor -%}",
991        )
992        .unwrap()
993        .render(&[msg("user", "x"), msg("assistant", "y")], &opts())
994        .unwrap();
995        assert_eq!(out, "userassistant");
996    }
997
998    /// `namespace()` is the standard workaround for Jinja loop scoping —
999    /// a plain `{% set %}` inside a `{% for %}` does not escape it.
1000    /// gemma-4's real template uses six namespaces.
1001    #[test]
1002    fn namespace_writes_escape_loop_scope() {
1003        let out = ChatTemplate::from_jinja(
1004            "{%- set ns = namespace(n=0) -%}{%- for m in messages -%}{%- set ns.n = ns.n + 1 -%}{%- endfor -%}{{ ns.n }}",
1005        )
1006        .unwrap()
1007        .render(&[msg("user", "a"), msg("user", "b"), msg("user", "c")], &opts())
1008        .unwrap();
1009        assert_eq!(out, "3");
1010    }
1011
1012    /// An unknown filter must be a refusal, not an empty string.
1013    #[test]
1014    fn an_unsupported_construct_fails_loudly() {
1015        let err = ChatTemplate::from_jinja("{{ messages | no_such_filter }}")
1016            .unwrap()
1017            .render(&[msg("user", "hi")], &opts())
1018            .unwrap_err();
1019        let text = err.to_string();
1020        assert!(matches!(err, TemplateError::Render(_)), "{text}");
1021        assert!(text.contains("no_such_filter"), "{text}");
1022    }
1023
1024    #[test]
1025    fn a_template_that_does_not_compile_is_recorded_not_replaced() {
1026        let t = ChatTemplate::from_gguf_metadata(
1027            Some("{% for m in messages %}{{ m }}"),
1028            Some("llama"),
1029            false,
1030        );
1031        assert!(!t.is_jinja());
1032        let err = t.render(&[msg("user", "hi")], &opts()).unwrap_err();
1033        assert!(matches!(err, TemplateError::Compile(_)), "{err}");
1034        // Specifically NOT a silent fallback to a hand-written renderer.
1035        assert!(t.describe().starts_with("BROKEN"), "{}", t.describe());
1036    }
1037
1038    // ---- chat_template_kwargs passthrough --------------------------
1039
1040    #[test]
1041    fn chat_template_kwargs_reach_the_template() {
1042        let src = "{%- if enable_thinking -%}THINK{%- else -%}PLAIN{%- endif -%}";
1043        let t = ChatTemplate::from_jinja(src).unwrap();
1044        let mut extra = serde_json::Map::new();
1045        extra.insert("enable_thinking".into(), Value::Bool(true));
1046        let on = t
1047            .render(
1048                &[msg("user", "hi")],
1049                &RenderOptions {
1050                    extra,
1051                    ..Default::default()
1052                },
1053            )
1054            .unwrap();
1055        let off = t
1056            .render(&[msg("user", "hi")], &RenderOptions::default())
1057            .unwrap();
1058        assert_eq!((on.as_str(), off.as_str()), ("THINK", "PLAIN"));
1059    }
1060
1061    #[test]
1062    fn chat_template_kwargs_cannot_shadow_messages_or_tools() {
1063        let mut extra = serde_json::Map::new();
1064        extra.insert(
1065            "messages".into(),
1066            json!([{"role": "user", "content": "INJECTED"}]),
1067        );
1068        extra.insert("add_generation_prompt".into(), Value::Bool(true));
1069        let out = ChatTemplate::from_jinja(
1070            "{%- for m in messages -%}{{ m['content'] }}{%- endfor -%}|{{ add_generation_prompt }}",
1071        )
1072        .unwrap()
1073        .render(
1074            &[msg("user", "real")],
1075            &RenderOptions {
1076                add_generation_prompt: false,
1077                extra,
1078                ..Default::default()
1079            },
1080        )
1081        .unwrap();
1082        assert_eq!(out, "real|false");
1083    }
1084
1085    // ---- gemma-4: the variant the plan says was never implemented ---
1086
1087    /// The hand-written `ChatTemplate::Gemma4` rendered
1088    /// `<|turn>user\n…<turn|>\n<|turn>model\n` and nothing else. The real
1089    /// template injects a `<|think|>` channel into the first system turn
1090    /// when `enable_thinking` is set — driven by `chat_template_kwargs`,
1091    /// which had no path to it at all before.
1092    #[test]
1093    fn gemma4_thinking_injection_is_reachable_now() {
1094        let t = ChatTemplate::from_jinja(GEMMA4_TEMPLATE_CORE).unwrap();
1095        let mut extra = serde_json::Map::new();
1096        extra.insert("enable_thinking".into(), Value::Bool(true));
1097        let thinking = t
1098            .render(
1099                &[msg("user", "hi")],
1100                &RenderOptions {
1101                    add_generation_prompt: true,
1102                    bos_token: Some("<bos>".into()),
1103                    extra,
1104                    ..Default::default()
1105                },
1106            )
1107            .unwrap();
1108        assert_eq!(
1109            thinking,
1110            "<bos><|turn>system\n<|think|>\n<turn|>\n<|turn>user\nhi<turn|>\n<|turn>model\n"
1111        );
1112        let plain = t
1113            .render(
1114                &[msg("user", "hi")],
1115                &RenderOptions {
1116                    add_generation_prompt: true,
1117                    bos_token: Some("<bos>".into()),
1118                    ..Default::default()
1119                },
1120            )
1121            .unwrap();
1122        assert_eq!(plain, "<bos><|turn>user\nhi<turn|>\n<|turn>model\n");
1123    }
1124
1125    /// `strip_thinking`: a replayed assistant turn must have its
1126    /// `<|channel>…<channel|>` reasoning removed before it goes back into
1127    /// the prompt. The hand-written renderer replayed it verbatim.
1128    #[test]
1129    fn gemma4_strip_thinking_removes_replayed_reasoning() {
1130        let out = ChatTemplate::from_jinja(GEMMA4_TEMPLATE_CORE)
1131            .unwrap()
1132            .render(
1133                &[
1134                    msg("user", "hi"),
1135                    msg(
1136                        "assistant",
1137                        "<|channel>thought\nlet me think<channel|>the answer is 4",
1138                    ),
1139                    msg("user", "again?"),
1140                ],
1141                &RenderOptions {
1142                    add_generation_prompt: true,
1143                    bos_token: Some("<bos>".into()),
1144                    ..Default::default()
1145                },
1146            )
1147            .unwrap();
1148        assert!(!out.contains("let me think"), "{out}");
1149        assert!(
1150            out.contains("<|turn>model\nthe answer is 4<turn|>\n"),
1151            "{out}"
1152        );
1153    }
1154
1155    // ---- builtins (no template in the checkpoint) -------------------
1156
1157    #[test]
1158    fn a_checkpoint_with_no_template_gets_chatml_or_plain() {
1159        assert!(matches!(
1160            &*ChatTemplate::from_gguf_metadata(None, Some("olmoe"), false).0,
1161            Kind::Builtin(BuiltinTemplate::ChatMl)
1162        ));
1163        assert!(matches!(
1164            &*ChatTemplate::from_gguf_metadata(Some("   "), Some("olmoe"), false).0,
1165            Kind::Builtin(BuiltinTemplate::ChatMl)
1166        ));
1167        assert!(matches!(
1168            &*ChatTemplate::from_gguf_metadata(None, Some("olmoe"), true).0,
1169            Kind::Builtin(BuiltinTemplate::Plain)
1170        ));
1171        assert!(matches!(
1172            &*ChatTemplate::from_gguf_metadata(None, None, false).0,
1173            Kind::Builtin(BuiltinTemplate::Plain)
1174        ));
1175    }
1176
1177    #[test]
1178    fn builtin_chatml_and_plain_render_as_before() {
1179        let msgs = [msg("system", "be helpful"), msg("user", "hi")];
1180        assert_eq!(
1181            ChatTemplate::builtin(BuiltinTemplate::ChatMl)
1182                .render(&msgs, &opts())
1183                .unwrap(),
1184            "<|im_start|>system\nbe helpful<|im_end|>\n<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\n"
1185        );
1186        assert_eq!(
1187            ChatTemplate::builtin(BuiltinTemplate::Plain)
1188                .render(&msgs, &opts())
1189                .unwrap(),
1190            "system: be helpful\nuser: hi"
1191        );
1192    }
1193
1194    #[test]
1195    fn builtin_renders_replayed_tool_calls_as_marker_text() {
1196        let msgs = [json!({"role": "assistant", "tool_calls": [
1197            {"function": {"name": "f", "arguments": "{\"a\": 1}"}}
1198        ]})];
1199        assert_eq!(
1200            ChatTemplate::builtin(BuiltinTemplate::Plain)
1201                .render(&msgs, &opts())
1202                .unwrap(),
1203            "assistant: <tool_call>{\"name\": \"f\", \"arguments\": {\"a\": 1}}</tool_call>"
1204        );
1205    }
1206
1207    #[test]
1208    fn handles_tools_is_a_property_of_the_template_not_a_guess() {
1209        assert!(ChatTemplate::from_jinja(QWEN25_TEMPLATE)
1210            .unwrap()
1211            .handles_tools());
1212        assert!(!ChatTemplate::from_jinja(GEMMA3_TEMPLATE)
1213            .unwrap()
1214            .handles_tools());
1215        assert!(!ChatTemplate::builtin(BuiltinTemplate::ChatMl).handles_tools());
1216    }
1217
1218    #[test]
1219    fn utc_calendar_math_matches_known_dates() {
1220        assert_eq!(
1221            format_utc(0, "%F %T %A %j").unwrap(),
1222            "1970-01-01 00:00:00 Thursday 001"
1223        );
1224        assert_eq!(
1225            format_utc(951_782_400, "%F %A %j").unwrap(),
1226            "2000-02-29 Tuesday 060"
1227        );
1228        assert_eq!(
1229            format_utc(1_709_164_800, "%F %A %j").unwrap(),
1230            "2024-02-29 Thursday 060"
1231        );
1232        assert_eq!(
1233            format_utc(1_767_225_599, "%F %T %j").unwrap(),
1234            "2025-12-31 23:59:59 365"
1235        );
1236        assert_eq!(
1237            format_utc(-86_400, "%F %A").unwrap(),
1238            "1969-12-31 Wednesday"
1239        );
1240    }
1241
1242    // ---- real template strings, verbatim from local GGUFs -----------
1243
1244    /// `tokenizer.chat_template` of `models/gemma-3-1b-it-Q8_0.gguf`,
1245    /// read out of the file's metadata, not paraphrased.
1246    const GEMMA3_TEMPLATE: &str = include_str!("../tests/templates/gemma-3-1b-it.jinja");
1247    /// `tokenizer.chat_template` of `models/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf`.
1248    const QWEN25_TEMPLATE: &str = include_str!("../tests/templates/qwen2.5-instruct.jinja");
1249    /// `tokenizer.chat_template` of `models/gemma-4-E2B-it-Q4_K_M.gguf`,
1250    /// all 18 KB of it: six macros, six namespaces, recursive
1251    /// `format_parameters`, `{% set … %}{% endset %}` block capture,
1252    /// string slicing and `.split()`. This is the template the plan
1253    /// records as "checked, and `ChatTemplate::Gemma4` does not
1254    /// implement it".
1255    const GEMMA4_TEMPLATE_CORE: &str = include_str!("../tests/templates/gemma-4-E2B-it.jinja");
1256    /// `tokenizer.chat_template` of `models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf`.
1257    const LLAMA31_TEMPLATE: &str = include_str!("../tests/templates/llama-3.1-8b-instruct.jinja");
1258}