1use anyhow::{Context, Result, anyhow};
28use minijinja::value::Object;
29use minijinja::{Environment, Error as JinjaError, ErrorKind, State, Value};
30use rlx_gguf::{GgufFile, MetaValue};
31use serde_json::Value as JsonValue;
32use std::path::Path;
33use std::sync::Arc;
34
35pub fn auto_chat_template(path: &Path) -> Result<ChatTemplate> {
41 ChatTemplate::from_gguf(path)
42}
43
44#[derive(Debug, Clone)]
47pub struct ChatMessage {
48 pub role: String,
49 pub content: String,
50}
51
52#[derive(Debug, Clone, Copy)]
55pub struct ChatRenderOptions {
56 pub add_generation_prompt: bool,
57 pub enable_thinking: bool,
60}
61
62impl Default for ChatRenderOptions {
63 fn default() -> Self {
64 Self {
65 add_generation_prompt: true,
66 enable_thinking: false,
67 }
68 }
69}
70
71impl ChatRenderOptions {
72 pub fn user_turn(add_generation_prompt: bool) -> Self {
73 Self {
74 add_generation_prompt,
75 ..Self::default()
76 }
77 }
78
79 pub fn gemma4_thinking(add_generation_prompt: bool) -> Self {
80 Self {
81 add_generation_prompt,
82 enable_thinking: true,
83 }
84 }
85}
86
87#[derive(Debug, Clone)]
91struct GettableValue(JsonValue);
92
93impl GettableValue {
94 fn from_json(v: JsonValue) -> Value {
95 match v {
96 JsonValue::Object(_) | JsonValue::Array(_) => Value::from_object(Self(v)),
97 JsonValue::String(s) => Value::from(s),
98 JsonValue::Number(n) => {
99 if let Some(i) = n.as_i64() {
100 Value::from(i)
101 } else if let Some(u) = n.as_u64() {
102 Value::from(u)
103 } else if let Some(f) = n.as_f64() {
104 Value::from(f)
105 } else {
106 Value::from(n.to_string())
107 }
108 }
109 JsonValue::Bool(b) => Value::from(b),
110 JsonValue::Null => Value::from(()),
111 }
112 }
113}
114
115impl Object for GettableValue {
116 fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
117 match &self.0 {
118 JsonValue::Object(map) => {
119 let k = key.as_str()?;
120 map.get(k).map(|v| GettableValue::from_json(v.clone()))
121 }
122 JsonValue::Array(items) => {
123 let idx = key.as_usize()?;
124 items.get(idx).map(|v| GettableValue::from_json(v.clone()))
125 }
126 _ => None,
127 }
128 }
129
130 fn call_method(
131 self: &Arc<Self>,
132 _state: &State<'_, '_>,
133 name: &str,
134 args: &[Value],
135 ) -> Result<Value, JinjaError> {
136 if name != "get" {
137 return Err(JinjaError::new(
138 ErrorKind::UnknownMethod,
139 format!("GettableValue has no method named {name}"),
140 ));
141 }
142 let key = args
143 .first()
144 .and_then(|v| v.as_str())
145 .ok_or_else(|| JinjaError::new(ErrorKind::InvalidOperation, "get() needs a key"))?;
146 let default = args.get(1).cloned().unwrap_or(Value::UNDEFINED);
147 Ok(match &self.0 {
148 JsonValue::Object(map) => map
149 .get(key)
150 .map(|v| GettableValue::from_json(v.clone()))
151 .unwrap_or(default),
152 _ => default,
153 })
154 }
155
156 fn render(self: &Arc<Self>, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 match &self.0 {
158 JsonValue::String(s) => write!(f, "{s}"),
159 JsonValue::Number(n) => write!(f, "{n}"),
160 JsonValue::Bool(b) => write!(f, "{b}"),
161 JsonValue::Null => write!(f, "null"),
162 JsonValue::Array(_) => write!(f, "[...]"),
163 JsonValue::Object(_) => write!(f, "{{...}}"),
164 }
165 }
166}
167
168fn chat_messages_to_values(messages: &[ChatMessage]) -> Vec<Value> {
169 messages
170 .iter()
171 .map(|m| {
172 GettableValue::from_json(serde_json::json!({
173 "role": m.role,
174 "content": m.content,
175 }))
176 })
177 .collect()
178}
179
180impl ChatMessage {
181 pub fn user(content: impl Into<String>) -> Self {
182 Self {
183 role: "user".into(),
184 content: content.into(),
185 }
186 }
187 pub fn system(content: impl Into<String>) -> Self {
188 Self {
189 role: "system".into(),
190 content: content.into(),
191 }
192 }
193 pub fn assistant(content: impl Into<String>) -> Self {
194 Self {
195 role: "assistant".into(),
196 content: content.into(),
197 }
198 }
199}
200
201#[derive(Debug, Clone)]
204pub enum ChatTemplateSource {
205 Inline,
206 GgufMetadata(String),
207}
208
209pub struct ChatTemplate {
211 env: Environment<'static>,
212 source_text: String,
213 source_kind: ChatTemplateSource,
214 bos_token: Option<String>,
215 eos_token: Option<String>,
216}
217
218const TEMPLATE_NAME: &str = "chat";
219
220fn build_env(source: String) -> Result<Environment<'static>> {
221 let mut env = Environment::new();
222 env.add_function(
225 "raise_exception",
226 |msg: String| -> Result<Value, JinjaError> {
227 Err(JinjaError::new(ErrorKind::InvalidOperation, msg))
228 },
229 );
230 env.add_template_owned(TEMPLATE_NAME, source)
231 .context("compiling chat template")?;
232 Ok(env)
233}
234
235impl ChatTemplate {
236 pub fn from_source(src: impl Into<String>) -> Result<Self> {
238 let source_text: String = src.into();
239 let env = build_env(source_text.clone())?;
240 Ok(Self {
241 env,
242 source_text,
243 source_kind: ChatTemplateSource::Inline,
244 bos_token: None,
245 eos_token: None,
246 })
247 }
248
249 pub fn with_tokens(mut self, bos: Option<String>, eos: Option<String>) -> Self {
252 self.bos_token = bos;
253 self.eos_token = eos;
254 self
255 }
256
257 pub fn from_gguf(path: &Path) -> Result<Self> {
260 let raw = GgufFile::from_path(path).with_context(|| format!("opening GGUF {path:?}"))?;
261 Self::from_gguf_file(&raw)
262 }
263
264 pub fn from_gguf_file(raw: &GgufFile) -> Result<Self> {
266 let (key, src) = pick_chat_template_meta(raw).ok_or_else(|| {
267 anyhow!("no tokenizer.chat_template or tokenizer.ggml.chat_template in GGUF metadata")
268 })?;
269 let env = build_env(src.clone())?;
270 let bos = resolve_special_token(raw, "tokenizer.ggml.bos_token_id");
271 let eos = resolve_special_token(raw, "tokenizer.ggml.eos_token_id");
272 Ok(Self {
273 env,
274 source_text: src,
275 source_kind: ChatTemplateSource::GgufMetadata(key.to_owned()),
276 bos_token: bos,
277 eos_token: eos,
278 })
279 }
280
281 pub fn source_text(&self) -> &str {
282 &self.source_text
283 }
284
285 pub fn source_kind(&self) -> &ChatTemplateSource {
286 &self.source_kind
287 }
288
289 pub fn bos_token(&self) -> Option<&str> {
290 self.bos_token.as_deref()
291 }
292
293 pub fn eos_token(&self) -> Option<&str> {
294 self.eos_token.as_deref()
295 }
296
297 pub fn render(&self, messages: &[ChatMessage], add_generation_prompt: bool) -> Result<String> {
303 self.render_with_options(
304 messages,
305 ChatRenderOptions {
306 add_generation_prompt,
307 ..ChatRenderOptions::default()
308 },
309 )
310 }
311
312 pub fn render_with_options(
314 &self,
315 messages: &[ChatMessage],
316 opts: ChatRenderOptions,
317 ) -> Result<String> {
318 let msgs = chat_messages_to_values(messages);
319 let ctx = minijinja::context! {
320 messages => Value::from(msgs),
321 add_generation_prompt => opts.add_generation_prompt,
322 enable_thinking => opts.enable_thinking,
323 bos_token => self.bos_token.clone().unwrap_or_default(),
324 eos_token => self.eos_token.clone().unwrap_or_default(),
325 };
326 let tmpl = self
327 .env
328 .get_template(TEMPLATE_NAME)
329 .expect("template registered in build_env");
330 tmpl.render(ctx).context("rendering chat template")
331 }
332}
333
334fn pick_chat_template_meta(raw: &GgufFile) -> Option<(&'static str, String)> {
335 for key in ["tokenizer.chat_template", "tokenizer.ggml.chat_template"] {
336 if let Some(MetaValue::String(s)) = raw.metadata.get(key) {
337 return Some((key, s.clone()));
338 }
339 }
340 None
341}
342
343fn resolve_special_token(raw: &GgufFile, id_key: &str) -> Option<String> {
344 let id = raw.metadata.get(id_key).and_then(MetaValue::as_u32)? as usize;
345 let toks = raw.metadata.get("tokenizer.ggml.tokens")?;
346 let MetaValue::Array(arr) = toks else {
347 return None;
348 };
349 match arr.get(id)? {
350 MetaValue::String(s) => Some(s.clone()),
351 _ => None,
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 const QWEN_TEMPLATE: &str = "{% for m in messages %}<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}";
364
365 const LLAMA3_TEMPLATE: &str = "{% for m in messages %}{% if loop.first %}{{ bos_token }}{% endif %}<|start_header_id|>{{ m.role }}<|end_header_id|>\n\n{{ m.content }}<|eot_id|>{% endfor %}{% if add_generation_prompt %}<|start_header_id|>assistant<|end_header_id|>\n\n{% endif %}";
367
368 const GEMMA_TEMPLATE: &str = "{% for m in messages %}{% set role = 'user' if m.role == 'system' else m.role %}<start_of_turn>{{ role }}\n{{ m.content }}<end_of_turn>\n{% endfor %}{% if add_generation_prompt %}<start_of_turn>model\n{% endif %}";
370
371 fn sample_conv() -> Vec<ChatMessage> {
372 vec![ChatMessage::system("be concise"), ChatMessage::user("hi")]
373 }
374
375 #[test]
376 fn qwen_template_renders_with_generation_prompt() {
377 let t = ChatTemplate::from_source(QWEN_TEMPLATE).unwrap();
378 let out = t.render(&sample_conv(), true).unwrap();
379 let expected = "<|im_start|>system\nbe concise<|im_end|>\n\
380 <|im_start|>user\nhi<|im_end|>\n\
381 <|im_start|>assistant\n";
382 assert_eq!(out, expected);
383 }
384
385 #[test]
386 fn qwen_template_omits_generation_prompt_when_disabled() {
387 let t = ChatTemplate::from_source(QWEN_TEMPLATE).unwrap();
388 let out = t.render(&sample_conv(), false).unwrap();
389 assert!(out.ends_with("<|im_end|>\n"));
390 assert!(!out.contains("<|im_start|>assistant\n"));
391 }
392
393 #[test]
394 fn llama3_template_uses_bos_token() {
395 let t = ChatTemplate::from_source(LLAMA3_TEMPLATE)
396 .unwrap()
397 .with_tokens(Some("<|begin_of_text|>".into()), Some("<|eot_id|>".into()));
398 let out = t.render(&sample_conv(), true).unwrap();
399 let expected = "<|begin_of_text|>\
400 <|start_header_id|>system<|end_header_id|>\n\nbe concise<|eot_id|>\
401 <|start_header_id|>user<|end_header_id|>\n\nhi<|eot_id|>\
402 <|start_header_id|>assistant<|end_header_id|>\n\n";
403 assert_eq!(out, expected);
404 assert_eq!(t.bos_token(), Some("<|begin_of_text|>"));
405 assert_eq!(t.eos_token(), Some("<|eot_id|>"));
406 }
407
408 #[test]
409 fn gemma_template_rewrites_system_to_user() {
410 let t = ChatTemplate::from_source(GEMMA_TEMPLATE).unwrap();
411 let out = t.render(&sample_conv(), true).unwrap();
412 let expected = "<start_of_turn>user\nbe concise<end_of_turn>\n\
413 <start_of_turn>user\nhi<end_of_turn>\n\
414 <start_of_turn>model\n";
415 assert_eq!(out, expected);
416 }
417
418 #[test]
419 fn dict_get_method_works_like_hf_templates() {
420 const TEMPLATE: &str =
421 "{% for m in messages %}{{ m.get('role') }}:{{ m.get('content') }};{% endfor %}";
422 let t = ChatTemplate::from_source(TEMPLATE).unwrap();
423 let out = t
424 .render(
425 &[ChatMessage::user("hi"), ChatMessage::assistant("yo")],
426 false,
427 )
428 .unwrap();
429 assert_eq!(out, "user:hi;assistant:yo;");
430 }
431
432 #[test]
433 fn enable_thinking_is_visible_to_template() {
434 const TEMPLATE: &str = "{% if enable_thinking %}think{% else %}plain{% endif %}";
435 let t = ChatTemplate::from_source(TEMPLATE).unwrap();
436 let on = t
437 .render_with_options(&[], ChatRenderOptions::gemma4_thinking(false))
438 .unwrap();
439 let off = t.render(&[], false).unwrap();
440 assert_eq!(on, "think");
441 assert_eq!(off, "plain");
442 }
443
444 #[test]
445 fn raise_exception_propagates_as_error() {
446 let t = ChatTemplate::from_source("{{ raise_exception('nope') }}").unwrap();
447 let err = t.render(&[], false).unwrap_err();
448 assert!(format!("{err:#}").contains("nope"));
449 }
450
451 #[test]
454 fn from_gguf_reads_template_and_special_tokens() {
455 let mut buf: Vec<u8> = Vec::new();
462 buf.extend_from_slice(&rlx_gguf::GGUF_MAGIC.to_le_bytes());
463 buf.extend_from_slice(&3u32.to_le_bytes());
464 buf.extend_from_slice(&1u64.to_le_bytes()); buf.extend_from_slice(&4u64.to_le_bytes()); let write_string_kv = |buf: &mut Vec<u8>, k: &str, v: &str| {
468 buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
469 buf.extend_from_slice(k.as_bytes());
470 buf.extend_from_slice(&8u32.to_le_bytes());
471 buf.extend_from_slice(&(v.len() as u64).to_le_bytes());
472 buf.extend_from_slice(v.as_bytes());
473 };
474 let write_u32_kv = |buf: &mut Vec<u8>, k: &str, v: u32| {
475 buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
476 buf.extend_from_slice(k.as_bytes());
477 buf.extend_from_slice(&4u32.to_le_bytes());
478 buf.extend_from_slice(&v.to_le_bytes());
479 };
480 let write_string_array_kv = |buf: &mut Vec<u8>, k: &str, items: &[&str]| {
481 buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
482 buf.extend_from_slice(k.as_bytes());
483 buf.extend_from_slice(&9u32.to_le_bytes());
485 buf.extend_from_slice(&8u32.to_le_bytes());
487 buf.extend_from_slice(&(items.len() as u64).to_le_bytes());
489 for s in items {
490 buf.extend_from_slice(&(s.len() as u64).to_le_bytes());
491 buf.extend_from_slice(s.as_bytes());
492 }
493 };
494
495 write_string_kv(&mut buf, "tokenizer.chat_template", QWEN_TEMPLATE);
496 write_string_array_kv(
497 &mut buf,
498 "tokenizer.ggml.tokens",
499 &["<pad>", "<bos>", "<eos>", "hi"],
500 );
501 write_u32_kv(&mut buf, "tokenizer.ggml.bos_token_id", 1);
502 write_u32_kv(&mut buf, "tokenizer.ggml.eos_token_id", 2);
503
504 let name = "w";
506 buf.extend_from_slice(&(name.len() as u64).to_le_bytes());
507 buf.extend_from_slice(name.as_bytes());
508 buf.extend_from_slice(&1u32.to_le_bytes());
509 buf.extend_from_slice(&4u64.to_le_bytes());
510 buf.extend_from_slice(&(rlx_gguf::GgmlType::F32 as u32).to_le_bytes());
511 buf.extend_from_slice(&0u64.to_le_bytes());
512 while !buf
513 .len()
514 .is_multiple_of(rlx_gguf::DEFAULT_ALIGNMENT as usize)
515 {
516 buf.push(0);
517 }
518 for _ in 0..4 {
519 buf.extend_from_slice(&1.0f32.to_le_bytes());
520 }
521 let path = std::env::temp_dir().join("rlx_chat_template_from_gguf.gguf");
522 std::fs::write(&path, &buf).unwrap();
523
524 let t = ChatTemplate::from_gguf(&path).expect("from_gguf");
525 assert_eq!(t.bos_token(), Some("<bos>"));
526 assert_eq!(t.eos_token(), Some("<eos>"));
527 let out = t.render(&sample_conv(), true).unwrap();
528 assert!(out.contains("<|im_start|>assistant\n"));
529 match t.source_kind() {
530 ChatTemplateSource::GgufMetadata(k) => assert_eq!(k, "tokenizer.chat_template"),
531 other => panic!("unexpected source: {other:?}"),
532 }
533 std::fs::remove_file(&path).ok();
534 }
535}