1use std::{
2 collections::{BTreeMap, BTreeSet},
3 fs::read_to_string,
4 ops::{Deref, DerefMut},
5 path::Path,
6 str::FromStr,
7};
8
9use minijinja::{
10 value::{Kwargs, Value},
11 Environment, Template,
12};
13use serde::Serialize;
14use sha2::{Digest, Sha256};
15use tokenizers::Encoding;
16
17use crate::error::Error;
18
19const DEFAULT_CHAT_TEMPLATE_NAME: &str = "default";
20const TOOL_USE_CHAT_TEMPLATE_NAME: &str = "tool_use";
21
22pub fn vocabulary_fingerprint(tokenizer: &tokenizers::Tokenizer) -> [u8; 32] {
24 let vocabulary_size = tokenizer.get_vocab_size(true);
25 let mut hasher = Sha256::new();
26 hasher.update(b"eredu-token-id-vocabulary-v1");
27 hasher.update((vocabulary_size as u64).to_le_bytes());
28 for token_id in 0..vocabulary_size {
29 hasher.update((token_id as u64).to_le_bytes());
30 match tokenizer.id_to_token(token_id as u32) {
31 Some(token) => {
32 hasher.update((token.len() as u64).to_le_bytes());
33 hasher.update(token.as_bytes());
34 }
35 None => hasher.update(u64::MAX.to_le_bytes()),
36 }
37 }
38 hasher.finalize().into()
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ModelChatTemplate {
44 Single(String),
46 Named(BTreeMap<String, String>),
48}
49
50impl From<String> for ModelChatTemplate {
51 fn from(template: String) -> Self {
52 Self::Single(template)
53 }
54}
55
56impl From<&str> for ModelChatTemplate {
57 fn from(template: &str) -> Self {
58 Self::Single(template.to_owned())
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Hash)]
64pub enum ChatTemplateIdentity {
65 Single,
67 Named(String),
69}
70
71#[derive(Debug, Clone)]
73pub struct SelectedChatTemplate<'a> {
74 template: &'a str,
75 identity: ChatTemplateIdentity,
76}
77
78impl SelectedChatTemplate<'_> {
79 pub fn template(&self) -> &str {
81 self.template
82 }
83
84 pub fn identity(&self) -> &ChatTemplateIdentity {
86 &self.identity
87 }
88}
89
90impl ModelChatTemplate {
91 pub fn select(
94 &self,
95 tools: Option<&[serde_json::Value]>,
96 ) -> Result<SelectedChatTemplate<'_>, Error> {
97 match self {
98 Self::Single(template) => Ok(SelectedChatTemplate {
99 template,
100 identity: ChatTemplateIdentity::Single,
101 }),
102 Self::Named(templates) => {
103 let selected_name = if tools.is_some_and(|tools| !tools.is_empty())
104 && templates.contains_key(TOOL_USE_CHAT_TEMPLATE_NAME)
105 {
106 TOOL_USE_CHAT_TEMPLATE_NAME
107 } else {
108 DEFAULT_CHAT_TEMPLATE_NAME
109 };
110 let template =
111 templates
112 .get(selected_name)
113 .ok_or_else(|| Error::AmbiguousChatTemplate {
114 available: templates.keys().cloned().collect(),
115 })?;
116 Ok(SelectedChatTemplate {
117 template,
118 identity: ChatTemplateIdentity::Named(selected_name.to_owned()),
119 })
120 }
121 }
122 }
123}
124
125pub struct Tokenizer {
128 inner: tokenizers::Tokenizer,
129 env: Environment<'static>,
130 template_kwargs: serde_json::Map<String, serde_json::Value>,
131}
132
133struct TemplateKwargs<'defaults, 'overrides> {
134 defaults: Option<&'defaults serde_json::Map<String, serde_json::Value>>,
135 overrides: Option<&'overrides serde_json::Map<String, serde_json::Value>>,
136}
137
138impl FromStr for Tokenizer {
139 type Err = tokenizers::Error;
140
141 fn from_str(s: &str) -> Result<Self, Self::Err> {
142 tokenizers::Tokenizer::from_str(s).map(Self::from_tokenizer)
143 }
144}
145
146impl Tokenizer {
147 pub fn from_tokenizer(tokenizer: tokenizers::Tokenizer) -> Self {
149 let mut env = Environment::new();
150 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
151 env.add_filter("tojson", hugging_face_tojson);
152 Self {
153 inner: tokenizer,
154 env,
155 template_kwargs: serde_json::Map::new(),
156 }
157 }
158
159 pub fn set_template_kwargs(
161 &mut self,
162 template_kwargs: serde_json::Map<String, serde_json::Value>,
163 ) {
164 self.template_kwargs = template_kwargs;
165 }
166
167 pub fn template_kwargs(&self) -> &serde_json::Map<String, serde_json::Value> {
169 &self.template_kwargs
170 }
171
172 pub fn from_file(file: impl AsRef<Path>) -> tokenizers::Result<Self> {
174 tokenizers::Tokenizer::from_file(file).map(Self::from_tokenizer)
175 }
176
177 pub fn from_bytes(bytes: impl AsRef<[u8]>) -> tokenizers::Result<Self> {
179 tokenizers::Tokenizer::from_bytes(bytes).map(Self::from_tokenizer)
180 }
181
182 pub fn apply_chat_template<'a, I, R, T>(
184 &'a mut self,
185 model_template: impl Into<ModelChatTemplate>,
186 args: ApplyChatTemplateArgs<'a, I, R, T>,
187 ) -> Result<Vec<String>, Error>
188 where
189 I: IntoIterator<Item = Chat<'a, R, T>>,
190 R: Serialize + 'a,
191 T: Serialize + 'a,
192 {
193 apply_chat_template_with_default_kwargs(
194 &mut self.env,
195 model_template.into(),
196 args,
197 Some(&self.template_kwargs),
198 )
199 }
200
201 pub fn apply_chat_template_and_encode<'a, I, R, T>(
203 &mut self,
204 model_template: impl Into<ModelChatTemplate>,
205 args: ApplyChatTemplateArgs<'a, I, R, T>,
206 ) -> Result<Vec<Encoding>, Error>
207 where
208 I: IntoIterator<Item = Chat<'a, R, T>>,
209 R: Serialize + 'a,
210 T: Serialize + 'a,
211 {
212 let Self {
213 inner,
214 env,
215 template_kwargs,
216 } = self;
217
218 let rendered_chats = apply_chat_template_with_default_kwargs(
219 env,
220 model_template.into(),
221 args,
222 Some(template_kwargs),
223 )?;
224 inner
225 .encode_batch(rendered_chats, false)
226 .map_err(Into::into)
227 }
228
229 pub fn apply_chat_template_json<'a, I>(
231 &mut self,
232 model_template: impl Into<ModelChatTemplate>,
233 conversations: I,
234 tools: Option<&'a [serde_json::Value]>,
235 model_id: &'a str,
236 add_generation_prompt: bool,
237 template_kwargs: Option<&'a serde_json::Map<String, serde_json::Value>>,
238 ) -> Result<Vec<String>, Error>
239 where
240 I: IntoIterator<Item = Vec<serde_json::Value>>,
241 {
242 apply_chat_template_json_with_default_kwargs(
243 &mut self.env,
244 model_template.into(),
245 conversations,
246 tools,
247 model_id,
248 add_generation_prompt,
249 TemplateKwargs {
250 defaults: Some(&self.template_kwargs),
251 overrides: template_kwargs,
252 },
253 )
254 }
255}
256
257fn hugging_face_tojson(
261 value: &Value,
262 indent: Option<Value>,
263 kwargs: Kwargs,
264) -> Result<Value, minijinja::Error> {
265 let _: Option<Value> = kwargs.get("separators")?;
266 let _: Option<bool> = kwargs.get("sort_keys")?;
267 minijinja::filters::tojson(value, indent, kwargs)
268}
269
270impl Deref for Tokenizer {
271 type Target = tokenizers::Tokenizer;
272
273 fn deref(&self) -> &Self::Target {
274 &self.inner
275 }
276}
277
278impl DerefMut for Tokenizer {
279 fn deref_mut(&mut self) -> &mut Self::Target {
280 &mut self.inner
281 }
282}
283
284#[derive(Debug, Clone, Copy, Serialize)]
285#[serde(rename_all = "lowercase")]
286pub enum Role {
288 System,
290 User,
292 Assistant,
294}
295
296#[derive(Debug, Clone, Serialize)]
298pub struct Conversation<R, T> {
299 pub role: R,
301 pub content: T,
303}
304
305#[derive(Debug, Clone, Serialize)]
307#[serde(untagged)]
308pub enum Chat<'a, R, T> {
309 Borrowed(&'a [Conversation<R, T>]),
311 Owned(Vec<Conversation<R, T>>),
313}
314
315impl<R, T> Deref for Chat<'_, R, T> {
316 type Target = [Conversation<R, T>];
317
318 fn deref(&self) -> &Self::Target {
319 match self {
320 Chat::Borrowed(conversations) => conversations,
321 Chat::Owned(conversations) => conversations,
322 }
323 }
324}
325
326impl<R, T> From<Vec<Conversation<R, T>>> for Chat<'_, R, T> {
327 fn from(value: Vec<Conversation<R, T>>) -> Self {
328 Chat::Owned(value)
329 }
330}
331
332impl<'a, R, T> From<&'a [Conversation<R, T>]> for Chat<'a, R, T> {
333 fn from(value: &'a [Conversation<R, T>]) -> Self {
334 Chat::Borrowed(value)
335 }
336}
337
338#[derive(Debug, Clone, Serialize)]
340pub struct Document {
341 pub title: String,
343 pub text: String,
345}
346
347#[derive(Debug, Clone, Serialize)]
349#[serde(transparent)]
350pub struct JsonConversation(pub Vec<serde_json::Value>);
351
352#[derive(Default)]
354pub struct ApplyChatTemplateArgs<'a, I, R = Role, T = String>
355where
356 I: IntoIterator<Item = Chat<'a, R, T>>,
357 R: Serialize + 'a,
358 T: Serialize + 'a,
359{
360 pub conversations: I,
362 pub tools: Option<&'a [serde_json::Value]>,
364 pub documents: Option<&'a [Document]>,
366 pub model_id: &'a str,
368 pub chat_template_id: Option<&'a str>,
370 pub add_generation_prompt: Option<bool>,
372 pub continue_final_message: Option<bool>,
374 pub template_kwargs: Option<&'a serde_json::Map<String, serde_json::Value>>,
376}
377
378pub fn load_model_chat_template_from_str(
380 content: &str,
381) -> std::io::Result<Option<ModelChatTemplate>> {
382 let config =
383 serde_json::from_str::<serde_json::Value>(content).map_err(std::io::Error::from)?;
384 let Some(value) = config.get("chat_template") else {
385 return Ok(None);
386 };
387 if value.is_null() {
388 return Ok(None);
389 }
390 if let Some(template) = value.as_str() {
391 return Ok(Some(ModelChatTemplate::Single(template.to_owned())));
392 }
393 let Some(entries) = value.as_array() else {
394 return Err(invalid_chat_template(
395 "expected a string or an array of named template entries".into(),
396 ));
397 };
398 if entries.is_empty() {
399 return Err(invalid_chat_template(
400 "named template collection must not be empty".into(),
401 ));
402 }
403
404 let mut templates = BTreeMap::new();
405 for (index, entry) in entries.iter().enumerate() {
406 let Some(entry) = entry.as_object() else {
407 return Err(invalid_chat_template(format!(
408 "entry {index} must be an object with string fields \"name\" and \"template\""
409 )));
410 };
411 if entry.len() != 2 || !entry.contains_key("name") || !entry.contains_key("template") {
412 return Err(invalid_chat_template(format!(
413 "entry {index} must contain exactly the fields \"name\" and \"template\""
414 )));
415 }
416 let Some(name) = entry.get("name").and_then(serde_json::Value::as_str) else {
417 return Err(invalid_chat_template(format!(
418 "entry {index} field \"name\" must be a string"
419 )));
420 };
421 if name.is_empty() {
422 return Err(invalid_chat_template(format!(
423 "entry {index} field \"name\" must not be empty"
424 )));
425 }
426 let Some(template) = entry.get("template").and_then(serde_json::Value::as_str) else {
427 return Err(invalid_chat_template(format!(
428 "entry {index} field \"template\" must be a string"
429 )));
430 };
431 if templates
432 .insert(name.to_owned(), template.to_owned())
433 .is_some()
434 {
435 return Err(invalid_chat_template(format!(
436 "duplicate named template {name:?}"
437 )));
438 }
439 }
440
441 Ok(Some(ModelChatTemplate::Named(templates)))
442}
443
444pub fn load_model_chat_template_from_file(
446 file: impl AsRef<Path>,
447) -> std::io::Result<Option<ModelChatTemplate>> {
448 let content = read_to_string(file)?;
449 load_model_chat_template_from_str(&content)
450}
451
452fn invalid_chat_template(message: String) -> std::io::Error {
453 std::io::Error::new(
454 std::io::ErrorKind::InvalidData,
455 Error::InvalidChatTemplate(message),
456 )
457}
458
459pub fn chat_template_kwargs(
465 model_template: &str,
466 model_id: &str,
467) -> Result<BTreeSet<String>, Error> {
468 let mut env = Environment::new();
469 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
470 let compatible_template = normalize_chat_template(model_template);
471 env.add_template_owned(model_id.to_owned(), compatible_template)?;
472 let template = env.get_template(model_id)?;
473 let globals = env
474 .globals()
475 .map(|(name, _)| name.to_string())
476 .collect::<BTreeSet<_>>();
477
478 Ok(template
479 .undeclared_variables(false)
480 .into_iter()
481 .filter(|name| !STANDARD_CHAT_TEMPLATE_VARIABLES.contains(&name.as_str()))
482 .filter(|name| !globals.contains(name))
483 .collect())
484}
485
486const STANDARD_CHAT_TEMPLATE_VARIABLES: &[&str] = &[
487 "messages",
488 "tools",
489 "documents",
490 "add_generation_prompt",
491 "raise_exception",
492 "strftime_now",
493];
494
495pub fn apply_chat_template_json<'a, I>(
497 env: &mut Environment<'static>,
498 model_template: impl Into<ModelChatTemplate>,
499 conversations: I,
500 tools: Option<&'a [serde_json::Value]>,
501 model_id: &'a str,
502 add_generation_prompt: bool,
503 template_kwargs: Option<&'a serde_json::Map<String, serde_json::Value>>,
504) -> Result<Vec<String>, Error>
505where
506 I: IntoIterator<Item = Vec<serde_json::Value>>,
507{
508 apply_chat_template_json_with_default_kwargs(
509 env,
510 model_template.into(),
511 conversations,
512 tools,
513 model_id,
514 add_generation_prompt,
515 TemplateKwargs {
516 defaults: None,
517 overrides: template_kwargs,
518 },
519 )
520}
521
522fn apply_chat_template_json_with_default_kwargs<'a, 'defaults, I>(
523 env: &mut Environment<'static>,
524 model_template: ModelChatTemplate,
525 conversations: I,
526 tools: Option<&'a [serde_json::Value]>,
527 model_id: &'a str,
528 add_generation_prompt: bool,
529 template_kwargs: TemplateKwargs<'defaults, 'a>,
530) -> Result<Vec<String>, Error>
531where
532 I: IntoIterator<Item = Vec<serde_json::Value>>,
533{
534 let conversations = conversations.into_iter().map(|conversation| {
535 Chat::Owned(vec![Conversation {
536 role: serde_json::Value::Null,
537 content: JsonConversation(conversation),
538 }])
539 });
540
541 apply_chat_template_with_default_kwargs(
542 env,
543 model_template,
544 ApplyChatTemplateArgs {
545 conversations,
546 tools,
547 documents: None,
548 model_id,
549 chat_template_id: None,
550 add_generation_prompt: Some(add_generation_prompt),
551 continue_final_message: None,
552 template_kwargs: template_kwargs.overrides,
553 },
554 template_kwargs.defaults,
555 )
556}
557
558pub fn apply_chat_template<'a, I, R, T>(
560 env: &mut Environment<'static>,
561 model_template: impl Into<ModelChatTemplate>,
562 args: ApplyChatTemplateArgs<'a, I, R, T>,
563) -> Result<Vec<String>, Error>
564where
565 I: IntoIterator<Item = Chat<'a, R, T>>,
566 R: Serialize + 'a,
567 T: Serialize + 'a,
568{
569 apply_chat_template_with_default_kwargs(env, model_template.into(), args, None)
570}
571
572fn apply_chat_template_with_default_kwargs<'a, I, R, T>(
573 env: &mut Environment<'static>,
574 model_template: ModelChatTemplate,
575 args: ApplyChatTemplateArgs<'a, I, R, T>,
576 default_template_kwargs: Option<&serde_json::Map<String, serde_json::Value>>,
577) -> Result<Vec<String>, Error>
578where
579 I: IntoIterator<Item = Chat<'a, R, T>>,
580 R: Serialize + 'a,
581 T: Serialize + 'a,
582{
583 env.add_function("strftime_now", |format: &str| {
584 chrono::Local::now().format(format).to_string()
585 });
586
587 let ApplyChatTemplateArgs {
588 conversations,
589 tools,
590 documents,
591 model_id,
592 chat_template_id,
593 add_generation_prompt,
594 continue_final_message,
595 template_kwargs,
596 } = args;
597
598 let add_generation_prompt = add_generation_prompt.unwrap_or(false);
599 let continue_final_message = continue_final_message.unwrap_or(false);
600 let selected = model_template.select(tools)?;
601
602 let template = match chat_template_id {
603 Some(chat_template_id) => env.get_template(chat_template_id)?,
604 None => {
605 let selected_template_id = match selected.identity() {
606 ChatTemplateIdentity::Single => model_id.to_owned(),
607 ChatTemplateIdentity::Named(name) => {
608 format!("{model_id}::chat_template::{name}")
609 }
610 };
611 match env.get_template(&selected_template_id) {
612 Ok(template) => template,
613 Err(_) => {
614 let compatible_template = normalize_chat_template(selected.template());
615 env.add_template_owned(selected_template_id.clone(), compatible_template)?;
616 env.get_template(&selected_template_id)
617 .expect("Newly added template must be present")
618 }
619 }
620 }
621 };
622
623 render_jinja_template(
624 template,
625 conversations,
626 tools,
627 documents,
628 Some(add_generation_prompt),
629 Some(continue_final_message),
630 TemplateKwargs {
631 defaults: default_template_kwargs,
632 overrides: template_kwargs,
633 },
634 )
635}
636
637fn normalize_generation_blocks(template: &str) -> String {
641 let mut output = String::with_capacity(template.len());
642 let mut remaining = template;
643 while let Some(start) = remaining.find("{%") {
644 output.push_str(&remaining[..start]);
645 let statement = &remaining[start..];
646 let Some(end) = statement.find("%}") else {
647 output.push_str(statement);
648 return output;
649 };
650 let end = end + 2;
651 let tag = &statement[..end];
652 let body = tag[2..tag.len() - 2].trim().trim_matches('-').trim();
653 match body {
654 "generation" => output.push_str(&tag.replacen("generation", "if true", 1)),
655 "endgeneration" => output.push_str(&tag.replacen("endgeneration", "endif", 1)),
656 _ => output.push_str(tag),
657 }
658 remaining = &statement[end..];
659 }
660 output.push_str(remaining);
661 output
662}
663
664fn normalize_conditional_keyword_arguments(template: &str) -> String {
671 fn is_identifier(byte: u8) -> bool {
672 byte.is_ascii_alphanumeric() || byte == b'_'
673 }
674
675 fn keyword_at(bytes: &[u8], index: usize, keyword: &[u8]) -> bool {
676 bytes.get(index..index + keyword.len()) == Some(keyword)
677 && (index == 0 || !is_identifier(bytes[index - 1]))
678 && bytes
679 .get(index + keyword.len())
680 .is_none_or(|byte| !is_identifier(*byte))
681 }
682
683 fn is_fully_parenthesized(bytes: &[u8], start: usize, end: usize) -> bool {
684 let Some(first) = (start..end).find(|index| !bytes[*index].is_ascii_whitespace()) else {
685 return false;
686 };
687 let Some(last) = (start..end)
688 .rev()
689 .find(|index| !bytes[*index].is_ascii_whitespace())
690 else {
691 return false;
692 };
693 if bytes[first] != b'(' || bytes[last] != b')' {
694 return false;
695 }
696
697 let mut depth = 0usize;
698 let mut quote = None;
699 let mut escaped = false;
700 for (offset, byte) in bytes[first..=last].iter().copied().enumerate() {
701 if let Some(active_quote) = quote {
702 if escaped {
703 escaped = false;
704 } else if byte == b'\\' {
705 escaped = true;
706 } else if byte == active_quote {
707 quote = None;
708 }
709 continue;
710 }
711 match byte {
712 b'\'' | b'"' => quote = Some(byte),
713 b'(' => depth += 1,
714 b')' => {
715 depth = depth.saturating_sub(1);
716 if depth == 0 && first + offset != last {
717 return false;
718 }
719 }
720 _ => {}
721 }
722 }
723 depth == 0 && quote.is_none()
724 }
725
726 fn normalize_tag(tag: &str) -> String {
727 let bytes = tag.as_bytes();
728 let mut paren_depth = 0usize;
729 let mut bracket_depth = 0usize;
730 let mut brace_depth = 0usize;
731 let mut quote = None;
732 let mut escaped = false;
733 let mut insertions = Vec::new();
734
735 for (index, byte) in bytes.iter().copied().enumerate() {
736 if let Some(active_quote) = quote {
737 if escaped {
738 escaped = false;
739 } else if byte == b'\\' {
740 escaped = true;
741 } else if byte == active_quote {
742 quote = None;
743 }
744 continue;
745 }
746 match byte {
747 b'\'' | b'"' => quote = Some(byte),
748 b'(' => paren_depth += 1,
749 b')' => paren_depth = paren_depth.saturating_sub(1),
750 b'[' => bracket_depth += 1,
751 b']' => bracket_depth = bracket_depth.saturating_sub(1),
752 b'{' => brace_depth += 1,
753 b'}' => brace_depth = brace_depth.saturating_sub(1),
754 b'=' if paren_depth > 0
755 && bytes.get(index.wrapping_sub(1)).is_some_and(|byte| {
756 *byte != b'=' && *byte != b'!' && *byte != b'<' && *byte != b'>'
757 })
758 && bytes.get(index + 1) != Some(&b'=') =>
759 {
760 let Some(lhs_end) = (0..index)
761 .rev()
762 .find(|position| !bytes[*position].is_ascii_whitespace())
763 else {
764 continue;
765 };
766 if !is_identifier(bytes[lhs_end]) {
767 continue;
768 }
769
770 let base_paren = paren_depth;
771 let base_bracket = bracket_depth;
772 let base_brace = brace_depth;
773 let mut scan_paren = paren_depth;
774 let mut scan_bracket = bracket_depth;
775 let mut scan_brace = brace_depth;
776 let mut scan_quote = None;
777 let mut scan_escaped = false;
778 let mut found_if = false;
779 let mut found_else = false;
780 let mut end = bytes.len();
781 let mut cursor = index + 1;
782 while cursor < bytes.len() {
783 let current = bytes[cursor];
784 if let Some(active_quote) = scan_quote {
785 if scan_escaped {
786 scan_escaped = false;
787 } else if current == b'\\' {
788 scan_escaped = true;
789 } else if current == active_quote {
790 scan_quote = None;
791 }
792 cursor += 1;
793 continue;
794 }
795 match current {
796 b'\'' | b'"' => scan_quote = Some(current),
797 b'(' => scan_paren += 1,
798 b')' if scan_paren == base_paren
799 && scan_bracket == base_bracket
800 && scan_brace == base_brace =>
801 {
802 end = cursor;
803 break;
804 }
805 b')' => scan_paren = scan_paren.saturating_sub(1),
806 b'[' => scan_bracket += 1,
807 b']' => scan_bracket = scan_bracket.saturating_sub(1),
808 b'{' => scan_brace += 1,
809 b'}' => scan_brace = scan_brace.saturating_sub(1),
810 b',' if scan_paren == base_paren
811 && scan_bracket == base_bracket
812 && scan_brace == base_brace =>
813 {
814 end = cursor;
815 break;
816 }
817 _ if scan_paren == base_paren
818 && scan_bracket == base_bracket
819 && scan_brace == base_brace =>
820 {
821 if keyword_at(bytes, cursor, b"if") {
822 found_if = true;
823 } else if found_if && keyword_at(bytes, cursor, b"else") {
824 found_else = true;
825 }
826 }
827 _ => {}
828 }
829 cursor += 1;
830 }
831
832 let Some(rhs_start) =
833 (index + 1..end).find(|position| !bytes[*position].is_ascii_whitespace())
834 else {
835 continue;
836 };
837 let Some(rhs_end) = (index + 1..end)
838 .rev()
839 .find(|position| !bytes[*position].is_ascii_whitespace())
840 .map(|position| position + 1)
841 else {
842 continue;
843 };
844 if found_if && found_else && !is_fully_parenthesized(bytes, rhs_start, rhs_end)
845 {
846 insertions.push((rhs_start, '('));
847 insertions.push((rhs_end, ')'));
848 }
849 }
850 _ => {}
851 }
852 }
853
854 if insertions.is_empty() {
855 return tag.to_owned();
856 }
857 insertions.sort_unstable_by_key(|(position, character)| {
858 (*position, if *character == ')' { 0 } else { 1 })
859 });
860 let mut output = String::with_capacity(tag.len() + insertions.len());
861 let mut insertion_index = 0usize;
862 for (index, character) in tag.char_indices() {
863 while insertions
864 .get(insertion_index)
865 .is_some_and(|(position, _)| *position == index)
866 {
867 output.push(insertions[insertion_index].1);
868 insertion_index += 1;
869 }
870 output.push(character);
871 }
872 while insertions
873 .get(insertion_index)
874 .is_some_and(|(position, _)| *position == tag.len())
875 {
876 output.push(insertions[insertion_index].1);
877 insertion_index += 1;
878 }
879 output
880 }
881
882 let mut output = String::with_capacity(template.len());
883 let mut cursor = 0usize;
884 while cursor < template.len() {
885 let expression = template[cursor..].find("{{").map(|offset| cursor + offset);
886 let statement = template[cursor..].find("{%").map(|offset| cursor + offset);
887 let Some(start) = (match (expression, statement) {
888 (Some(left), Some(right)) => Some(left.min(right)),
889 (left, right) => left.or(right),
890 }) else {
891 output.push_str(&template[cursor..]);
892 break;
893 };
894 output.push_str(&template[cursor..start]);
895 let delimiter = if template[start..].starts_with("{{") {
896 "}}"
897 } else {
898 "%}"
899 };
900 let Some(relative_end) = template[start + 2..].find(delimiter) else {
901 output.push_str(&template[start..]);
902 break;
903 };
904 let end = start + 2 + relative_end + 2;
905 output.push_str(&normalize_tag(&template[start..end]));
906 cursor = end;
907 }
908 output
909}
910
911fn normalize_chat_template(template: &str) -> String {
912 normalize_conditional_keyword_arguments(&normalize_generation_blocks(template))
913}
914
915fn render_jinja_template<'a, 'defaults, R, T>(
916 template: Template,
917 conversations: impl IntoIterator<Item = Chat<'a, R, T>>,
918 tools: Option<&'a [serde_json::Value]>,
919 documents: Option<&'a [Document]>,
920 add_generation_prompt: Option<bool>,
921 continue_final_message: Option<bool>,
922 template_kwargs: TemplateKwargs<'defaults, 'a>,
923) -> Result<Vec<String>, Error>
924where
925 R: Serialize + 'a,
926 T: Serialize + 'a,
927{
928 let add_generation_prompt = add_generation_prompt.unwrap_or(false);
929 let continue_final_message = continue_final_message.unwrap_or(false);
930
931 let mut rendered = Vec::new();
932 for chat in conversations {
933 let empty_tools: &[serde_json::Value] = &[];
934 let empty_documents: &[Document] = &[];
935 let tools = tools.unwrap_or(empty_tools);
936 let documents = documents.unwrap_or(empty_documents);
937 let messages = if chat.len() == 1 {
938 serde_json::to_value(&chat[0].content)
939 .ok()
940 .and_then(|value| match value {
941 serde_json::Value::Array(messages)
942 if serde_json::to_value(&chat[0].role).ok()
943 == Some(serde_json::Value::Null) =>
944 {
945 Some(serde_json::Value::Array(messages))
946 }
947 _ => None,
948 })
949 } else {
950 None
951 }
952 .unwrap_or_else(|| serde_json::to_value(&chat).unwrap_or(serde_json::Value::Null));
953
954 let mut context = serde_json::Map::new();
955 context.insert("messages".to_string(), messages);
956 context.insert("tools".to_string(), serde_json::to_value(tools)?);
957 context.insert("documents".to_string(), serde_json::to_value(documents)?);
958 context.insert(
959 "add_generation_prompt".to_string(),
960 serde_json::Value::Bool(add_generation_prompt),
961 );
962 if let Some(default_template_kwargs) = template_kwargs.defaults {
963 context.extend(default_template_kwargs.clone());
964 }
965 if let Some(template_kwargs) = template_kwargs.overrides {
966 context.extend(template_kwargs.clone());
967 }
968
969 let mut rendered_chat = template.render(context)?;
970 rendered_chat = rendered_chat.trim_start_matches('\n').to_string();
971
972 if continue_final_message {
973 let Some(final_message) = chat
974 .last()
975 .and_then(|chat| serde_json::to_value(&chat.content).ok())
976 .and_then(|value| match value {
977 serde_json::Value::String(text) => Some(text),
978 other => other
979 .get("text")
980 .or_else(|| other.get("content"))
981 .and_then(|value| value.as_str())
982 .map(ToString::to_string),
983 })
984 else {
985 continue;
986 };
987
988 let final_message_str = final_message;
989
990 if !rendered_chat.contains(final_message_str.trim()) {
991 return Err(Error::FinalMsgNotInChat);
992 }
993
994 let final_msg_loc = rendered_chat.rfind(&final_message_str.trim()).unwrap();
995 let final_msg_len = final_message_str.trim_start().len();
996 rendered_chat = if rendered_chat[final_msg_loc..final_msg_loc + final_msg_len]
997 == final_message_str
998 {
999 rendered_chat[..final_msg_loc + final_msg_len].to_string()
1001 } else {
1002 rendered_chat[..final_msg_loc + final_message_str.trim().len()].to_string()
1004 };
1005 }
1006 rendered.push(rendered_chat);
1007 }
1008
1009 Ok(rendered)
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014 use minijinja::Environment;
1015 use std::{collections::BTreeSet, path::PathBuf};
1016
1017 use crate::tokenizer::{
1018 apply_chat_template, apply_chat_template_json, load_model_chat_template_from_file,
1019 load_model_chat_template_from_str, normalize_conditional_keyword_arguments,
1020 normalize_generation_blocks, ApplyChatTemplateArgs, ChatTemplateIdentity, Conversation,
1021 ModelChatTemplate, Role, Tokenizer,
1022 };
1023
1024 fn fixtures_dir() -> PathBuf {
1027 std::env::var("TEST_MODEL_DIR")
1028 .map(PathBuf::from)
1029 .unwrap_or_else(|_| {
1030 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/qwen3")
1031 })
1032 }
1033
1034 #[test]
1035 fn generation_blocks_become_transparent_minijinja_blocks() {
1036 assert_eq!(
1037 normalize_generation_blocks(
1038 "before{%- generation -%}assistant{%- endgeneration -%}after"
1039 ),
1040 "before{%- if true -%}assistant{%- endif -%}after"
1041 );
1042 assert_eq!(
1043 normalize_generation_blocks("{% generation %}{{ generation }}{% endgeneration %}"),
1044 "{% if true %}{{ generation }}{% endif %}"
1045 );
1046 }
1047
1048 #[test]
1049 fn generation_blocks_are_transparent_to_template_kwarg_analysis() {
1050 let kwargs = super::chat_template_kwargs(
1051 concat!(
1052 "{% generation %}",
1053 "{{ messages }}{{ custom_flag }}",
1054 "{% endgeneration %}"
1055 ),
1056 "generation-block-template",
1057 )
1058 .unwrap();
1059
1060 assert_eq!(kwargs, BTreeSet::from(["custom_flag".to_owned()]));
1061 }
1062
1063 #[test]
1064 fn conditional_keyword_arguments_are_parenthesized_for_minijinja() {
1065 let template = concat!(
1066 "plain namespace(name=value if condition else 'fallback')",
1067 "{{ namespace(name=value if condition else 'fallback', keep=other) }}",
1068 "{% set wrapped = namespace(name=(value if condition else 'fallback')) %}",
1069 );
1070 let normalized = normalize_conditional_keyword_arguments(template);
1071
1072 assert_eq!(
1073 normalized,
1074 concat!(
1075 "plain namespace(name=value if condition else 'fallback')",
1076 "{{ namespace(name=(value if condition else 'fallback'), keep=other) }}",
1077 "{% set wrapped = namespace(name=(value if condition else 'fallback')) %}",
1078 )
1079 );
1080 let mut env = Environment::new();
1081 env.add_template_owned("conditional-kwarg", normalized)
1082 .unwrap();
1083 }
1084
1085 #[test]
1086 fn released_muse_glimmer_template_renders_reasoning_and_atem_history() {
1087 let template =
1088 include_str!("../tests/fixtures/chat_templates/muse-glimmer-30b-97c77dff.jinja")
1089 .strip_suffix('\n')
1090 .expect("the fixture-only file terminator is documented");
1091 let mut tokenizer = Tokenizer::from_tokenizer(tokenizers::Tokenizer::new(
1092 tokenizers::models::wordlevel::WordLevel::default(),
1093 ));
1094 tokenizer.set_template_kwargs(serde_json::Map::from_iter([(
1095 "bos_token".into(),
1096 serde_json::json!(""),
1097 )]));
1098 let kwargs =
1099 serde_json::Map::from_iter([("reasoning_strength".into(), serde_json::json!("xhigh"))]);
1100 let rendered = tokenizer
1101 .apply_chat_template_json(
1102 ModelChatTemplate::Single(template.into()),
1103 [vec![
1104 serde_json::json!({"role": "user", "content": "probe"}),
1105 ]],
1106 Some(&[]),
1107 "meta-models/Muse-Glimmer-30B",
1108 true,
1109 Some(&kwargs),
1110 )
1111 .unwrap()
1112 .remove(0);
1113 assert!(rendered.contains("Reasoning strength: xhigh."));
1114 assert!(rendered.ends_with("<|start|>assistant"));
1115
1116 let tools = vec![serde_json::json!({
1117 "type": "function",
1118 "function": {
1119 "name": "lookup",
1120 "description": "look up a value",
1121 "parameters": {
1122 "type": "object",
1123 "properties": {"value": {"type": "string"}}
1124 }
1125 }
1126 })];
1127 let messages = vec![
1128 serde_json::json!({"role": "user", "content": "probe"}),
1129 serde_json::json!({
1130 "role": "assistant",
1131 "reasoning_content": "inspect",
1132 "content": "",
1133 "tool_calls": [{
1134 "id": "call_1",
1135 "type": "function",
1136 "function": {
1137 "name": "lookup",
1138 "arguments": {"value": "probe-value"}
1139 }
1140 }]
1141 }),
1142 serde_json::json!({
1143 "role": "tool",
1144 "name": "lookup",
1145 "tool_call_id": "call_1",
1146 "content": "result"
1147 }),
1148 serde_json::json!({"role": "assistant", "content": "done"}),
1149 ];
1150 let history = tokenizer
1151 .apply_chat_template_json(
1152 ModelChatTemplate::Single(template.into()),
1153 [messages],
1154 Some(&tools),
1155 "meta-models/Muse-Glimmer-30B",
1156 false,
1157 None,
1158 )
1159 .unwrap()
1160 .remove(0);
1161 assert!(history.contains(concat!(
1162 "<|start|>assistant to=self<|message|>inspect<|eom|>",
1163 "<|start|>assistant to=lookup<|message|>",
1164 "<atem:function_calls>\n<atem:invoke name=\"lookup\">\n",
1165 "<atem:parameter name=\"value\">probe-value</atem:parameter>"
1166 )));
1167 assert!(history
1168 .contains("<|start|>tool lookup<|message|><tool_output name=\"lookup\">\nresult"));
1169 }
1170
1171 #[test]
1172 fn test_load_chat_template_from_file() {
1173 let file = fixtures_dir().join("tokenizer_config.json");
1174 let chat_template = load_model_chat_template_from_file(file).unwrap().unwrap();
1175 assert!(!chat_template.select(None).unwrap().template().is_empty());
1176 }
1177
1178 #[test]
1179 fn single_chat_template_remains_compatible() {
1180 let templates = load_model_chat_template_from_str(r#"{"chat_template":"single-template"}"#)
1181 .unwrap()
1182 .unwrap();
1183 let selected = templates.select(None).unwrap();
1184 assert_eq!(selected.template(), "single-template");
1185 assert_eq!(selected.identity(), &ChatTemplateIdentity::Single);
1186
1187 let selected_with_tools = templates.select(Some(&[serde_json::json!({})])).unwrap();
1188 assert_eq!(selected_with_tools.template(), "single-template");
1189 assert_eq!(
1190 selected_with_tools.identity(),
1191 &ChatTemplateIdentity::Single
1192 );
1193 }
1194
1195 #[test]
1196 fn named_chat_templates_select_default_or_tool_use() {
1197 let templates = load_model_chat_template_from_str(
1198 r#"{
1199 "chat_template": [
1200 {"name": "tool_use", "template": "tools-template"},
1201 {"name": "default", "template": "default-template"}
1202 ]
1203 }"#,
1204 )
1205 .unwrap()
1206 .unwrap();
1207
1208 let selected = templates.select(None).unwrap();
1209 assert_eq!(selected.template(), "default-template");
1210 assert_eq!(
1211 selected.identity(),
1212 &ChatTemplateIdentity::Named("default".into())
1213 );
1214
1215 let selected = templates.select(Some(&[])).unwrap();
1216 assert_eq!(selected.template(), "default-template");
1217 assert_eq!(
1218 selected.identity(),
1219 &ChatTemplateIdentity::Named("default".into())
1220 );
1221
1222 let tools = [serde_json::json!({"type": "function"})];
1223 let selected = templates.select(Some(&tools)).unwrap();
1224 assert_eq!(selected.template(), "tools-template");
1225 assert_eq!(
1226 selected.identity(),
1227 &ChatTemplateIdentity::Named("tool_use".into())
1228 );
1229
1230 let default_only = load_model_chat_template_from_str(
1231 r#"{"chat_template":[{"name":"default","template":"default-only"}]}"#,
1232 )
1233 .unwrap()
1234 .unwrap();
1235 let selected = default_only.select(Some(&tools)).unwrap();
1236 assert_eq!(selected.template(), "default-only");
1237 assert_eq!(
1238 selected.identity(),
1239 &ChatTemplateIdentity::Named("default".into())
1240 );
1241 }
1242
1243 #[test]
1244 fn named_chat_template_selection_reports_missing_default_deterministically() {
1245 let templates = load_model_chat_template_from_str(
1246 r#"{"chat_template":[
1247 {"name":"rag","template":"rag-template"},
1248 {"name":"chat","template":"chat-template"}
1249 ]}"#,
1250 )
1251 .unwrap()
1252 .unwrap();
1253
1254 assert_eq!(
1255 templates.select(None).unwrap_err().to_string(),
1256 r#"chat_template collection has no default template; available templates: ["chat", "rag"]"#
1257 );
1258 }
1259
1260 #[test]
1261 fn named_chat_template_collection_rejects_malformed_and_duplicate_entries() {
1262 let cases = [
1263 (
1264 r#"{"chat_template":{}}"#,
1265 "invalid chat_template: expected a string or an array of named template entries",
1266 ),
1267 (
1268 r#"{"chat_template":[]}"#,
1269 "invalid chat_template: named template collection must not be empty",
1270 ),
1271 (
1272 r#"{"chat_template":["default"]}"#,
1273 "invalid chat_template: entry 0 must be an object with string fields \"name\" and \"template\"",
1274 ),
1275 (
1276 r#"{"chat_template":[{"template":"x"}]}"#,
1277 "invalid chat_template: entry 0 must contain exactly the fields \"name\" and \"template\"",
1278 ),
1279 (
1280 r#"{"chat_template":[{"name":"default","template":1}]}"#,
1281 "invalid chat_template: entry 0 field \"template\" must be a string",
1282 ),
1283 (
1284 r#"{"chat_template":[{"name":"default","template":"a"},{"name":"default","template":"b"}]}"#,
1285 "invalid chat_template: duplicate named template \"default\"",
1286 ),
1287 ];
1288
1289 for (config, expected) in cases {
1290 assert_eq!(
1291 load_model_chat_template_from_str(config)
1292 .unwrap_err()
1293 .to_string(),
1294 expected
1295 );
1296 }
1297 }
1298
1299 #[test]
1300 fn apply_chat_template_apis_share_named_template_selection() {
1301 let templates = load_model_chat_template_from_str(
1302 r#"{
1303 "chat_template": [
1304 {"name": "default", "template": "default"},
1305 {"name": "tool_use", "template": "tool_use"}
1306 ]
1307 }"#,
1308 )
1309 .unwrap()
1310 .unwrap();
1311 let mut env = Environment::new();
1312 let conversations = [vec![Conversation {
1313 role: Role::User,
1314 content: "hello",
1315 }]
1316 .into()];
1317
1318 let rendered = apply_chat_template(
1319 &mut env,
1320 templates.clone(),
1321 ApplyChatTemplateArgs {
1322 conversations,
1323 tools: Some(&[]),
1324 documents: None,
1325 model_id: "selection-test",
1326 chat_template_id: None,
1327 add_generation_prompt: None,
1328 continue_final_message: None,
1329 template_kwargs: None,
1330 },
1331 )
1332 .unwrap();
1333 assert_eq!(rendered, vec!["default"]);
1334
1335 let tools = [serde_json::json!({"type": "function"})];
1336 let rendered = apply_chat_template_json(
1337 &mut env,
1338 templates,
1339 [vec![
1340 serde_json::json!({"role": "user", "content": "hello"}),
1341 ]],
1342 Some(&tools),
1343 "selection-test",
1344 false,
1345 None,
1346 )
1347 .unwrap();
1348 assert_eq!(rendered, vec!["tool_use"]);
1349 }
1350
1351 #[test]
1352 fn tokenizer_tojson_accepts_hugging_face_sort_and_separator_kwargs() {
1353 let raw = tokenizers::Tokenizer::new(tokenizers::models::wordlevel::WordLevel::default());
1354 let mut tokenizer = super::Tokenizer::from_tokenizer(raw);
1355 let tools = [serde_json::json!({"zeta": 2, "alpha": 1})];
1356
1357 let rendered = tokenizer
1358 .apply_chat_template_json(
1359 "{{ tools[0] | tojson(sort_keys=true, separators=(\",\", \":\")) }}",
1360 [Vec::new()],
1361 Some(&tools),
1362 "tojson-kwargs-test",
1363 false,
1364 None,
1365 )
1366 .unwrap();
1367
1368 assert_eq!(rendered, [r#"{"alpha":1,"zeta":2}"#]);
1369 }
1370
1371 #[test]
1372 fn test_apply_chat_template() {
1373 let file = fixtures_dir().join("tokenizer_config.json");
1374 let model_chat_template = load_model_chat_template_from_file(file).unwrap().unwrap();
1375 assert!(!model_chat_template
1376 .select(None)
1377 .unwrap()
1378 .template()
1379 .is_empty());
1380
1381 let model_id = "mlx-community/Qwen3-4B-bf16".to_string();
1382 let conversations = vec![Conversation {
1383 role: Role::User,
1384 content: "hello",
1385 }];
1386 let args = ApplyChatTemplateArgs {
1387 conversations: [conversations.into()],
1388 tools: None,
1389 documents: None,
1390 model_id: &model_id,
1391 chat_template_id: None,
1392 add_generation_prompt: None,
1393 continue_final_message: None,
1394 template_kwargs: None,
1395 };
1396
1397 let mut env = Environment::new();
1398 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
1399
1400 let rendered_chat = apply_chat_template(&mut env, model_chat_template, args).unwrap();
1401 println!("{:?}", rendered_chat);
1402 }
1403
1404 #[test]
1405 fn test_apply_chat_template_with_template_kwargs() {
1406 let model_template =
1407 "{% if enable_thinking %}think{% else %}no-think{% endif %}".to_string();
1408 let model_id = "test-model".to_string();
1409 let conversations = vec![Conversation {
1410 role: Role::User,
1411 content: "hello",
1412 }];
1413 let mut template_kwargs = serde_json::Map::new();
1414 template_kwargs.insert(
1415 "enable_thinking".to_string(),
1416 serde_json::Value::Bool(false),
1417 );
1418 let args = ApplyChatTemplateArgs {
1419 conversations: [conversations.into()],
1420 tools: None,
1421 documents: None,
1422 model_id: &model_id,
1423 chat_template_id: None,
1424 add_generation_prompt: None,
1425 continue_final_message: None,
1426 template_kwargs: Some(&template_kwargs),
1427 };
1428
1429 let mut env = Environment::new();
1430 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
1431
1432 let rendered_chat = apply_chat_template(&mut env, model_template, args).unwrap();
1433 assert_eq!(rendered_chat, vec!["no-think"]);
1434 }
1435
1436 #[test]
1437 fn test_tokenizer_template_kwargs_defaults_can_be_overridden() {
1438 let raw = tokenizers::Tokenizer::new(tokenizers::models::wordlevel::WordLevel::default());
1439 let mut tokenizer = super::Tokenizer::from_tokenizer(raw);
1440 tokenizer.set_template_kwargs(serde_json::Map::from_iter([
1441 (
1442 "bos_token".to_string(),
1443 serde_json::Value::String("<bos>".to_string()),
1444 ),
1445 (
1446 "tone".to_string(),
1447 serde_json::Value::String("default".to_string()),
1448 ),
1449 ]));
1450 let model_template =
1451 "{{ bos_token }}{{ messages[0].role }}: {{ messages[0].content }} {{ tone }}"
1452 .to_string();
1453 let model_id = "test-model".to_string();
1454 let conversations = vec![Conversation {
1455 role: Role::User,
1456 content: "hello",
1457 }];
1458 let mut template_kwargs = serde_json::Map::new();
1459 template_kwargs.insert(
1460 "tone".to_string(),
1461 serde_json::Value::String("override".to_string()),
1462 );
1463
1464 let rendered_chat = tokenizer
1465 .apply_chat_template(
1466 model_template,
1467 ApplyChatTemplateArgs {
1468 conversations: [conversations.into()],
1469 tools: None,
1470 documents: None,
1471 model_id: &model_id,
1472 chat_template_id: None,
1473 add_generation_prompt: None,
1474 continue_final_message: None,
1475 template_kwargs: Some(&template_kwargs),
1476 },
1477 )
1478 .unwrap();
1479
1480 assert_eq!(rendered_chat, vec!["<bos>user: hello override"]);
1481 }
1482
1483 #[test]
1484 fn test_chat_template_kwargs_filters_standard_variables_and_globals() {
1485 let model_template = concat!(
1486 "{% set ns = namespace(found=false) %}",
1487 "{% for message in messages %}{{ message.role }}{% endfor %}",
1488 "{% if tools %}{{ tools|length }}{% endif %}",
1489 "{% if documents %}{{ documents|length }}{% endif %}",
1490 "{% if add_generation_prompt and enable_thinking is defined %}{{ tone }}{% endif %}",
1491 );
1492
1493 let kwargs = super::chat_template_kwargs(model_template, "test-model").unwrap();
1494 assert_eq!(
1495 kwargs.into_iter().collect::<Vec<_>>(),
1496 vec!["enable_thinking", "tone"]
1497 );
1498 }
1499
1500 #[test]
1501 fn test_qwen_fixture_reports_enable_thinking_kwarg() {
1502 let file = fixtures_dir().join("tokenizer_config.json");
1503 let chat_template = load_model_chat_template_from_file(file).unwrap().unwrap();
1504 let kwargs = super::chat_template_kwargs(
1505 chat_template.select(None).unwrap().template(),
1506 "qwen-fixture",
1507 )
1508 .unwrap();
1509 assert!(kwargs.contains("enable_thinking"), "{kwargs:?}");
1510 }
1511
1512 #[test]
1513 #[ignore = "requires local model files (tokenizer.json is 11MB)"]
1514 fn test_tokenizer_apply_chat_template() {
1515 let tokenizer_file = fixtures_dir().join("tokenizer.json");
1516 let tokenizer_config_file = fixtures_dir().join("tokenizer_config.json");
1517
1518 let model_id = "mlx-community/Qwen3-4B-bf16".to_string();
1519
1520 let conversations = vec![Conversation {
1521 role: Role::User,
1522 content: "hello",
1523 }];
1524
1525 let mut tokenizer = super::Tokenizer::from_file(tokenizer_file).unwrap();
1526
1527 let model_chat_template = load_model_chat_template_from_file(tokenizer_config_file)
1528 .unwrap()
1529 .unwrap();
1530 assert!(!model_chat_template
1531 .select(None)
1532 .unwrap()
1533 .template()
1534 .is_empty());
1535
1536 let args = ApplyChatTemplateArgs {
1537 conversations: [conversations.into()],
1538 tools: None,
1539 documents: None,
1540 model_id: &model_id,
1541 chat_template_id: None,
1542 add_generation_prompt: None,
1543 continue_final_message: None,
1544 template_kwargs: None,
1545 };
1546
1547 let rendered_chat = tokenizer
1548 .apply_chat_template(model_chat_template, args)
1549 .unwrap();
1550 println!("{:?}", rendered_chat);
1551 }
1552
1553 #[test]
1554 #[ignore = "requires local model files (tokenizer.json is 11MB)"]
1555 fn test_tokenizer_apply_chat_template_and_encode() {
1556 let tokenizer_file = fixtures_dir().join("tokenizer.json");
1557 let tokenizer_config_file = fixtures_dir().join("tokenizer_config.json");
1558
1559 let model_id = "mlx-community/Qwen3-4B-bf16".to_string();
1560
1561 let conversations = vec![Conversation {
1562 role: Role::User,
1563 content: "hello",
1564 }];
1565 let mut tokenizer = super::Tokenizer::from_file(tokenizer_file).unwrap();
1566
1567 let model_chat_template = load_model_chat_template_from_file(tokenizer_config_file)
1568 .unwrap()
1569 .unwrap();
1570 assert!(!model_chat_template
1571 .select(None)
1572 .unwrap()
1573 .template()
1574 .is_empty());
1575
1576 let args = ApplyChatTemplateArgs {
1577 conversations: [conversations.into()],
1578 tools: None,
1579 documents: None,
1580 model_id: &model_id,
1581 chat_template_id: None,
1582 add_generation_prompt: None,
1583 continue_final_message: None,
1584 template_kwargs: None,
1585 };
1586
1587 let encodings = tokenizer
1588 .apply_chat_template_and_encode(model_chat_template, args)
1589 .unwrap();
1590 println!("{:?}", encodings.iter().flat_map(|e| e.get_ids()));
1591 }
1592}