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