use std::path::Path;
use anyhow::{bail, Result};
use tokenizers::Tokenizer;
pub struct LocalTokenizer {
tokenizer: Tokenizer,
}
impl LocalTokenizer {
pub fn from_file(path: &Path) -> Result<Self> {
let tokenizer = Tokenizer::from_file(path).map_err(|error| {
anyhow::anyhow!("loading {} failed: {error}", path.display())
})?;
Ok(Self { tokenizer })
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
let tokenizer = Tokenizer::from_bytes(bytes)
.map_err(|error| anyhow::anyhow!("parsing the tokenizer failed: {error}"))?;
Ok(Self { tokenizer })
}
pub fn tokenize(&self, content: &str) -> Result<Vec<u32>> {
let encoding = self
.tokenizer
.encode(content, false)
.map_err(|error| anyhow::anyhow!("tokenizing failed: {error}"))?;
Ok(encoding.get_ids().to_vec())
}
pub fn detokenize(&self, tokens: &[u32]) -> Result<String> {
self.tokenizer
.decode(tokens, true)
.map_err(|error| anyhow::anyhow!("detokenizing failed: {error}"))
}
pub fn vocab_size(&self) -> usize {
self.tokenizer.get_vocab_size(true)
}
pub fn verify_special_tokens(&self, expected: &serde_json::Map<String, serde_json::Value>) -> Result<()> {
for (name, value) in expected {
let Some(text) = value.as_str() else {
continue;
};
if !name.ends_with("_token") {
continue;
}
let known = self.tokenizer.token_to_id(text).is_some();
if !known {
bail!(
"the chat template context sets {name} = {text:?}, but this tokenizer has no \
such token; the rendered prompt would not match the model"
);
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_file_fails_with_a_clear_message() {
let error = LocalTokenizer::from_file(Path::new("/nonexistent/tokenizer.json"))
.err()
.expect("a missing file must not load");
assert!(
format!("{error:#}").contains("/nonexistent/tokenizer.json"),
"{error:#}"
);
}
#[test]
fn garbage_bytes_are_rejected() {
let error = LocalTokenizer::from_bytes(b"not a tokenizer")
.err()
.expect("garbage must not parse");
assert!(
format!("{error:#}").contains("parsing the tokenizer failed"),
"{error:#}"
);
}
}