use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use tiktoken_rs::CoreBPE;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum Tokenizer {
#[default]
Llama3,
Llama2,
TiktokenCl100k,
TiktokenO200k,
Claude,
CharsDiv4,
HfLlama3 {
vocab_path: PathBuf,
},
}
impl Tokenizer {
pub fn label(&self) -> &'static str {
match self {
Tokenizer::Llama3 => "llama3",
Tokenizer::Llama2 => "llama2",
Tokenizer::TiktokenCl100k => "tiktoken-cl100k",
Tokenizer::TiktokenO200k => "tiktoken-o200k",
Tokenizer::Claude => "claude",
Tokenizer::CharsDiv4 => "chars-div-4",
Tokenizer::HfLlama3 { .. } => "hf-llama3",
}
}
pub fn count(&self, text: &str) -> usize {
if text.is_empty() {
return 0;
}
match self {
Tokenizer::TiktokenCl100k | Tokenizer::Claude => cl100k()
.map(|bpe| bpe.encode_with_special_tokens(text).len())
.unwrap_or_else(|| chars_div_4(text)),
Tokenizer::TiktokenO200k => o200k()
.map(|bpe| bpe.encode_with_special_tokens(text).len())
.unwrap_or_else(|| chars_div_4(text)),
Tokenizer::Llama3 => calibrated(text, 3.5),
Tokenizer::Llama2 => calibrated(text, 3.3),
Tokenizer::CharsDiv4 => chars_div_4(text),
Tokenizer::HfLlama3 { vocab_path } => count_hf(vocab_path, text),
}
}
}
fn chars_div_4(text: &str) -> usize {
text.chars().count().div_ceil(4)
}
fn calibrated(text: &str, chars_per_token: f64) -> usize {
let chars = text.chars().count() as f64;
(chars / chars_per_token).ceil() as usize
}
fn cl100k() -> Option<&'static CoreBPE> {
static CL100K: OnceLock<Option<CoreBPE>> = OnceLock::new();
CL100K
.get_or_init(|| tiktoken_rs::cl100k_base().ok())
.as_ref()
}
fn o200k() -> Option<&'static CoreBPE> {
static O200K: OnceLock<Option<CoreBPE>> = OnceLock::new();
O200K
.get_or_init(|| tiktoken_rs::o200k_base().ok())
.as_ref()
}
fn hf_cache() -> &'static Mutex<HashMap<PathBuf, Arc<tokenizers::Tokenizer>>> {
static CACHE: OnceLock<Mutex<HashMap<PathBuf, Arc<tokenizers::Tokenizer>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn load_hf(path: &Path) -> Option<Arc<tokenizers::Tokenizer>> {
let key = path.to_path_buf();
let mut cache = hf_cache().lock().ok()?;
if let Some(t) = cache.get(&key) {
return Some(Arc::clone(t));
}
let t = tokenizers::Tokenizer::from_file(path).ok()?;
let arc = Arc::new(t);
cache.insert(key, Arc::clone(&arc));
Some(arc)
}
fn count_hf(vocab_path: &Path, text: &str) -> usize {
match load_hf(vocab_path) {
Some(t) => match t.encode(text, false) {
Ok(enc) => enc.len(),
Err(_) => calibrated(text, 3.5),
},
None => calibrated(text, 3.5),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_string_counts_zero() {
for t in [
Tokenizer::Llama3,
Tokenizer::Llama2,
Tokenizer::TiktokenCl100k,
Tokenizer::TiktokenO200k,
Tokenizer::Claude,
Tokenizer::CharsDiv4,
Tokenizer::HfLlama3 {
vocab_path: PathBuf::from("/nonexistent"),
},
] {
assert_eq!(t.count(""), 0, "tokenizer {t:?}");
}
}
#[test]
fn nonempty_counts_at_least_one() {
assert!(Tokenizer::Llama3.count("hello world") >= 1);
assert!(Tokenizer::TiktokenCl100k.count("hello world") >= 1);
}
#[test]
fn cl100k_matches_known_tokenization() {
let n = Tokenizer::TiktokenCl100k.count("hello world");
assert_eq!(n, 2, "cl100k count for 'hello world' should be 2, got {n}");
}
#[test]
fn o200k_matches_known_tokenization() {
let n = Tokenizer::TiktokenO200k.count("hello world");
assert_eq!(n, 2, "o200k count for 'hello world' should be 2, got {n}");
}
#[test]
fn claude_aliases_cl100k() {
let s = "The quick brown fox jumps over the lazy dog.";
assert_eq!(
Tokenizer::Claude.count(s),
Tokenizer::TiktokenCl100k.count(s)
);
}
#[test]
fn llama_calibration_is_reasonable() {
let text = "The quick brown fox jumps over the lazy dog. ".repeat(10);
let n = Tokenizer::Llama3.count(&text);
let chars = text.chars().count();
let expected_low = (chars as f64 / 4.0) as usize;
let expected_high = (chars as f64 / 3.0) as usize;
assert!(
n >= expected_low && n <= expected_high,
"llama3 count {n} outside sane range [{expected_low}, {expected_high}] for {chars} chars"
);
}
#[test]
fn label_is_stable() {
assert_eq!(Tokenizer::Llama3.label(), "llama3");
assert_eq!(Tokenizer::TiktokenCl100k.label(), "tiktoken-cl100k");
assert_eq!(Tokenizer::TiktokenO200k.label(), "tiktoken-o200k");
assert_eq!(Tokenizer::Claude.label(), "claude");
assert_eq!(
Tokenizer::HfLlama3 {
vocab_path: PathBuf::from("/nowhere")
}
.label(),
"hf-llama3"
);
}
#[test]
fn hf_llama3_missing_path_falls_back_gracefully() {
let t = Tokenizer::HfLlama3 {
vocab_path: PathBuf::from("/definitely/not/a/real/path/tokenizer.json"),
};
let n = t.count("hello world");
assert!(n >= 1, "fallback should still produce a positive count");
}
#[test]
fn hf_llama3_loads_real_tokenizer_without_falling_back() {
use tokenizers::Tokenizer as HfTokenizer;
use tokenizers::models::bpe::BPE;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("tokenizer.json");
let hf = HfTokenizer::new(BPE::default());
hf.save(&path, false).expect("save tokenizer fixture");
let our = Tokenizer::HfLlama3 {
vocab_path: path.clone(),
};
let fallback = Tokenizer::HfLlama3 {
vocab_path: PathBuf::from("/does/not/exist"),
};
assert_eq!(
our.count("hello"),
0,
"real loader should short-circuit on empty vocab"
);
assert!(
fallback.count("hello") >= 1,
"missing path must fall back to calibrated"
);
}
}