use std::{
collections::HashMap,
fs::read_to_string,
ops::{Deref, DerefMut},
path::Path,
str::FromStr,
};
use minijinja::{context, Environment, Template};
use serde::Serialize;
use tokenizers::Encoding;
use crate::error::Error;
pub struct Tokenizer {
inner: tokenizers::Tokenizer,
env: Environment<'static>,
}
impl FromStr for Tokenizer {
type Err = tokenizers::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
tokenizers::Tokenizer::from_str(s).map(Self::from_tokenizer)
}
}
impl Tokenizer {
pub fn from_tokenizer(tokenizer: tokenizers::Tokenizer) -> Self {
let mut env = Environment::new();
env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
Self {
inner: tokenizer,
env,
}
}
pub fn from_file(file: impl AsRef<Path>) -> tokenizers::Result<Self> {
tokenizers::Tokenizer::from_file(file).map(Self::from_tokenizer)
}
pub fn from_bytes(bytes: impl AsRef<[u8]>) -> tokenizers::Result<Self> {
tokenizers::Tokenizer::from_bytes(bytes).map(Self::from_tokenizer)
}
pub fn apply_chat_template<'a, I, R, T>(
&'a mut self,
model_template: String,
args: ApplyChatTemplateArgs<'a, I, R, T>,
) -> Result<Vec<String>, Error>
where
I: IntoIterator<Item = Chat<'a, R, T>>,
R: Serialize + 'a,
T: Serialize + ToString + 'a,
{
apply_chat_template(&mut self.env, model_template, args)
}
pub fn apply_chat_template_and_encode<'a, I, R, T>(
&mut self,
model_template: String,
args: ApplyChatTemplateArgs<'a, I, R, T>,
) -> Result<Vec<Encoding>, Error>
where
I: IntoIterator<Item = Chat<'a, R, T>>,
R: Serialize + 'a,
T: Serialize + ToString + 'a,
{
let Self { inner, env } = self;
let rendered_chats = apply_chat_template(env, model_template, args)?;
inner
.encode_batch(rendered_chats, false)
.map_err(Into::into)
}
}
impl Deref for Tokenizer {
type Target = tokenizers::Tokenizer;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl DerefMut for Tokenizer {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
Assistant,
}
#[derive(Debug, Clone, Serialize)]
pub enum Content {
String(String),
Map(HashMap<String, String>),
}
#[derive(Debug, Clone, Serialize)]
pub struct Conversation<R, T> {
pub role: R,
pub content: T,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum Chat<'a, R, T> {
Borrowed(&'a [Conversation<R, T>]),
Owned(Vec<Conversation<R, T>>),
}
impl<R, T> Deref for Chat<'_, R, T> {
type Target = [Conversation<R, T>];
fn deref(&self) -> &Self::Target {
match self {
Chat::Borrowed(conversations) => conversations,
Chat::Owned(conversations) => conversations,
}
}
}
impl<R, T> From<Vec<Conversation<R, T>>> for Chat<'_, R, T> {
fn from(value: Vec<Conversation<R, T>>) -> Self {
Chat::Owned(value)
}
}
impl<'a, R, T> From<&'a [Conversation<R, T>]> for Chat<'a, R, T> {
fn from(value: &'a [Conversation<R, T>]) -> Self {
Chat::Borrowed(value)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Document {
pub title: String,
pub text: String,
}
pub enum Padding {
Longest,
MaxLength,
}
pub enum Truncation {
MaxLength(usize),
}
#[derive(Default)]
pub struct ApplyChatTemplateArgs<'a, I, R = Role, T = String>
where
I: IntoIterator<Item = Chat<'a, R, T>>,
R: Serialize + 'a,
T: Serialize + ToString + 'a,
{
pub conversations: I,
pub documents: Option<&'a [Document]>,
pub model_id: &'a str,
pub chat_template_id: Option<&'a str>,
pub add_generation_prompt: Option<bool>,
pub continue_final_message: Option<bool>,
}
pub fn load_model_chat_template_from_str(content: &str) -> std::io::Result<Option<String>> {
serde_json::from_str::<serde_json::Value>(content)
.map(|value| {
value
.get("chat_template")
.and_then(|value| value.as_str())
.map(ToString::to_string)
})
.map_err(Into::into)
}
pub fn load_model_chat_template_from_file(
file: impl AsRef<Path>,
) -> std::io::Result<Option<String>> {
let content = read_to_string(file)?;
load_model_chat_template_from_str(&content)
}
pub fn apply_chat_template<'a, I, R, T>(
env: &mut Environment<'static>,
model_template: String,
args: ApplyChatTemplateArgs<'a, I, R, T>,
) -> Result<Vec<String>, Error>
where
I: IntoIterator<Item = Chat<'a, R, T>>,
R: Serialize + 'a,
T: Serialize + ToString + 'a,
{
let ApplyChatTemplateArgs {
conversations,
documents,
model_id,
chat_template_id,
add_generation_prompt,
continue_final_message,
} = args;
let add_generation_prompt = add_generation_prompt.unwrap_or(false);
let continue_final_message = continue_final_message.unwrap_or(false);
let template = match chat_template_id {
Some(chat_template_id) => env.get_template(chat_template_id)?,
None => match env.get_template(model_id) {
Ok(template) => template,
Err(_) => {
env.add_template_owned(model_id.to_owned(), model_template)?;
env.get_template(model_id)
.expect("Newly added template must be present")
}
},
};
render_jinja_tempalte(
template,
conversations,
documents,
Some(add_generation_prompt),
Some(continue_final_message),
)
}
fn render_jinja_tempalte<'a, R, T>(
template: Template,
conversations: impl IntoIterator<Item = Chat<'a, R, T>>,
documents: Option<&'a [Document]>,
add_generation_prompt: Option<bool>,
continue_final_message: Option<bool>,
) -> Result<Vec<String>, Error>
where
R: Serialize + 'a,
T: Serialize + ToString + 'a,
{
let add_generation_prompt = add_generation_prompt.unwrap_or(false);
let continue_final_message = continue_final_message.unwrap_or(false);
let mut rendered = Vec::new();
for chat in conversations {
let mut rendered_chat = template.render(context! {
messages => chat,
documents => documents,
add_generation_prompt => add_generation_prompt,
})?;
if continue_final_message {
let Some(final_message) = chat.last().map(|chat| &chat.content) else {
continue;
};
let final_message_str = final_message.to_string();
if !rendered_chat.contains(final_message_str.trim()) {
return Err(Error::FinalMsgNotInChat);
}
let final_msg_loc = rendered_chat.rfind(&final_message_str.trim()).unwrap();
let final_msg_len = final_message_str.trim_start().len();
rendered_chat = if rendered_chat[final_msg_loc..final_msg_loc + final_msg_len]
== final_message_str
{
rendered_chat[..final_msg_loc + final_msg_len].to_string()
} else {
rendered_chat[..final_msg_loc + final_message_str.trim().len()].to_string()
};
}
rendered.push(rendered_chat);
}
Ok(rendered)
}
#[cfg(test)]
mod tests {
use minijinja::Environment;
use std::path::Path;
use crate::tokenizer::{
apply_chat_template, load_model_chat_template_from_file, ApplyChatTemplateArgs,
Conversation, Role,
};
const CACHED_TEST_MODEL_DIR: &str = "../cache/Qwen3-4B-bf16";
#[test]
fn test_load_chat_template_from_file() {
let file = Path::new(CACHED_TEST_MODEL_DIR).join("tokenizer_config.json");
let chat_template = load_model_chat_template_from_file(file).unwrap().unwrap();
assert!(!chat_template.is_empty());
}
#[test]
fn test_apply_chat_template() {
let file = Path::new(CACHED_TEST_MODEL_DIR).join("tokenizer_config.json");
let model_chat_template = load_model_chat_template_from_file(file).unwrap().unwrap();
assert!(!model_chat_template.is_empty());
let model_id = "mlx-community/Qwen3-4B-bf16".to_string();
let conversations = vec![Conversation {
role: Role::User,
content: "hello",
}];
let args = ApplyChatTemplateArgs {
conversations: [conversations.into()],
documents: None,
model_id: &model_id,
chat_template_id: None,
add_generation_prompt: None,
continue_final_message: None,
};
let mut env = Environment::new();
env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
let rendered_chat = apply_chat_template(&mut env, model_chat_template, args).unwrap();
println!("{:?}", rendered_chat);
}
#[test]
fn test_tokenizer_apply_chat_template() {
let tokenizer_file = Path::new(CACHED_TEST_MODEL_DIR).join("tokenizer.json");
let tokenizer_config_file = Path::new(CACHED_TEST_MODEL_DIR).join("tokenizer_config.json");
let model_id = "mlx-community/Qwen3-4B-bf16".to_string();
let conversations = vec![Conversation {
role: Role::User,
content: "hello",
}];
let mut tokenizer = super::Tokenizer::from_file(tokenizer_file).unwrap();
let model_chat_template = load_model_chat_template_from_file(tokenizer_config_file)
.unwrap()
.unwrap();
assert!(!model_chat_template.is_empty());
let args = ApplyChatTemplateArgs {
conversations: [conversations.into()],
documents: None,
model_id: &model_id,
chat_template_id: None,
add_generation_prompt: None,
continue_final_message: None,
};
let rendered_chat = tokenizer
.apply_chat_template(model_chat_template, args)
.unwrap();
println!("{:?}", rendered_chat);
}
#[test]
fn test_tokenizer_apply_chat_template_and_encode() {
let tokenizer_file = Path::new(CACHED_TEST_MODEL_DIR).join("tokenizer.json");
let tokenizer_config_file = Path::new(CACHED_TEST_MODEL_DIR).join("tokenizer_config.json");
let model_id = "mlx-community/Qwen3-4B-bf16".to_string();
let conversations = vec![Conversation {
role: Role::User,
content: "hello",
}];
let mut tokenizer = super::Tokenizer::from_file(tokenizer_file).unwrap();
let model_chat_template = load_model_chat_template_from_file(tokenizer_config_file)
.unwrap()
.unwrap();
assert!(!model_chat_template.is_empty());
let args = ApplyChatTemplateArgs {
conversations: [conversations.into()],
documents: None,
model_id: &model_id,
chat_template_id: None,
add_generation_prompt: None,
continue_final_message: None,
};
let encodings = tokenizer
.apply_chat_template_and_encode(model_chat_template, args)
.unwrap();
println!("{:?}", encodings.iter().map(|e| e.get_ids()).flatten());
}
}