1use std::sync::OnceLock;
4use tiktoken_rs::CoreBPE;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
13pub enum TokenizerFamily {
14 #[default]
16 O200kBase,
17 Cl100k,
19 Gemini,
21 Llama,
23}
24
25impl std::fmt::Display for TokenizerFamily {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 match self {
28 Self::O200kBase => write!(f, "o200k_base"),
29 Self::Cl100k => write!(f, "cl100k_base"),
30 Self::Gemini => write!(f, "gemini"),
31 Self::Llama => write!(f, "llama"),
32 }
33 }
34}
35
36pub fn detect_tokenizer(client_name: &str) -> TokenizerFamily {
42 let lower = client_name.to_ascii_lowercase();
43 if lower.contains("claude")
44 || lower.contains("anthropic")
45 || lower.contains("sonnet")
46 || lower.contains("opus")
47 || lower.contains("haiku")
48 {
49 TokenizerFamily::Cl100k
50 } else if lower.contains("gemini") || lower.contains("google") {
51 TokenizerFamily::Gemini
52 } else if lower.contains("llama")
53 || lower.contains("codex")
54 || lower.contains("opencode")
55 || lower.contains("mistral")
56 || lower.contains("deepseek")
57 || lower.contains("qwen")
58 {
59 TokenizerFamily::Llama
60 } else {
61 TokenizerFamily::O200kBase
62 }
63}
64
65static BPE_O200K: OnceLock<Option<CoreBPE>> = OnceLock::new();
68static BPE_CL100K: OnceLock<Option<CoreBPE>> = OnceLock::new();
69
70fn get_bpe_o200k() -> Option<&'static CoreBPE> {
71 BPE_O200K
72 .get_or_init(|| {
73 tiktoken_rs::o200k_base()
74 .inspect_err(|e| tracing::error!("failed to load o200k_base tokenizer: {e}"))
75 .ok()
76 })
77 .as_ref()
78}
79
80fn get_bpe_cl100k() -> Option<&'static CoreBPE> {
81 BPE_CL100K
82 .get_or_init(|| {
83 tiktoken_rs::cl100k_base()
84 .inspect_err(|e| tracing::error!("failed to load cl100k_base tokenizer: {e}"))
85 .ok()
86 })
87 .as_ref()
88}
89
90fn bpe_for_family(family: TokenizerFamily) -> Option<&'static CoreBPE> {
91 match family {
92 TokenizerFamily::O200kBase | TokenizerFamily::Gemini => get_bpe_o200k(),
93 TokenizerFamily::Cl100k | TokenizerFamily::Llama => get_bpe_cl100k(),
94 }
95}
96
97const CHARS_PER_TOKEN_ESTIMATE: f64 = 3.5;
98
99const GEMINI_CORRECTION: f64 = 1.08;
101
102const TOKEN_CACHE_MAX: u64 = 4096;
105
106fn token_cache() -> &'static moka::sync::Cache<u64, usize> {
107 static CACHE: std::sync::OnceLock<moka::sync::Cache<u64, usize>> = std::sync::OnceLock::new();
108 CACHE.get_or_init(|| {
109 moka::sync::Cache::builder()
110 .max_capacity(TOKEN_CACHE_MAX)
111 .build()
112 })
113}
114
115fn hash_text(text: &str, family: TokenizerFamily) -> u64 {
116 let h = blake3::hash(text.as_bytes());
117 let bytes = h.as_bytes();
118 let base = u64::from_le_bytes([
119 bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
120 ]);
121 base ^ (family as u64)
122}
123
124#[cfg(test)]
125fn floor_char_boundary(s: &str, idx: usize) -> usize {
126 let idx = idx.min(s.len());
127 let mut i = idx;
128 while i > 0 && !s.is_char_boundary(i) {
129 i -= 1;
130 }
131 i
132}
133
134#[cfg(test)]
135fn ceil_char_boundary(s: &str, idx: usize) -> usize {
136 let idx = idx.min(s.len());
137 let mut i = idx;
138 while i < s.len() && !s.is_char_boundary(i) {
139 i += 1;
140 }
141 i
142}
143
144pub fn count_tokens(text: &str) -> usize {
151 count_tokens_for(text, COUNTING_FAMILY)
152}
153
154pub const COUNTING_FAMILY: TokenizerFamily = TokenizerFamily::O200kBase;
159
160pub fn counting_family_label() -> String {
162 COUNTING_FAMILY.to_string()
163}
164
165pub fn count_tokens_for(text: &str, family: TokenizerFamily) -> usize {
167 if text.is_empty() {
168 return 0;
169 }
170
171 let key = hash_text(text, family);
172 let cache = token_cache();
173
174 if let Some(cached) = cache.get(&key) {
175 return cached;
176 }
177
178 let Some(bpe) = bpe_for_family(family) else {
179 let estimate = (text.len() as f64 / CHARS_PER_TOKEN_ESTIMATE).ceil() as usize;
180 cache.insert(key, estimate);
181 return estimate;
182 };
183 let raw = bpe.encode_with_special_tokens(text).len();
184 let count = if family == TokenizerFamily::Gemini {
185 (raw as f64 * GEMINI_CORRECTION).ceil() as usize
186 } else {
187 raw
188 };
189
190 cache.insert(key, count);
191 count
192}
193
194pub fn encode_tokens(text: &str) -> Vec<u32> {
196 if text.is_empty() {
197 return Vec::new();
198 }
199 match get_bpe_o200k() {
200 Some(bpe) => bpe.encode_with_special_tokens(text),
201 None => Vec::new(),
202 }
203}
204
205pub fn encode_tokens_for(text: &str, family: TokenizerFamily) -> Vec<u32> {
209 if text.is_empty() {
210 return Vec::new();
211 }
212 match bpe_for_family(family) {
213 Some(bpe) => bpe.encode_with_special_tokens(text),
214 None => Vec::new(),
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use std::sync::{Mutex, OnceLock};
222
223 fn token_test_lock() -> std::sync::MutexGuard<'static, ()> {
224 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
225 LOCK.get_or_init(|| Mutex::new(()))
226 .lock()
227 .unwrap_or_else(std::sync::PoisonError::into_inner)
228 }
229
230 fn reset_cache() {
231 token_cache().invalidate_all();
232 }
233
234 #[test]
237 fn count_tokens_empty_is_zero() {
238 assert_eq!(count_tokens(""), 0);
239 }
240
241 #[test]
242 fn encode_tokens_empty_is_empty() {
243 {
244 let found = &encode_tokens("");
245 assert!(found.is_empty(), "{found:?}");
246 }
247 }
248
249 #[test]
250 fn count_tokens_matches_encoded_length() {
251 let _lock = token_test_lock();
252 reset_cache();
253
254 let text = "hello world, Grüezi 🌍";
255 let counted = count_tokens(text);
256 let encoded = encode_tokens(text);
257 assert_eq!(counted, encoded.len());
258 assert_eq!(counted, count_tokens(text));
259 }
260
261 #[test]
262 fn char_boundary_helpers_handle_multibyte_indices() {
263 let s = "aé🙂z";
264 let emoji_start = s.find('🙂').expect("emoji exists");
265 let middle_of_emoji = emoji_start + 1;
266
267 let floor = floor_char_boundary(s, middle_of_emoji);
268 let ceil = ceil_char_boundary(s, middle_of_emoji);
269
270 assert!(s.is_char_boundary(floor));
271 assert!(s.is_char_boundary(ceil));
272 assert!(floor <= middle_of_emoji);
273 assert!(ceil >= middle_of_emoji);
274 }
275
276 #[test]
277 fn hash_text_is_stable_for_long_strings() {
278 let long = "abc🙂".repeat(300);
279 let h1 = hash_text(&long, TokenizerFamily::O200kBase);
280 let h2 = hash_text(&long, TokenizerFamily::O200kBase);
281 assert_eq!(h1, h2);
282 assert!(count_tokens(&long) > 0);
283 }
284
285 #[test]
288 fn tokenizer_family_default_is_o200k() {
289 assert_eq!(TokenizerFamily::default(), TokenizerFamily::O200kBase);
290 }
291
292 #[test]
293 fn tokenizer_family_display() {
294 assert_eq!(TokenizerFamily::O200kBase.to_string(), "o200k_base");
295 assert_eq!(TokenizerFamily::Cl100k.to_string(), "cl100k_base");
296 assert_eq!(TokenizerFamily::Gemini.to_string(), "gemini");
297 assert_eq!(TokenizerFamily::Llama.to_string(), "llama");
298 }
299
300 #[test]
301 fn detect_tokenizer_openai_variants() {
302 assert_eq!(detect_tokenizer("cursor"), TokenizerFamily::O200kBase);
303 assert_eq!(detect_tokenizer("openai"), TokenizerFamily::O200kBase);
304 assert_eq!(detect_tokenizer("gpt-4o"), TokenizerFamily::O200kBase);
305 assert_eq!(detect_tokenizer("GPT-4-turbo"), TokenizerFamily::O200kBase);
306 }
307
308 #[test]
309 fn detect_tokenizer_claude_variants() {
310 assert_eq!(detect_tokenizer("claude-3.5"), TokenizerFamily::Cl100k);
311 assert_eq!(detect_tokenizer("anthropic"), TokenizerFamily::Cl100k);
312 assert_eq!(detect_tokenizer("Claude"), TokenizerFamily::Cl100k);
313 }
314
315 #[test]
316 fn detect_tokenizer_gemini_variants() {
317 assert_eq!(detect_tokenizer("gemini-pro"), TokenizerFamily::Gemini);
318 assert_eq!(detect_tokenizer("google"), TokenizerFamily::Gemini);
319 assert_eq!(detect_tokenizer("Gemini-1.5"), TokenizerFamily::Gemini);
320 }
321
322 #[test]
323 fn detect_tokenizer_llama_variants() {
324 assert_eq!(detect_tokenizer("llama-3"), TokenizerFamily::Llama);
325 assert_eq!(detect_tokenizer("codex"), TokenizerFamily::Llama);
326 assert_eq!(detect_tokenizer("opencode"), TokenizerFamily::Llama);
327 }
328
329 #[test]
330 fn detect_tokenizer_unknown_defaults_to_o200k() {
331 assert_eq!(
332 detect_tokenizer("unknown-model"),
333 TokenizerFamily::O200kBase
334 );
335 assert_eq!(detect_tokenizer(""), TokenizerFamily::O200kBase);
336 }
337
338 #[test]
339 fn detect_tokenizer_ledger_model_keys() {
340 assert_eq!(
345 detect_tokenizer("fallback-blended"),
346 TokenizerFamily::O200kBase
347 );
348 assert_eq!(
349 detect_tokenizer("claude-sonnet-4.5"),
350 TokenizerFamily::Cl100k
351 );
352 assert_eq!(detect_tokenizer("gemini-2.5-pro"), TokenizerFamily::Gemini);
353 }
354
355 #[test]
356 fn count_tokens_for_all_families_nonzero() {
357 let _lock = token_test_lock();
358 reset_cache();
359
360 let text = "fn main() { println!(\"hello\"); }";
361 for family in [
362 TokenizerFamily::O200kBase,
363 TokenizerFamily::Cl100k,
364 TokenizerFamily::Gemini,
365 TokenizerFamily::Llama,
366 ] {
367 let count = count_tokens_for(text, family);
368 assert!(count > 0, "{family} returned 0 tokens");
369 }
370 }
371
372 #[test]
373 fn count_tokens_for_empty_is_zero_all_families() {
374 for family in [
375 TokenizerFamily::O200kBase,
376 TokenizerFamily::Cl100k,
377 TokenizerFamily::Gemini,
378 TokenizerFamily::Llama,
379 ] {
380 assert_eq!(count_tokens_for("", family), 0);
381 }
382 }
383
384 #[test]
385 fn gemini_count_exceeds_raw_o200k() {
386 let _lock = token_test_lock();
387 reset_cache();
388
389 let text = "The quick brown fox jumps over the lazy dog. ".repeat(20);
390 let o200k = count_tokens_for(&text, TokenizerFamily::O200kBase);
391 let gemini = count_tokens_for(&text, TokenizerFamily::Gemini);
392 assert!(
393 gemini > o200k,
394 "Gemini ({gemini}) should exceed O200kBase ({o200k}) due to 1.1× correction"
395 );
396 }
397
398 #[test]
399 fn cl100k_differs_from_o200k() {
400 let _lock = token_test_lock();
401 reset_cache();
402
403 let text =
404 "use std::collections::HashMap;\nfn main() {\n let mut map = HashMap::new();\n}";
405 let o200k = count_tokens_for(text, TokenizerFamily::O200kBase);
406 let cl100k = count_tokens_for(text, TokenizerFamily::Cl100k);
407 assert!(o200k > 0);
408 assert!(cl100k > 0);
409 }
410
411 #[test]
412 fn encode_tokens_for_matches_count() {
413 let _lock = token_test_lock();
414 reset_cache();
415
416 let text = "hello world";
417 for family in [
418 TokenizerFamily::O200kBase,
419 TokenizerFamily::Cl100k,
420 TokenizerFamily::Llama,
421 ] {
422 let encoded = encode_tokens_for(text, family);
423 let raw_count = bpe_for_family(family)
424 .unwrap()
425 .encode_with_special_tokens(text)
426 .len();
427 assert_eq!(encoded.len(), raw_count, "mismatch for {family}");
428 }
429 }
430
431 #[test]
432 fn cache_distinguishes_families() {
433 let _lock = token_test_lock();
434 reset_cache();
435
436 let text = "cache test string";
437 let o200k = count_tokens_for(text, TokenizerFamily::O200kBase);
438 let cl100k = count_tokens_for(text, TokenizerFamily::Cl100k);
439
440 let h_o200k = hash_text(text, TokenizerFamily::O200kBase);
441 let h_cl100k = hash_text(text, TokenizerFamily::Cl100k);
442 assert_ne!(h_o200k, h_cl100k, "cache keys must differ across families");
443
444 assert_eq!(o200k, count_tokens_for(text, TokenizerFamily::O200kBase));
445 assert_eq!(cl100k, count_tokens_for(text, TokenizerFamily::Cl100k));
446 }
447
448 #[test]
449 fn default_count_tokens_is_o200k() {
450 let _lock = token_test_lock();
451 reset_cache();
452
453 let text = "backward compat check";
454 assert_eq!(
455 count_tokens(text),
456 count_tokens_for(text, TokenizerFamily::O200kBase)
457 );
458 }
459
460 #[test]
461 fn count_tokens_reference_snapshot_o200k() {
462 let cases: [(&str, usize); 5] = [
466 ("", 0),
467 ("hello world", 2),
468 ("fn main() { println!(\"hello\"); }", 9),
469 ("Grüezi 🌍 — café déjà vu", 9),
470 (
471 "use std::collections::HashMap;\nfn main() {\n let mut map = HashMap::new();\n}",
472 23,
473 ),
474 ];
475 for (text, expected) in cases {
476 assert_eq!(
477 count_tokens(text),
478 expected,
479 "token count drift for {text:?}"
480 );
481 }
482 }
483}