Skip to main content

eredu_text/
tokenizer.rs

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
22/// Computes a stable fingerprint of the complete token-id vocabulary mapping.
23pub 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/// A single chat template or a Hugging Face named-template collection.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ModelChatTemplate {
44    /// A single template used for every request.
45    Single(String),
46    /// A collection indexed by template name.
47    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/// Stable identity of the template selected from a checkpoint's template metadata.
63#[derive(Debug, Clone, PartialEq, Eq, Hash)]
64pub enum ChatTemplateIdentity {
65    /// The checkpoint provides one unnamed template.
66    Single,
67    /// The checkpoint provides a template with this name.
68    Named(String),
69}
70
71/// A selected Jinja template together with its stable checkpoint-local identity.
72#[derive(Debug, Clone)]
73pub struct SelectedChatTemplate<'a> {
74    template: &'a str,
75    identity: ChatTemplateIdentity,
76}
77
78impl SelectedChatTemplate<'_> {
79    /// Returns the selected template source.
80    pub fn template(&self) -> &str {
81        self.template
82    }
83
84    /// Returns the checkpoint-local identity of the selected template.
85    pub fn identity(&self) -> &ChatTemplateIdentity {
86        &self.identity
87    }
88}
89
90impl ModelChatTemplate {
91    /// Selects `tool_use` for a non-empty tool list when present, and `default`
92    /// otherwise. Single templates are always selected unchanged.
93    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
125/// Wrapper around [`tokenizers::Tokenizer`] and [`minijinja::Environment`]
126/// providing more utilities.
127pub 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    /// Wraps a Hugging Face tokenizer with chat-template support.
148    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    /// Replaces the default variables supplied to chat templates.
160    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    /// Returns the default variables supplied to chat templates.
168    pub fn template_kwargs(&self) -> &serde_json::Map<String, serde_json::Value> {
169        &self.template_kwargs
170    }
171
172    /// Loads and wraps a tokenizer from a `tokenizer.json` file.
173    pub fn from_file(file: impl AsRef<Path>) -> tokenizers::Result<Self> {
174        tokenizers::Tokenizer::from_file(file).map(Self::from_tokenizer)
175    }
176
177    /// Loads and wraps a tokenizer from serialized `tokenizer.json` bytes.
178    pub fn from_bytes(bytes: impl AsRef<[u8]>) -> tokenizers::Result<Self> {
179        tokenizers::Tokenizer::from_bytes(bytes).map(Self::from_tokenizer)
180    }
181
182    /// Renders a batch of structured conversations with a model chat template.
183    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    /// Renders and tokenizes a batch of structured conversations.
202    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    /// Renders conversations already represented as JSON message arrays.
230    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
257/// Hugging Face templates commonly pass Python `json.dumps` compatibility
258/// arguments. MiniJinja already emits compact JSON with deterministic object
259/// ordering, but its built-in filter rejects those otherwise redundant kwargs.
260fn 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")]
286/// Standard roles used by chat-template messages.
287pub enum Role {
288    /// Instructions supplied by the system or model author.
289    System,
290    /// Input supplied by the user.
291    User,
292    /// Output supplied by the assistant.
293    Assistant,
294}
295
296/// A role-tagged message passed to a chat template.
297#[derive(Debug, Clone, Serialize)]
298pub struct Conversation<R, T> {
299    /// The message role.
300    pub role: R,
301    /// The message content.
302    pub content: T,
303}
304
305/// An owned or borrowed conversation presented to a chat template.
306#[derive(Debug, Clone, Serialize)]
307#[serde(untagged)]
308pub enum Chat<'a, R, T> {
309    /// A borrowed conversation slice.
310    Borrowed(&'a [Conversation<R, T>]),
311    /// An owned conversation.
312    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/// A document made available for retrieval-aware chat templates.
339#[derive(Debug, Clone, Serialize)]
340pub struct Document {
341    /// The document title.
342    pub title: String,
343    /// The document body.
344    pub text: String,
345}
346
347/// A conversation whose messages are already represented as JSON values.
348#[derive(Debug, Clone, Serialize)]
349#[serde(transparent)]
350pub struct JsonConversation(pub Vec<serde_json::Value>);
351
352/// Inputs for rendering one or more conversations with a chat template.
353#[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    /// Conversations to render.
361    pub conversations: I,
362    /// Tool definitions exposed to the template.
363    pub tools: Option<&'a [serde_json::Value]>,
364    /// Documents exposed to the template.
365    pub documents: Option<&'a [Document]>,
366    /// Stable model identifier used to cache the compiled template.
367    pub model_id: &'a str,
368    /// Identifier of a template already registered in the environment.
369    pub chat_template_id: Option<&'a str>,
370    /// Whether to ask the template to append its generation prompt.
371    pub add_generation_prompt: Option<bool>,
372    /// Whether to trim the rendering after the final input message.
373    pub continue_final_message: Option<bool>,
374    /// Additional variables exposed to the template.
375    pub template_kwargs: Option<&'a serde_json::Map<String, serde_json::Value>>,
376}
377
378/// Loads chat-template metadata from serialized `tokenizer_config.json` data.
379pub 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
444/// Loads chat-template metadata from a `tokenizer_config.json` file.
445pub 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
459/// Returns likely user-provided kwargs referenced by a chat template.
460///
461/// The result excludes the standard chat-template variables provided by this
462/// crate and MiniJinja's registered globals. It remains best-effort because
463/// Jinja templates do not carry a formal kwarg schema.
464pub 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
495/// Renders JSON message arrays using a caller-provided MiniJinja environment.
496pub 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
558/// Renders structured conversations using a caller-provided MiniJinja environment.
559pub 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
637/// MiniJinja does not assign semantics to Transformers' assistant-token
638/// tracking block. Rendering without an assistant mask treats it as a
639/// transparent block while preserving the template's whitespace controls.
640fn 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
664/// Jinja permits a conditional expression directly as a keyword argument,
665/// while MiniJinja requires that expression to be parenthesized. Hugging Face
666/// templates are authored for Jinja, so add the otherwise-semantic no-op
667/// parentheses before compiling them with MiniJinja.
668/// Remove this normalization once https://github.com/mitsuhiko/minijinja/pull/921
669/// is available in the minimum supported MiniJinja release.
670fn 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                // The template preserves spacing or the message doesn't have trailing spacing, so things are simple
1000                rendered_chat[..final_msg_loc + final_msg_len].to_string()
1001            } else {
1002                // The message has trailing spacing that was trimmed, so we must be more cautious
1003                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    /// Returns the path to test fixtures. Uses TEST_MODEL_DIR env var if set,
1025    /// otherwise falls back to the fixtures bundled in the repo.
1026    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}