Skip to main content

frink_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 (`frink-server`'s `chat_template.rs`, and
7//! a near-identical copy in `frink-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 `frink-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    /// `frink-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 frink_gguf::TensorSource) -> bool {
226        let Some(frink_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 (`frink-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`). Frink
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                        "frink 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        // str.startswith(prefix) / str.endswith(suffix), with Python's
553        // "a string or a tuple of strings" argument. Qwen3.5's template
554        // (`chat:72`) tests a user turn for `<tool_response>` wrapping
555        // with both; without them the render failed on every request,
556        // which is a model that cannot chat, not a template detail.
557        "startswith" | "endswith" => {
558            let s = value.as_str().ok_or_else(unknown)?;
559            let [needle] = args else {
560                return Err(minijinja::Error::new(
561                    minijinja::ErrorKind::InvalidOperation,
562                    format!("{method}() takes exactly one argument"),
563                ));
564            };
565            let test = |n: &str| {
566                if method == "startswith" {
567                    s.starts_with(n)
568                } else {
569                    s.ends_with(n)
570                }
571            };
572            let hit = if let Some(n) = needle.as_str() {
573                test(n)
574            } else if let Ok(iter) = needle.try_iter() {
575                let mut any = false;
576                for n in iter {
577                    any |= test(as_str(&n)?);
578                }
579                any
580            } else {
581                return Err(minijinja::Error::new(
582                    minijinja::ErrorKind::InvalidOperation,
583                    format!("{method}() takes a string or a sequence of strings"),
584                ));
585            };
586            Ok(JinjaValue::from(hit))
587        }
588        _ => Err(unknown()),
589    }
590}
591
592/// `{{ raise_exception("...") }}` — the standard HuggingFace escape
593/// hatch for "this conversation is not representable in this template"
594/// (mistral and gemma-3 both use it to reject non-alternating roles).
595/// Aborts the render; the message reaches the client.
596fn raise_exception(msg: String) -> Result<JinjaValue, minijinja::Error> {
597    Err(minijinja::Error::new(
598        minijinja::ErrorKind::InvalidOperation,
599        format!("template raised: {msg}"),
600    ))
601}
602
603/// `{{ strftime_now("%d %b %Y") }}` — Llama-3.1's template stamps
604/// today's date into its system preamble with it.
605///
606/// UTC, and a deliberately small `strftime` subset: `%Y %y %m %d %e %H
607/// %M %S %j %B %b %A %a %F %T %%`, plus the `-` no-pad flag
608/// (`%-d`, `%-m`). Anything else is an error rather than a silently
609/// wrong date — a model told the wrong year is a real quality bug and it
610/// would never show up as a crash.
611///
612/// `FRINK_TEST_CHAT_TEMPLATE_NOW` (Unix seconds) pins the clock, which is
613/// how the regression tests below assert an exact string.
614fn strftime_now(fmt: String) -> Result<String, minijinja::Error> {
615    let secs = match std::env::var("FRINK_TEST_CHAT_TEMPLATE_NOW") {
616        Ok(v) => v.trim().parse::<i64>().map_err(|_| {
617            minijinja::Error::new(
618                minijinja::ErrorKind::InvalidOperation,
619                "FRINK_TEST_CHAT_TEMPLATE_NOW must be Unix seconds",
620            )
621        })?,
622        Err(_) => std::time::SystemTime::now()
623            .duration_since(std::time::UNIX_EPOCH)
624            .map(|d| d.as_secs() as i64)
625            .unwrap_or(0),
626    };
627    format_utc(secs, &fmt).map_err(|spec| {
628        minijinja::Error::new(
629            minijinja::ErrorKind::InvalidOperation,
630            format!(
631                "strftime_now: unsupported format specifier `%{spec}` in {fmt:?} -- \
632                 frink implements a subset (%Y %y %m %d %e %H %M %S %j %B %b %A %a %F %T %%) \
633                 and refuses rather than stamping a wrong date into the prompt"
634            ),
635        )
636    })
637}
638
639const MONTHS: [&str; 12] = [
640    "January",
641    "February",
642    "March",
643    "April",
644    "May",
645    "June",
646    "July",
647    "August",
648    "September",
649    "October",
650    "November",
651    "December",
652];
653const WEEKDAYS: [&str; 7] = [
654    "Thursday",
655    "Friday",
656    "Saturday",
657    "Sunday",
658    "Monday",
659    "Tuesday",
660    "Wednesday",
661];
662
663/// Civil date from a Unix timestamp (Howard Hinnant's `civil_from_days`),
664/// then a `strftime` subset. Returns `Err(spec)` naming the first
665/// unsupported specifier.
666fn format_utc(secs: i64, fmt: &str) -> Result<String, char> {
667    let days = secs.div_euclid(86_400);
668    let tod = secs.rem_euclid(86_400);
669    let (hour, minute, second) = (tod / 3600, (tod % 3600) / 60, tod % 60);
670    // 1970-01-01 was a Thursday, hence WEEKDAYS's rotation.
671    let weekday = days.rem_euclid(7) as usize;
672
673    let z = days + 719_468;
674    let era = z.div_euclid(146_097);
675    let doe = z.rem_euclid(146_097);
676    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
677    let y = yoe + era * 400;
678    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
679    let mp = (5 * doy + 2) / 153;
680    let day = doy - (153 * mp + 2) / 5 + 1;
681    let month = if mp < 10 { mp + 3 } else { mp - 9 };
682    let year = if month <= 2 { y + 1 } else { y };
683
684    // Day-of-year needs the calendar year's own Jan 1.
685    let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
686    const CUM: [i64; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
687    let yday = CUM[(month - 1) as usize] + day + i64::from(leap && month > 2);
688
689    let mut out = String::with_capacity(fmt.len() + 8);
690    let mut chars = fmt.chars().peekable();
691    while let Some(c) = chars.next() {
692        if c != '%' {
693            out.push(c);
694            continue;
695        }
696        let mut pad = true;
697        let mut spec = chars.next().ok_or('%')?;
698        if spec == '-' {
699            pad = false;
700            spec = chars.next().ok_or('-')?;
701        }
702        let num = |out: &mut String, v: i64, w: usize| {
703            if pad {
704                out.push_str(&format!("{v:0w$}"));
705            } else {
706                out.push_str(&v.to_string());
707            }
708        };
709        match spec {
710            'Y' => out.push_str(&year.to_string()),
711            'y' => num(&mut out, year.rem_euclid(100), 2),
712            'm' => num(&mut out, month, 2),
713            'd' => num(&mut out, day, 2),
714            // %e is space-padded day-of-month.
715            'e' => out.push_str(&format!("{day:2}")),
716            'H' => num(&mut out, hour, 2),
717            'M' => num(&mut out, minute, 2),
718            'S' => num(&mut out, second, 2),
719            'j' => num(&mut out, yday, 3),
720            'B' => out.push_str(MONTHS[(month - 1) as usize]),
721            'b' => out.push_str(&MONTHS[(month - 1) as usize][..3]),
722            'A' => out.push_str(WEEKDAYS[weekday]),
723            'a' => out.push_str(&WEEKDAYS[weekday][..3]),
724            'F' => out.push_str(&format!("{year:04}-{month:02}-{day:02}")),
725            'T' => out.push_str(&format!("{hour:02}:{minute:02}:{second:02}")),
726            '%' => out.push('%'),
727            other => return Err(other),
728        }
729    }
730    Ok(out)
731}
732
733/// Text a message contributes to a builtin render: `content` as a
734/// string (or the concatenated `text` parts of an OpenAI content array),
735/// plus any `tool_calls` re-rendered as the `<tool_call>{…}</tool_call>`
736/// marker text a model is asked to emit for a *new* call.
737fn builtin_message_text(m: &Value) -> String {
738    let mut out = match m.get("content") {
739        Some(Value::String(s)) => s.clone(),
740        Some(Value::Array(parts)) => parts
741            .iter()
742            .filter_map(|p| p.get("text").and_then(Value::as_str))
743            .collect::<Vec<_>>()
744            .join(""),
745        _ => String::new(),
746    };
747    if let Some(Value::Array(calls)) = m.get("tool_calls") {
748        for call in calls {
749            let f = call.get("function");
750            let name = f
751                .and_then(|f| f.get("name"))
752                .and_then(Value::as_str)
753                .unwrap_or("");
754            let args = f
755                .and_then(|f| f.get("arguments"))
756                .map(|a| match a {
757                    Value::String(s) => s.clone(),
758                    other => other.to_string(),
759                })
760                .unwrap_or_else(|| "{}".to_string());
761            out.push_str(&format!(
762                "<tool_call>{{\"name\": \"{name}\", \"arguments\": {args}}}</tool_call>"
763            ));
764        }
765    }
766    out
767}
768
769fn builtin_role(m: &Value) -> &str {
770    m.get("role").and_then(Value::as_str).unwrap_or("user")
771}
772
773fn render_builtin(b: BuiltinTemplate, messages: &[Value], opts: &RenderOptions) -> String {
774    let mut out = String::new();
775    match b {
776        BuiltinTemplate::ChatMl => {
777            for m in messages {
778                out.push_str("<|im_start|>");
779                out.push_str(builtin_role(m));
780                out.push('\n');
781                out.push_str(&builtin_message_text(m));
782                out.push_str("<|im_end|>\n");
783            }
784            if opts.add_generation_prompt {
785                out.push_str("<|im_start|>assistant\n");
786            }
787        }
788        BuiltinTemplate::Plain => {
789            let lines: Vec<String> = messages
790                .iter()
791                .map(|m| format!("{}: {}", builtin_role(m), builtin_message_text(m)))
792                .collect();
793            out.push_str(&lines.join("\n"));
794        }
795    }
796    out
797}
798
799#[cfg(test)]
800mod tests {
801
802    /// A checkpoint with no template and no ChatML markers in its vocab
803    /// must NOT be wrapped in ChatML.
804    ///
805    /// OLMoE-1B-7B is the case: its vocabulary contains neither
806    /// `<|im_start|>` nor `<|im_end|>`. Wrapping it anyway made the
807    /// markers tokenize as literal text the model never saw, left
808    /// `<|im_end|>` impossible to generate so nothing could stop the
809    /// run, and led the model to imitate the pattern it was shown. The
810    /// observed output was 512 tokens of invented `<|area_key|>`-style
811    /// markers, while the raw completion path answered correctly.
812    #[test]
813    fn a_vocab_without_chatml_markers_falls_back_to_plain() {
814        let without = ChatTemplate::from_gguf_metadata(None, Some("olmoe"), false, false);
815        let with = ChatTemplate::from_gguf_metadata(None, Some("olmoe"), false, true);
816        assert_ne!(
817            without.describe(),
818            with.describe(),
819            "the ChatML fallback must depend on the markers actually existing"
820        );
821        assert!(
822            !without.describe().to_lowercase().contains("chatml"),
823            "got {} for a vocab with no ChatML tokens",
824            without.describe()
825        );
826    }
827    use super::*;
828    use serde_json::json;
829
830    fn msg(role: &str, content: &str) -> Value {
831        json!({"role": role, "content": content})
832    }
833
834    fn opts() -> RenderOptions {
835        RenderOptions {
836            add_generation_prompt: true,
837            bos_token: Some("<s>".into()),
838            eos_token: Some("</s>".into()),
839            ..Default::default()
840        }
841    }
842
843    // ---- the four constructs the plan named ------------------------
844
845    /// `{{ bos_token }}`: the template, not the loader, decides where BOS
846    /// goes. Mistral-7B-Instruct-v0.2's real GGUF template, verbatim.
847    #[test]
848    fn renders_bos_token_and_the_real_mistral_inst_framing() {
849        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 %}";
850        let t = ChatTemplate::from_jinja(src).unwrap();
851        let out = t
852            .render(
853                &[
854                    msg("user", "hi"),
855                    msg("assistant", "hello"),
856                    msg("user", "2+2?"),
857                ],
858                &opts(),
859            )
860            .unwrap();
861        assert_eq!(out, "<s>[INST] hi [/INST]hello</s>[INST] 2+2? [/INST]");
862    }
863
864    /// The sniffing implementation matched *no* marker in that template
865    /// and rendered `user: hi` instead. This is the bug, pinned.
866    #[test]
867    fn mistral_is_not_plain_role_labelled_lines() {
868        let src = "{{ bos_token }}{% for message in messages %}{% if message['role'] == 'user' %}{{ '[INST] ' + message['content'] + ' [/INST]' }}{% endif %}{% endfor %}";
869        let out = ChatTemplate::from_jinja(src)
870            .unwrap()
871            .render(&[msg("user", "hi")], &opts())
872            .unwrap();
873        assert!(!out.contains("user: hi"), "{out}");
874        assert!(out.contains("[INST]"), "{out}");
875    }
876
877    /// A system message: gemma-3's real GGUF template folds it into the
878    /// first user turn, which the hand-written `Gemma` renderer only
879    /// approximated (it always joined with `\n\n`, and never emitted
880    /// `<bos>` or the multimodal `<start_of_image>` arm).
881    #[test]
882    fn renders_a_system_message_with_the_real_gemma3_template() {
883        let src = GEMMA3_TEMPLATE;
884        let t = ChatTemplate::from_jinja(src).unwrap();
885        let out = t
886            .render(
887                &[msg("system", "be brief"), msg("user", "hi")],
888                &RenderOptions {
889                    add_generation_prompt: true,
890                    bos_token: Some("<bos>".into()),
891                    ..Default::default()
892                },
893            )
894            .unwrap();
895        assert_eq!(
896            out,
897            "<bos><start_of_turn>user\nbe brief\n\nhi<end_of_turn>\n<start_of_turn>model\n"
898        );
899    }
900
901    /// Multimodal content parts reach `<start_of_image>` — which the
902    /// hand-written renderer dropped on the floor.
903    #[test]
904    fn gemma3_emits_the_image_placeholder_for_content_parts() {
905        let out = ChatTemplate::from_jinja(GEMMA3_TEMPLATE)
906            .unwrap()
907            .render(
908                &[json!({"role": "user", "content": [
909                    {"type": "image"},
910                    {"type": "text", "text": "what is this?"}
911                ]})],
912                &RenderOptions {
913                    add_generation_prompt: true,
914                    bos_token: Some("<bos>".into()),
915                    ..Default::default()
916                },
917            )
918            .unwrap();
919        assert_eq!(
920            out,
921            "<bos><start_of_turn>user\n<start_of_image>what is this?<end_of_turn>\n<start_of_turn>model\n"
922        );
923    }
924
925    /// A tool-call block: Qwen2.5's real GGUF template, the `tools`
926    /// preamble plus a replayed assistant `tool_calls` turn plus a
927    /// `role: tool` result. None of this was reachable before — no
928    /// hand-written renderer read `tools` at all.
929    #[test]
930    fn renders_a_tool_call_block_with_the_real_qwen25_template() {
931        let t = ChatTemplate::from_jinja(QWEN25_TEMPLATE).unwrap();
932        assert!(t.handles_tools());
933        let out = t
934            .render(
935                &[
936                    msg("user", "weather in Paris?"),
937                    json!({"role": "assistant", "content": "", "tool_calls": [
938                        {"type": "function", "function": {"name": "get_weather", "arguments": {"city": "Paris"}}}
939                    ]}),
940                    json!({"role": "tool", "content": "18C"}),
941                ],
942                &RenderOptions {
943                    add_generation_prompt: true,
944                    tools: vec![json!({"type": "function", "function": {
945                        "name": "get_weather",
946                        "description": "Current weather",
947                        "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
948                    }})],
949                    ..Default::default()
950                },
951            )
952            .unwrap();
953        assert_eq!(
954            out,
955            concat!(
956                "<|im_start|>system\n",
957                "You are Qwen, created by Alibaba Cloud. You are a helpful assistant.\n\n",
958                "# Tools\n\n",
959                "You may call one or more functions to assist with the user query.\n\n",
960                "You are provided with function signatures within <tools></tools> XML tags:\n",
961                "<tools>\n",
962                // `tojson`: sorted keys and `", "` / `": "` separators,
963                // exactly as jinja2's `json.dumps(sort_keys=True)` does.
964                "{\"function\": {\"description\": \"Current weather\", \"name\": \"get_weather\", ",
965                "\"parameters\": {\"properties\": {\"city\": {\"type\": \"string\"}}, ",
966                "\"type\": \"object\"}}, \"type\": \"function\"}\n",
967                "</tools>\n\n",
968                "For each function call, return a json object with function name and arguments ",
969                "within <tool_call></tool_call> XML tags:\n",
970                "<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n",
971                "</tool_call><|im_end|>\n",
972                "<|im_start|>user\nweather in Paris?<|im_end|>\n",
973                "<|im_start|>assistant\n",
974                "<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Paris\"}}\n",
975                "</tool_call><|im_end|>\n",
976                "<|im_start|>user\n<tool_response>\n18C\n</tool_response><|im_end|>\n",
977                "<|im_start|>assistant\n",
978            )
979        );
980    }
981
982    /// `add_generation_prompt` is honoured both ways. The hand-written
983    /// renderers appended the assistant header unconditionally, so a
984    /// caller could not ask for a prefix-only render (what a
985    /// prefill/scoring path or a "continue this reply" request needs).
986    #[test]
987    fn add_generation_prompt_is_honoured_both_ways() {
988        let t = ChatTemplate::from_jinja(GEMMA3_TEMPLATE).unwrap();
989        let with = t
990            .render(
991                &[msg("user", "hi")],
992                &RenderOptions {
993                    add_generation_prompt: true,
994                    ..Default::default()
995                },
996            )
997            .unwrap();
998        let without = t
999            .render(
1000                &[msg("user", "hi")],
1001                &RenderOptions {
1002                    add_generation_prompt: false,
1003                    ..Default::default()
1004                },
1005            )
1006            .unwrap();
1007        assert_eq!(
1008            with,
1009            "<start_of_turn>user\nhi<end_of_turn>\n<start_of_turn>model\n"
1010        );
1011        assert_eq!(without, "<start_of_turn>user\nhi<end_of_turn>\n");
1012    }
1013
1014    /// `add_generation_prompt` + `{{ bos_token }}` + a system message on
1015    /// the real Llama-3.1-8B-Instruct template, which is also the
1016    /// regression for a bug this rewrite introduced and then fixed:
1017    /// leaving `tools` *undefined* rather than binding it to `null` made
1018    /// `{%- if tools is not none %}` true, so every plain chat request
1019    /// got Llama's ipython tool-calling preamble.
1020    #[test]
1021    fn llama31_binds_tools_to_null_so_a_plain_chat_gets_no_tool_preamble() {
1022        let out = ChatTemplate::from_jinja(LLAMA31_TEMPLATE)
1023            .unwrap()
1024            .render(
1025                &[msg("system", "be brief"), msg("user", "hi")],
1026                &RenderOptions {
1027                    add_generation_prompt: true,
1028                    bos_token: Some("<|begin_of_text|>".into()),
1029                    ..Default::default()
1030                },
1031            )
1032            .unwrap();
1033        assert_eq!(
1034            out,
1035            concat!(
1036                "<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n",
1037                "Cutting Knowledge Date: December 2023\n",
1038                "Today Date: 26 Jul 2024\n\n",
1039                "be brief<|eot_id|>",
1040                "<|start_header_id|>user<|end_header_id|>\n\nhi<|eot_id|>",
1041                "<|start_header_id|>assistant<|end_header_id|>\n\n",
1042            )
1043        );
1044        assert!(!out.contains("Environment: ipython"), "{out}");
1045    }
1046
1047    // ---- sharp edges the plan named --------------------------------
1048
1049    #[test]
1050    fn raise_exception_fails_the_render_and_keeps_the_message() {
1051        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 %}";
1052        let err = ChatTemplate::from_jinja(src)
1053            .unwrap()
1054            .render(&[msg("assistant", "oops")], &opts())
1055            .unwrap_err();
1056        let text = err.to_string();
1057        assert!(matches!(err, TemplateError::Render(_)), "{text}");
1058        assert!(text.contains("roles must alternate"), "{text}");
1059    }
1060
1061    #[test]
1062    fn strftime_now_stamps_a_pinned_clock() {
1063        // 2024-07-04T12:34:56Z
1064        std::env::set_var("FRINK_TEST_CHAT_TEMPLATE_NOW", "1720096496");
1065        let out = ChatTemplate::from_jinja(
1066            "{{ strftime_now(\"%d %b %Y\") }}|{{ strftime_now('%A %F %T %j %-d') }}",
1067        )
1068        .unwrap()
1069        .render(&[], &opts())
1070        .unwrap();
1071        std::env::remove_var("FRINK_TEST_CHAT_TEMPLATE_NOW");
1072        assert_eq!(out, "04 Jul 2024|Thursday 2024-07-04 12:34:56 186 4");
1073    }
1074
1075    #[test]
1076    fn strftime_now_refuses_an_unimplemented_specifier() {
1077        let err = ChatTemplate::from_jinja("{{ strftime_now('%Z') }}")
1078            .unwrap()
1079            .render(&[], &opts())
1080            .unwrap_err();
1081        assert!(
1082            err.to_string().contains("unsupported format specifier"),
1083            "{err}"
1084        );
1085    }
1086
1087    /// Whitespace control (`{%- … -%}`) is what makes gemma-3 render as
1088    /// one unbroken line despite being written across 40 indented lines.
1089    /// If it were ignored the prompt would be full of stray newlines.
1090    #[test]
1091    fn whitespace_control_is_respected() {
1092        let out = ChatTemplate::from_jinja(
1093            "{%- for m in messages -%}\n    {{- m['role'] -}}\n{%- endfor -%}",
1094        )
1095        .unwrap()
1096        .render(&[msg("user", "x"), msg("assistant", "y")], &opts())
1097        .unwrap();
1098        assert_eq!(out, "userassistant");
1099    }
1100
1101    /// `namespace()` is the standard workaround for Jinja loop scoping —
1102    /// a plain `{% set %}` inside a `{% for %}` does not escape it.
1103    /// gemma-4's real template uses six namespaces.
1104    #[test]
1105    fn namespace_writes_escape_loop_scope() {
1106        let out = ChatTemplate::from_jinja(
1107            "{%- set ns = namespace(n=0) -%}{%- for m in messages -%}{%- set ns.n = ns.n + 1 -%}{%- endfor -%}{{ ns.n }}",
1108        )
1109        .unwrap()
1110        .render(&[msg("user", "a"), msg("user", "b"), msg("user", "c")], &opts())
1111        .unwrap();
1112        assert_eq!(out, "3");
1113    }
1114
1115    /// An unknown filter must be a refusal, not an empty string.
1116    #[test]
1117    fn an_unsupported_construct_fails_loudly() {
1118        let err = ChatTemplate::from_jinja("{{ messages | no_such_filter }}")
1119            .unwrap()
1120            .render(&[msg("user", "hi")], &opts())
1121            .unwrap_err();
1122        let text = err.to_string();
1123        assert!(matches!(err, TemplateError::Render(_)), "{text}");
1124        assert!(text.contains("no_such_filter"), "{text}");
1125    }
1126
1127    #[test]
1128    fn a_template_that_does_not_compile_is_recorded_not_replaced() {
1129        let t = ChatTemplate::from_gguf_metadata(
1130            Some("{% for m in messages %}{{ m }}"),
1131            Some("llama"),
1132            false,
1133            true,
1134        );
1135        assert!(!t.is_jinja());
1136        let err = t.render(&[msg("user", "hi")], &opts()).unwrap_err();
1137        assert!(matches!(err, TemplateError::Compile(_)), "{err}");
1138        // Specifically NOT a silent fallback to a hand-written renderer.
1139        assert!(t.describe().starts_with("BROKEN"), "{}", t.describe());
1140    }
1141
1142    // ---- chat_template_kwargs passthrough --------------------------
1143
1144    #[test]
1145    fn chat_template_kwargs_reach_the_template() {
1146        let src = "{%- if enable_thinking -%}THINK{%- else -%}PLAIN{%- endif -%}";
1147        let t = ChatTemplate::from_jinja(src).unwrap();
1148        let mut extra = serde_json::Map::new();
1149        extra.insert("enable_thinking".into(), Value::Bool(true));
1150        let on = t
1151            .render(
1152                &[msg("user", "hi")],
1153                &RenderOptions {
1154                    extra,
1155                    ..Default::default()
1156                },
1157            )
1158            .unwrap();
1159        let off = t
1160            .render(&[msg("user", "hi")], &RenderOptions::default())
1161            .unwrap();
1162        assert_eq!((on.as_str(), off.as_str()), ("THINK", "PLAIN"));
1163    }
1164
1165    #[test]
1166    fn chat_template_kwargs_cannot_shadow_messages_or_tools() {
1167        let mut extra = serde_json::Map::new();
1168        extra.insert(
1169            "messages".into(),
1170            json!([{"role": "user", "content": "INJECTED"}]),
1171        );
1172        extra.insert("add_generation_prompt".into(), Value::Bool(true));
1173        let out = ChatTemplate::from_jinja(
1174            "{%- for m in messages -%}{{ m['content'] }}{%- endfor -%}|{{ add_generation_prompt }}",
1175        )
1176        .unwrap()
1177        .render(
1178            &[msg("user", "real")],
1179            &RenderOptions {
1180                add_generation_prompt: false,
1181                extra,
1182                ..Default::default()
1183            },
1184        )
1185        .unwrap();
1186        assert_eq!(out, "real|false");
1187    }
1188
1189    // ---- gemma-4: the variant the plan says was never implemented ---
1190
1191    /// The hand-written `ChatTemplate::Gemma4` rendered
1192    /// `<|turn>user\n…<turn|>\n<|turn>model\n` and nothing else. The real
1193    /// template injects a `<|think|>` channel into the first system turn
1194    /// when `enable_thinking` is set — driven by `chat_template_kwargs`,
1195    /// which had no path to it at all before.
1196    #[test]
1197    fn gemma4_thinking_injection_is_reachable_now() {
1198        let t = ChatTemplate::from_jinja(GEMMA4_TEMPLATE_CORE).unwrap();
1199        let mut extra = serde_json::Map::new();
1200        extra.insert("enable_thinking".into(), Value::Bool(true));
1201        let thinking = t
1202            .render(
1203                &[msg("user", "hi")],
1204                &RenderOptions {
1205                    add_generation_prompt: true,
1206                    bos_token: Some("<bos>".into()),
1207                    extra,
1208                    ..Default::default()
1209                },
1210            )
1211            .unwrap();
1212        assert_eq!(
1213            thinking,
1214            "<bos><|turn>system\n<|think|>\n<turn|>\n<|turn>user\nhi<turn|>\n<|turn>model\n"
1215        );
1216        let plain = t
1217            .render(
1218                &[msg("user", "hi")],
1219                &RenderOptions {
1220                    add_generation_prompt: true,
1221                    bos_token: Some("<bos>".into()),
1222                    ..Default::default()
1223                },
1224            )
1225            .unwrap();
1226        assert_eq!(plain, "<bos><|turn>user\nhi<turn|>\n<|turn>model\n");
1227    }
1228
1229    /// `strip_thinking`: a replayed assistant turn must have its
1230    /// `<|channel>…<channel|>` reasoning removed before it goes back into
1231    /// the prompt. The hand-written renderer replayed it verbatim.
1232    #[test]
1233    fn gemma4_strip_thinking_removes_replayed_reasoning() {
1234        let out = ChatTemplate::from_jinja(GEMMA4_TEMPLATE_CORE)
1235            .unwrap()
1236            .render(
1237                &[
1238                    msg("user", "hi"),
1239                    msg(
1240                        "assistant",
1241                        "<|channel>thought\nlet me think<channel|>the answer is 4",
1242                    ),
1243                    msg("user", "again?"),
1244                ],
1245                &RenderOptions {
1246                    add_generation_prompt: true,
1247                    bos_token: Some("<bos>".into()),
1248                    ..Default::default()
1249                },
1250            )
1251            .unwrap();
1252        assert!(!out.contains("let me think"), "{out}");
1253        assert!(
1254            out.contains("<|turn>model\nthe answer is 4<turn|>\n"),
1255            "{out}"
1256        );
1257    }
1258
1259    // ---- builtins (no template in the checkpoint) -------------------
1260
1261    #[test]
1262    fn a_checkpoint_with_no_template_gets_chatml_or_plain() {
1263        assert!(matches!(
1264            &*ChatTemplate::from_gguf_metadata(None, Some("olmoe"), false, true).0,
1265            Kind::Builtin(BuiltinTemplate::ChatMl)
1266        ));
1267        assert!(matches!(
1268            &*ChatTemplate::from_gguf_metadata(Some("   "), Some("olmoe"), false, true).0,
1269            Kind::Builtin(BuiltinTemplate::ChatMl)
1270        ));
1271        assert!(matches!(
1272            &*ChatTemplate::from_gguf_metadata(None, Some("olmoe"), true, true).0,
1273            Kind::Builtin(BuiltinTemplate::Plain)
1274        ));
1275        assert!(matches!(
1276            &*ChatTemplate::from_gguf_metadata(None, None, false, true).0,
1277            Kind::Builtin(BuiltinTemplate::Plain)
1278        ));
1279    }
1280
1281    #[test]
1282    fn builtin_chatml_and_plain_render_as_before() {
1283        let msgs = [msg("system", "be helpful"), msg("user", "hi")];
1284        assert_eq!(
1285            ChatTemplate::builtin(BuiltinTemplate::ChatMl)
1286                .render(&msgs, &opts())
1287                .unwrap(),
1288            "<|im_start|>system\nbe helpful<|im_end|>\n<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\n"
1289        );
1290        assert_eq!(
1291            ChatTemplate::builtin(BuiltinTemplate::Plain)
1292                .render(&msgs, &opts())
1293                .unwrap(),
1294            "system: be helpful\nuser: hi"
1295        );
1296    }
1297
1298    #[test]
1299    fn builtin_renders_replayed_tool_calls_as_marker_text() {
1300        let msgs = [json!({"role": "assistant", "tool_calls": [
1301            {"function": {"name": "f", "arguments": "{\"a\": 1}"}}
1302        ]})];
1303        assert_eq!(
1304            ChatTemplate::builtin(BuiltinTemplate::Plain)
1305                .render(&msgs, &opts())
1306                .unwrap(),
1307            "assistant: <tool_call>{\"name\": \"f\", \"arguments\": {\"a\": 1}}</tool_call>"
1308        );
1309    }
1310
1311    #[test]
1312    fn handles_tools_is_a_property_of_the_template_not_a_guess() {
1313        assert!(ChatTemplate::from_jinja(QWEN25_TEMPLATE)
1314            .unwrap()
1315            .handles_tools());
1316        assert!(!ChatTemplate::from_jinja(GEMMA3_TEMPLATE)
1317            .unwrap()
1318            .handles_tools());
1319        assert!(!ChatTemplate::builtin(BuiltinTemplate::ChatMl).handles_tools());
1320    }
1321
1322    /// Qwen3.5's template (`chat:72`) tests a user turn with
1323    /// `content.startswith('<tool_response>') and content.endswith(...)`;
1324    /// before the shim had them every request failed to render.
1325    #[test]
1326    fn python_startswith_and_endswith_take_a_string_or_a_tuple() {
1327        let t = ChatTemplate::from_jinja(
1328            "{% for m in messages %}{{ m.content.startswith('<tool_response>') }}\
1329             {{ m.content.endswith(('a', '</tool_response>')) }}\
1330             {{ m.content.startswith(('x', 'y')) }};{% endfor %}",
1331        )
1332        .unwrap();
1333        let out = t
1334            .render(
1335                &[serde_json::json!({"role": "user", "content": "<tool_response>ok</tool_response>"})],
1336                &RenderOptions::default(),
1337            )
1338            .unwrap();
1339        assert_eq!(out, "truetruefalse;");
1340    }
1341
1342    #[test]
1343    fn utc_calendar_math_matches_known_dates() {
1344        assert_eq!(
1345            format_utc(0, "%F %T %A %j").unwrap(),
1346            "1970-01-01 00:00:00 Thursday 001"
1347        );
1348        assert_eq!(
1349            format_utc(951_782_400, "%F %A %j").unwrap(),
1350            "2000-02-29 Tuesday 060"
1351        );
1352        assert_eq!(
1353            format_utc(1_709_164_800, "%F %A %j").unwrap(),
1354            "2024-02-29 Thursday 060"
1355        );
1356        assert_eq!(
1357            format_utc(1_767_225_599, "%F %T %j").unwrap(),
1358            "2025-12-31 23:59:59 365"
1359        );
1360        assert_eq!(
1361            format_utc(-86_400, "%F %A").unwrap(),
1362            "1969-12-31 Wednesday"
1363        );
1364    }
1365
1366    // ---- real template strings, verbatim from local GGUFs -----------
1367
1368    /// `tokenizer.chat_template` of `models/gemma-3-1b-it-Q8_0.gguf`,
1369    /// read out of the file's metadata, not paraphrased.
1370    const GEMMA3_TEMPLATE: &str = include_str!("../tests/templates/gemma-3-1b-it.jinja");
1371    /// `tokenizer.chat_template` of `models/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf`.
1372    const QWEN25_TEMPLATE: &str = include_str!("../tests/templates/qwen2.5-instruct.jinja");
1373    /// `tokenizer.chat_template` of `models/gemma-4-E2B-it-Q4_K_M.gguf`,
1374    /// all 18 KB of it: six macros, six namespaces, recursive
1375    /// `format_parameters`, `{% set … %}{% endset %}` block capture,
1376    /// string slicing and `.split()`. This is the template the plan
1377    /// records as "checked, and `ChatTemplate::Gemma4` does not
1378    /// implement it".
1379    const GEMMA4_TEMPLATE_CORE: &str = include_str!("../tests/templates/gemma-4-E2B-it.jinja");
1380    /// `tokenizer.chat_template` of `models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf`.
1381    const LLAMA31_TEMPLATE: &str = include_str!("../tests/templates/llama-3.1-8b-instruct.jinja");
1382}