Skip to main content

hf_chat_template/
template.rs

1//! The public [`ChatTemplate`] type and its builder.
2
3use std::sync::Arc;
4
5use minijinja::{Environment, UndefinedBehavior, Value};
6use serde_json::{Map, Value as Json};
7
8use crate::clock::{Clock, SystemClock};
9use crate::config::{self, ChatTemplateField, TokenizerConfig};
10use crate::engine::{self, EngineConfig};
11use crate::error::Error;
12use crate::model::{Message, RenderInput};
13
14/// A compiled Hugging Face chat template, ready to render.
15///
16/// Construction compiles the Jinja source and installs the `transformers`-compatible
17/// globals/filters. [`render`](ChatTemplate::render) borrows `&self`, so a single instance is
18/// cheap to reuse and shareable across threads.
19pub struct ChatTemplate {
20    env: Environment<'static>,
21    /// Special tokens captured from a [`TokenizerConfig`] (empty for raw-string construction),
22    /// injected into the render context as `{{ bos_token }}` etc. unless the input overrides them.
23    special_tokens: Vec<(String, String)>,
24}
25
26impl std::fmt::Debug for ChatTemplate {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        // The compiled environment is large and not user-meaningful; keep this opaque.
29        f.debug_struct("ChatTemplate")
30            .field("special_tokens", &self.special_tokens)
31            .finish_non_exhaustive()
32    }
33}
34
35impl ChatTemplate {
36    /// Compile from a raw Jinja chat-template string, using the default
37    /// `transformers`-compatible settings (see [`ChatTemplateBuilder`] to customize).
38    ///
39    /// An inherent `from_str` (not just the [`FromStr`](std::str::FromStr) impl) so callers
40    /// can write `ChatTemplate::from_str(s)` without importing the trait.
41    #[allow(clippy::should_implement_trait)]
42    pub fn from_str(source: &str) -> Result<Self, Error> {
43        ChatTemplate::builder(source).build()
44    }
45
46    /// Compile from a parsed `tokenizer_config.json`, resolving the default template and
47    /// injecting the config's special tokens (`bos_token`, …) into the render context.
48    ///
49    /// For named-template selection (`tool_use`, `rag`) or custom options, use
50    /// [`builder_from_config`](ChatTemplate::builder_from_config).
51    pub fn from_tokenizer_config(config: &TokenizerConfig) -> Result<Self, Error> {
52        ChatTemplate::builder_from_config(config)?.build()
53    }
54
55    /// Compile from a standalone template string (the contents of a `chat_template.jinja` file),
56    /// injecting special tokens from a separately-loaded [`TokenizerConfig`].
57    ///
58    /// Newer `transformers` ship the chat template as its own `chat_template.jinja` file rather
59    /// than inside `tokenizer_config.json` (Gemma 3+, SmolLM3, …). The template body comes from
60    /// the file; the special tokens (`bos_token`, …) still come from the config. Matching the
61    /// `transformers` precedence, a standalone file overrides any inline `chat_template` field, so
62    /// this ignores `config.chat_template` entirely.
63    pub fn from_template_and_config(
64        template: &str,
65        config: &TokenizerConfig,
66    ) -> Result<Self, Error> {
67        ChatTemplate::builder(template)
68            .special_tokens_from(config)
69            .build()
70    }
71
72    /// Start a builder to compile `source` with non-default options (clock, undefined policy…).
73    pub fn builder(source: &str) -> ChatTemplateBuilder {
74        ChatTemplateBuilder::new(BuilderSource::Raw(source.to_owned()), Vec::new())
75    }
76
77    /// Start a builder from a `tokenizer_config.json`, carrying its special tokens. Use
78    /// [`template_name`](ChatTemplateBuilder::template_name) to pick a named sub-template.
79    ///
80    /// Fails with [`Error::Config`] if the config has no `chat_template` field at all.
81    pub fn builder_from_config(config: &TokenizerConfig) -> Result<ChatTemplateBuilder, Error> {
82        let field = config
83            .chat_template
84            .clone()
85            .ok_or_else(|| Error::Config("tokenizer config has no chat_template".into()))?;
86        Ok(ChatTemplateBuilder::new(
87            BuilderSource::Field(field),
88            config.special_tokens(),
89        ))
90    }
91
92    /// Fetch a Hub repo (default branch) and compile its default chat template, injecting the
93    /// config's special tokens. Requires the `hub` feature.
94    ///
95    /// Loads `tokenizer_config.json`, plus a standalone `chat_template.jinja` if the repo ships
96    /// one (Gemma 3+, SmolLM3, …); the standalone file takes precedence over an inline
97    /// `chat_template` field, matching `transformers`.
98    ///
99    /// Authentication uses `hf-hub`'s discovery (the `HF_TOKEN` env var or the cached
100    /// `huggingface-cli login` token); gated repos need a token with access. For a pinned
101    /// commit or branch, use [`from_hub_revision`](ChatTemplate::from_hub_revision).
102    ///
103    /// ```no_run
104    /// use hf_chat_template::{ChatTemplate, Message};
105    /// let tmpl = ChatTemplate::from_hub("Qwen/Qwen2.5-0.5B-Instruct")?;
106    /// let prompt = tmpl.render_messages(&[Message::user("Hi")], true)?;
107    /// # Ok::<(), hf_chat_template::Error>(())
108    /// ```
109    #[cfg(feature = "hub")]
110    pub fn from_hub(repo_id: &str) -> Result<Self, Error> {
111        Self::from_hub_inner(repo_id, None)
112    }
113
114    /// Like [`from_hub`](ChatTemplate::from_hub), but pins a specific `revision` (a branch,
115    /// tag, or commit SHA). Requires the `hub` feature.
116    #[cfg(feature = "hub")]
117    pub fn from_hub_revision(repo_id: &str, revision: &str) -> Result<Self, Error> {
118        Self::from_hub_inner(repo_id, Some(revision))
119    }
120
121    /// Shared `from_hub` body: fetch `tokenizer_config.json` plus a standalone
122    /// `chat_template.jinja` if the repo ships one, then compile with the `transformers`
123    /// precedence — a standalone file overrides any inline `chat_template` field.
124    #[cfg(feature = "hub")]
125    fn from_hub_inner(repo_id: &str, revision: Option<&str>) -> Result<Self, Error> {
126        let (config, standalone) = crate::hub::fetch_template_material(repo_id, revision)?;
127        match standalone {
128            Some(jinja) => ChatTemplate::from_template_and_config(&jinja, &config),
129            None => ChatTemplate::from_tokenizer_config(&config),
130        }
131    }
132
133    /// Render the typed input model to the final prompt string. Special tokens from the
134    /// source config are injected first; any matching key in [`RenderInput::extra`] wins.
135    pub fn render(&self, input: &RenderInput) -> Result<String, Error> {
136        let ctx = self.build_context(input)?;
137        self.render_value(ctx)
138    }
139
140    /// Convenience: render with only `messages` and the generation-prompt flag.
141    pub fn render_messages(
142        &self,
143        messages: &[Message],
144        add_generation_prompt: bool,
145    ) -> Result<String, Error> {
146        let input = RenderInput {
147            messages: messages.to_vec(),
148            add_generation_prompt,
149            ..Default::default()
150        };
151        self.render(&input)
152    }
153
154    /// Render with an arbitrary minijinja context value — the low-level escape hatch for
155    /// callers who need to pass template variables we don't model with typed structs.
156    ///
157    /// Unlike [`render`](ChatTemplate::render), this does **not** inject the config's special
158    /// tokens; the caller's context is used as-is.
159    pub fn render_value(&self, ctx: Value) -> Result<String, Error> {
160        let tmpl = self
161            .env
162            .get_template("chat")
163            .expect("the 'chat' template is always present after construction");
164        tmpl.render(ctx).map_err(Error::from_render)
165    }
166
167    /// Build the Jinja context: special tokens, then the serialized input on top (input wins).
168    /// Routed through `serde_json` to a single `minijinja::Value`; `preserve_order` on both
169    /// crates keeps map key order intact end-to-end (load-bearing for `| tojson`).
170    fn build_context(&self, input: &RenderInput) -> Result<Value, Error> {
171        let mut ctx: Map<String, Json> = Map::new();
172        for (k, v) in &self.special_tokens {
173            ctx.insert(k.clone(), Json::String(v.clone()));
174        }
175        let serialized = serde_json::to_value(input)
176            .map_err(|e| Error::Config(format!("failed to serialize render input: {e}")))?;
177        if let Json::Object(map) = serialized {
178            for (k, v) in map {
179                ctx.insert(k, v);
180            }
181        }
182        Ok(Value::from_serialize(Json::Object(ctx)))
183    }
184}
185
186/// Where a builder draws its template source from.
187enum BuilderSource {
188    /// A raw Jinja string.
189    Raw(String),
190    /// A `chat_template` field whose concrete source is resolved at `build()`.
191    Field(ChatTemplateField),
192}
193
194/// Builder for [`ChatTemplate`], exposing the knobs that affect rendering semantics.
195///
196/// Defaults mirror the `transformers` reference environment:
197/// `trim_blocks = true`, `lstrip_blocks = true`, `keep_trailing_newline = true`,
198/// lenient undefined behavior, pycompat enabled, [`SystemClock`].
199pub struct ChatTemplateBuilder {
200    source: BuilderSource,
201    special_tokens: Vec<(String, String)>,
202    template_name: Option<String>,
203    cfg: EngineConfig,
204}
205
206impl ChatTemplateBuilder {
207    fn new(source: BuilderSource, special_tokens: Vec<(String, String)>) -> Self {
208        ChatTemplateBuilder {
209            source,
210            special_tokens,
211            template_name: None,
212            cfg: EngineConfig {
213                // VERIFY against transformers source; pinned here as the documented baseline.
214                trim_blocks: true,
215                lstrip_blocks: true,
216                keep_trailing_newline: true,
217                undefined: UndefinedBehavior::Lenient,
218                pycompat: true,
219                clock: Arc::new(SystemClock),
220            },
221        }
222    }
223
224    /// Inject special tokens (`bos_token`, …) from a parsed `tokenizer_config.json` into the
225    /// render context, replacing any already set. Use with [`ChatTemplate::builder`] when the
226    /// template source is a standalone `chat_template.jinja` but the tokens still live in the
227    /// config (see [`ChatTemplate::from_template_and_config`]).
228    pub fn special_tokens_from(mut self, config: &TokenizerConfig) -> Self {
229        self.special_tokens = config.special_tokens();
230        self
231    }
232
233    /// Select a named sub-template (e.g. `"tool_use"`, `"rag"`) when the config carries a
234    /// list of them. Ignored for a single-string source.
235    pub fn template_name(mut self, name: impl Into<String>) -> Self {
236        self.template_name = Some(name.into());
237        self
238    }
239
240    /// Inject a deterministic [`Clock`] for `strftime_now` (essential for golden tests).
241    pub fn clock(mut self, clock: impl Clock + 'static) -> Self {
242        self.cfg.clock = Arc::new(clock);
243        self
244    }
245
246    /// Enable/disable the pycompat Python-method shim. Default: enabled.
247    pub fn pycompat(mut self, enabled: bool) -> Self {
248        self.cfg.pycompat = enabled;
249        self
250    }
251
252    /// Override how undefined variables are treated. Default: [`UndefinedBehavior::Lenient`].
253    pub fn undefined_behavior(mut self, ub: UndefinedBehavior) -> Self {
254        self.cfg.undefined = ub;
255        self
256    }
257
258    /// Compile the template. Returns [`Error::Compile`] on a Jinja syntax error, or
259    /// [`Error::Config`] if a requested/needed named template can't be resolved.
260    pub fn build(self) -> Result<ChatTemplate, Error> {
261        let source: String = match &self.source {
262            BuilderSource::Raw(s) => s.clone(),
263            BuilderSource::Field(field) => {
264                config::resolve_template(field, self.template_name.as_deref())?.to_owned()
265            }
266        };
267        let env = engine::build(source, &self.cfg).map_err(Error::Compile)?;
268        Ok(ChatTemplate {
269            env,
270            special_tokens: self.special_tokens,
271        })
272    }
273}
274
275impl std::str::FromStr for ChatTemplate {
276    type Err = Error;
277    fn from_str(s: &str) -> Result<Self, Error> {
278        ChatTemplate::from_str(s)
279    }
280}