Skip to main content

vtcode_commons/
tokens.rs

1#![expect(
2    clippy::indexing_slicing,
3    clippy::string_slice,
4    reason = "Token and UTF-8 boundaries come from tokenizer output and the byte truncation helper."
5)]
6
7//! Token counting via tiktoken BPE tokenizer.
8//!
9//! All token estimation goes through [`tiktoken`]'s `cl100k_base` encoding
10//! (GPT-4, GPT-3.5-turbo). BPE tokenizers are similar enough across providers
11//! that this gives reasonable accuracy for Anthropic, Gemini, and others.
12//!
13//! Provider-reported exact token counts (from API responses) should always be
14//! preferred when available. This module is for pre-call budget estimation and
15//! offline token sizing where no provider response exists yet.
16
17use std::sync::OnceLock;
18use tiktoken::CoreBpe;
19
20/// Return the process-global `cl100k_base` BPE instance, if it could be loaded.
21///
22/// Loaded once on first call; all subsequent calls return the same reference.
23/// Returns `None` only if the builtin encoding fails to load, in which case
24/// callers fall back to a character-based heuristic rather than panicking.
25fn bpe() -> Option<&'static CoreBpe> {
26    static BPE: OnceLock<Option<&'static CoreBpe>> = OnceLock::new();
27    *BPE.get_or_init(|| tiktoken::get_encoding("cl100k_base"))
28}
29
30/// Approximate bytes per token used by the byte-length heuristic.
31///
32/// Single source of truth for the `~4 chars/token` convention shared by
33/// preview budgets, schema estimates, and message metadata. BPE-backed
34/// [`estimate_tokens`] stays the default; the heuristic exists for hot paths
35/// and serialized payloads where running the tokenizer costs more than the
36/// accuracy is worth.
37pub const CHARS_PER_TOKEN: usize = 4;
38
39/// Approximate token count from a byte length without running the tokenizer.
40///
41/// Rounds UP (`div_ceil`): budget gates must never undercount, so any
42/// non-empty payload costs at least one token. Prefer [`estimate_tokens`]
43/// when accuracy matters more than speed.
44#[must_use]
45pub fn heuristic_tokens_from_byte_len(byte_len: usize) -> usize {
46    byte_len.div_ceil(CHARS_PER_TOKEN)
47}
48
49/// `u64` twin of [`heuristic_tokens_from_byte_len`] for lifetime byte
50/// counters that never narrow to `usize`.
51#[must_use]
52pub fn heuristic_tokens_from_byte_len_u64(byte_len: u64) -> u64 {
53    byte_len.div_ceil(CHARS_PER_TOKEN as u64)
54}
55
56/// Approximate token count from text length without running the tokenizer.
57///
58/// `text.len()` counts bytes, so multibyte text overestimates slightly; that
59/// is the safe direction for a budget fallback.
60fn heuristic_token_count(text: &str) -> usize {
61    heuristic_tokens_from_byte_len(text.len())
62}
63
64/// Count the number of tokens in `text` using tiktoken BPE.
65///
66/// Returns 0 for empty strings. Falls back to a character-based heuristic if
67/// the BPE tokenizer is unavailable.
68pub fn estimate_tokens(text: &str) -> usize {
69    if text.is_empty() {
70        return 0;
71    }
72    match bpe() {
73        Some(bpe) => bpe.count(text),
74        None => heuristic_token_count(text),
75    }
76}
77
78/// Format a token count with a compact decimal suffix for transcript labels.
79///
80/// Counts below one thousand stay exact. Larger counts are rounded to one
81/// decimal place using `k`, `M`, or `B` suffixes.
82pub fn format_token_count(tokens: usize) -> String {
83    let (divisor, suffix) = match tokens {
84        999_500_000.. => (1_000_000_000, "B"),
85        999_500.. => (1_000_000, "M"),
86        1_000.. => (1_000, "k"),
87        _ => return tokens.to_string(),
88    };
89
90    let whole = tokens / divisor;
91    let decimal_step = divisor / 10;
92    // Divide into tenths before rounding; multiplying a billion-scale
93    // remainder by ten would overflow on 32-bit targets.
94    let tenths = (tokens % divisor + decimal_step / 2) / decimal_step;
95    let (whole, tenths) = if tenths >= 10 {
96        (whole.saturating_add(1), 0)
97    } else {
98        (whole, tenths)
99    };
100    if tenths == 0 {
101        format!("{whole}{suffix}")
102    } else {
103        format!("{whole}.{tenths}{suffix}")
104    }
105}
106
107/// Truncate `text` to at most `max_tokens` tokens.
108///
109/// Decodes the truncated token sequence back to text so the result is always
110/// valid UTF-8 with no mid-token corruption. If a token ends mid-character,
111/// earlier token boundaries are tried. Without BPE, uses the byte heuristic.
112pub fn truncate_to_tokens(text: &str, max_tokens: usize) -> String {
113    if max_tokens == 0 || text.is_empty() {
114        return String::new();
115    }
116    let Some(bpe) = bpe() else {
117        let end = text.floor_char_boundary(max_tokens.saturating_mul(CHARS_PER_TOKEN));
118        return text[..end].to_string();
119    };
120    let tokens = bpe.encode_with_special_tokens(text);
121    if tokens.len() <= max_tokens {
122        return text.to_string();
123    }
124    for end in (1..=max_tokens).rev() {
125        if let Ok(decoded) = bpe.decode_to_string(&tokens[..end]) {
126            return decoded;
127        }
128    }
129    String::new()
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn empty_string_returns_zero() {
138        assert_eq!(estimate_tokens(""), 0);
139        assert_eq!(truncate_to_tokens("", 10), "");
140    }
141
142    #[test]
143    fn count_is_reasonable() {
144        let count = estimate_tokens("Hello, how are you today?");
145        assert!((4..=12).contains(&count), "count={count}");
146    }
147
148    #[test]
149    fn heuristic_byte_len_rounds_up_at_boundaries() {
150        assert_eq!(heuristic_tokens_from_byte_len(0), 0);
151        assert_eq!(heuristic_tokens_from_byte_len(1), 1);
152        assert_eq!(heuristic_tokens_from_byte_len(3), 1);
153        assert_eq!(heuristic_tokens_from_byte_len(4), 1);
154        assert_eq!(heuristic_tokens_from_byte_len(5), 2);
155        assert_eq!(heuristic_tokens_from_byte_len(8), 2);
156        assert_eq!(heuristic_tokens_from_byte_len(9), 3);
157    }
158
159    #[test]
160    fn heuristic_u64_twin_matches_usize_flavor() {
161        for len in [0usize, 1, 3, 4, 5, 1_000, 1_000_003] {
162            assert_eq!(
163                heuristic_tokens_from_byte_len_u64(len as u64),
164                heuristic_tokens_from_byte_len(len) as u64,
165                "len={len}"
166            );
167        }
168        // `div_ceil` cannot overflow: even the largest input converts exactly.
169        assert_eq!(heuristic_tokens_from_byte_len_u64(u64::MAX), u64::MAX.div_ceil(4));
170    }
171
172    #[test]
173    fn bpe_fallback_heuristic_delegates_to_byte_len_helper() {
174        for text in ["", "a", "abcd", "abcde", "Hello, world!", "\u{1f44b}\u{1f30d}"] {
175            assert_eq!(heuristic_token_count(text), heuristic_tokens_from_byte_len(text.len()), "text={text:?}");
176        }
177    }
178
179    #[test]
180    fn truncate_respects_limit() {
181        let text = "the quick brown fox jumps over the lazy dog";
182        let truncated = truncate_to_tokens(text, 5);
183        let count = estimate_tokens(&truncated);
184        assert!(count <= 5 + 1, "count={count} should be <= 6");
185    }
186
187    #[test]
188    fn truncate_zero_returns_empty() {
189        assert_eq!(truncate_to_tokens("hello", 0), "");
190    }
191
192    #[test]
193    fn truncate_split_unicode_token_stays_within_budget() {
194        let text = "🙂🙂 more text";
195        let bpe = bpe().expect("builtin encoding is available");
196        let tokens = bpe.encode_with_special_tokens(text);
197        assert!(bpe.decode_to_string(&tokens[..1]).is_err());
198        for limit in 1..=4 {
199            let truncated = truncate_to_tokens(text, limit);
200            assert!(text.starts_with(&truncated), "limit={limit} output={truncated:?}");
201            assert!(estimate_tokens(&truncated) <= limit, "limit={limit} output={truncated:?}");
202        }
203        assert_eq!(truncate_to_tokens("short", 20), "short");
204    }
205
206    #[test]
207    fn code_and_prose_tokenize() {
208        let code = "fn main() { println!(\"hello\"); }";
209        let prose = "the main function prints hello to console";
210        assert!(estimate_tokens(code) > 0);
211        assert!(estimate_tokens(prose) > 0);
212    }
213
214    #[test]
215    fn json_tokenizes() {
216        let json = r#"{"name":"test","value":123,"nested":{"key":"value"}}"#;
217        let count = estimate_tokens(json);
218        assert!((10..=40).contains(&count), "json count={count}");
219    }
220
221    #[test]
222    fn token_count_format_is_compact_and_rounds_across_suffix_boundaries() {
223        assert_eq!(format_token_count(999), "999");
224        assert_eq!(format_token_count(1_000), "1k");
225        assert_eq!(format_token_count(1_050), "1.1k");
226        assert_eq!(format_token_count(6_100), "6.1k");
227        assert_eq!(format_token_count(999_500), "1M");
228        assert_eq!(format_token_count(15_600_000), "15.6M");
229        assert_eq!(format_token_count(999_500_000), "1B");
230        assert_eq!(format_token_count(1_550_000_000), "1.6B");
231        assert_eq!(format_token_count(1_549_999_999), "1.5B");
232    }
233}