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}