Skip to main content

lean_ctx_patterns/
tokens.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::sync::OnceLock;
4use tiktoken_rs::CoreBPE;
5
6// ── Tokenizer Families ─────────────────────────────────────
7
8/// Tokenizer families for different LLM providers.
9///
10/// Different LLM families use different tokenizers, leading to 5–15% variance
11/// in token counts. This enum lets callers select the appropriate tokenizer.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
13pub enum TokenizerFamily {
14    /// GPT-4o, GPT-4-turbo (tiktoken o200k_base, exact)
15    #[default]
16    O200kBase,
17    /// Claude / Anthropic (approximated via tiktoken cl100k_base)
18    Cl100k,
19    /// Gemini / Google (o200k_base with 1.1× correction factor)
20    Gemini,
21    /// Llama 3+ (approximated via cl100k_base)
22    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
36/// Detects the appropriate tokenizer family from a client or model name.
37///
38/// Matches are case-insensitive substrings. Falls back to `O200kBase`.
39/// Accuracy: cl100k is within ~3% of Claude's actual tokenizer;
40/// Gemini correction factor 1.08 is empirically calibrated; o200k is exact for GPT-4o+.
41pub 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
65// ── Tokenizer Instances ────────────────────────────────────
66
67static 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
99/// Gemini tokens are ~8% larger on average vs o200k; empirically calibrated.
100const GEMINI_CORRECTION: f64 = 1.08;
101
102// ── Cache ──────────────────────────────────────────────────
103
104const 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
144// ── Public API ─────────────────────────────────────────────
145
146/// Counts BPE tokens using the default tokenizer (o200k_base).
147///
148/// Backward-compatible — equivalent to
149/// `count_tokens_for(text, TokenizerFamily::O200kBase)`.
150pub fn count_tokens(text: &str) -> usize {
151    count_tokens_for(text, COUNTING_FAMILY)
152}
153
154/// The tokenizer family [`count_tokens`] uses for all read/savings accounting.
155///
156/// Centralised so honesty surfaces (the savings ledger, Wrapped) can record exactly
157/// which tokenizer produced their token counts, rather than assuming the model's own.
158pub const COUNTING_FAMILY: TokenizerFamily = TokenizerFamily::O200kBase;
159
160/// Label of the tokenizer used for counting (e.g. `"o200k_base"`).
161pub fn counting_family_label() -> String {
162    COUNTING_FAMILY.to_string()
163}
164
165/// Counts BPE tokens using the specified tokenizer family.
166pub 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
194/// Encodes text into BPE token IDs (o200k_base).
195pub 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
205/// Encodes text into BPE token IDs for the specified tokenizer family.
206///
207/// Gemini correction is not applied here — this returns raw token IDs.
208pub 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    // ── Backward-compatible tests ──────────────────────────
235
236    #[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    // ── Multi-tokenizer tests ──────────────────────────────
286
287    #[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        // #685: the savings ledger derives its counting family from the resolved
341        // model key. The blended fallback (no model resolved) maps to the exact
342        // o200k baseline, so the model-correct path is a byte-identical no-op for
343        // OpenAI/Cursor/unknown; real provider keys map to their own family.
344        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        // Reference counts captured at tiktoken-rs 0.6 baseline (Plan B Task 0).
463        // o200k_base encoding tables are a fixed spec; counts MUST stay identical
464        // across the 0.6→0.12 crate bump. A mismatch = silently wrong accounting.
465        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}