1use crate::types::ModelFamily;
2
3pub struct TokenCounter;
16
17impl TokenCounter {
18 pub fn new() -> Self {
23 Self
24 }
25
26 pub fn count(&self, text: &str, model: &ModelFamily) -> u32 {
33 match model {
34 ModelFamily::OpenAiGpt => self.count_o200k(text),
35 ModelFamily::AnthropicClaude | ModelFamily::GoogleGemini => self.count_cl100k(text),
36 ModelFamily::Local(_) => Self::count_fast(text),
37 }
38 }
39
40 pub fn count_fast(text: &str) -> u32 {
44 ((text.len() as f64) / 4.0).ceil() as u32
45 }
46
47 fn count_cl100k(&self, text: &str) -> u32 {
49 let bpe = tiktoken_rs::cl100k_base_singleton();
50 let lock = bpe.lock();
51 count_in_slices(text, |s| lock.encode_with_special_tokens(s).len())
52 }
53
54 fn count_o200k(&self, text: &str) -> u32 {
56 let bpe = tiktoken_rs::o200k_base_singleton();
57 let lock = bpe.lock();
58 count_in_slices(text, |s| lock.encode_with_special_tokens(s).len())
59 }
60}
61
62const MAX_RUN_BYTES: usize = 2048;
64
65fn count_in_slices(text: &str, encode: impl Fn(&str) -> usize) -> u32 {
67 let mut total = 0;
68 let mut start = 0;
69 let mut last = ("", 0);
70 for end in run_cuts(text).into_iter().chain([text.len()]) {
71 let slice = &text[start..end];
72 if slice != last.0 {
73 let n = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| encode(slice)))
74 .unwrap_or_else(|_| TokenCounter::count_fast(slice) as usize);
75 last = (slice, n);
76 }
77 total += last.1;
78 start = end;
79 }
80 total as u32
81}
82
83fn run_cuts(text: &str) -> Vec<usize> {
85 static RUNS: std::sync::OnceLock<Vec<regex::Regex>> = std::sync::OnceLock::new();
86 if text.len() <= MAX_RUN_BYTES || !may_hold_long_run(text) {
87 return Vec::new();
88 }
89 let runs = RUNS.get_or_init(|| {
90 [r"[\p{L}\p{M}]+", r"[^\s\p{L}\p{N}]+", r"\s+", r"[\r\n/]+"]
91 .iter()
92 .filter_map(|p| regex::Regex::new(p).ok())
93 .collect()
94 });
95 let mut long: Vec<(usize, usize)> = runs
96 .iter()
97 .flat_map(|re| re.find_iter(text))
98 .filter(|m| m.len() > MAX_RUN_BYTES)
99 .map(|m| (m.start(), m.end()))
100 .collect();
101 long.sort_unstable();
102 let mut cuts = Vec::new();
103 let mut i = 0;
104 while i < long.len() {
105 let (mut at, mut end) = long[i];
106 while i + 1 < long.len() && long[i + 1].0 <= end {
107 i += 1;
108 end = end.max(long[i].1);
109 }
110 while end - at > MAX_RUN_BYTES {
111 at = crate::text_boundary::floor_char_boundary(text, at + MAX_RUN_BYTES);
112 cuts.push(at);
113 }
114 i += 1;
115 }
116 cuts
117}
118
119fn may_hold_long_run(text: &str) -> bool {
121 let mut runs = [0usize; 4];
122 for &b in text.as_bytes() {
123 let space = b.is_ascii_whitespace() || b == 0x0b;
124 let letter = b.is_ascii_alphabetic();
125 let wide = !b.is_ascii();
126 let member = [
127 wide || letter,
128 wide || !(space || letter || b.is_ascii_digit()),
129 wide || space,
130 matches!(b, b'\r' | b'\n' | b'/'),
131 ];
132 for (run, member) in runs.iter_mut().zip(member) {
133 *run = if member { *run + 1 } else { 0 };
134 if *run > MAX_RUN_BYTES {
135 return true;
136 }
137 }
138 }
139 false
140}
141
142impl Default for TokenCounter {
143 fn default() -> Self {
144 Self::new()
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn count_empty_string() {
154 let tc = TokenCounter::new();
155 assert_eq!(tc.count("", &ModelFamily::OpenAiGpt), 0);
156 assert_eq!(tc.count("", &ModelFamily::AnthropicClaude), 0);
157 assert_eq!(TokenCounter::count_fast(""), 0);
158 }
159
160 #[test]
161 fn count_hello_world_openai() {
162 let tc = TokenCounter::new();
163 let count = tc.count("Hello, world!", &ModelFamily::OpenAiGpt);
164 assert!(count > 0 && count < 10, "unexpected count: {count}");
166 }
167
168 #[test]
169 fn count_hello_world_claude() {
170 let tc = TokenCounter::new();
171 let count = tc.count("Hello, world!", &ModelFamily::AnthropicClaude);
172 assert!(count > 0 && count < 10, "unexpected count: {count}");
173 }
174
175 #[test]
176 fn count_hello_world_gemini() {
177 let tc = TokenCounter::new();
178 let count = tc.count("Hello, world!", &ModelFamily::GoogleGemini);
179 assert!(count > 0 && count < 10, "unexpected count: {count}");
181 }
182
183 #[test]
184 fn count_local_model_uses_fallback() {
185 let tc = TokenCounter::new();
186 let count = tc.count("abcdefgh", &ModelFamily::Local("llama".into()));
188 assert_eq!(count, 2);
189 }
190
191 #[test]
192 fn count_fast_basic() {
193 assert_eq!(TokenCounter::count_fast("abcd"), 1);
194 assert_eq!(TokenCounter::count_fast("abcde"), 2);
195 assert_eq!(TokenCounter::count_fast("a"), 1);
196 }
197
198 #[test]
199 fn exact_count_differs_from_approximation() {
200 let tc = TokenCounter::new();
201 let text = "fn main() { println!(\"Hello, world!\"); }";
202 let exact = tc.count(text, &ModelFamily::OpenAiGpt);
203 let approx = TokenCounter::count_fast(text);
204 assert!(exact > 0);
206 assert!(approx > 0);
207 }
208
209 use proptest::prelude::*;
215
216 fn arb_nonempty_text() -> impl Strategy<Value = String> {
218 prop_oneof![
220 "\\PC{1,256}",
222 "[a-zA-Z0-9 .,;:!?()\\[\\]{}<>@#$%^&*_+=~`|/\\\\-]{1,256}",
224 "(fn|def|class|import|const|let|var|pub|async|await|return)[ a-zA-Z0-9_(){}:;,\n]{1,200}",
226 ]
227 .prop_filter("must be non-empty", |s| !s.is_empty())
228 }
229
230 fn arb_known_model() -> impl Strategy<Value = ModelFamily> {
232 prop_oneof![
233 Just(ModelFamily::OpenAiGpt),
234 Just(ModelFamily::AnthropicClaude),
235 Just(ModelFamily::GoogleGemini),
236 ]
237 }
238
239 proptest! {
240 #[test]
245 fn prop37a_exact_count_positive_for_nonempty(
246 text in arb_nonempty_text(),
247 model in arb_known_model(),
248 ) {
249 let tc = TokenCounter::new();
250 let count = tc.count(&text, &model);
251 prop_assert!(
252 count > 0,
253 "exact token count must be > 0 for non-empty string (len={}), got {}",
254 text.len(),
255 count
256 );
257 }
258
259 #[test]
265 fn prop37b_exact_count_leq_char_count(
266 text in arb_nonempty_text(),
267 model in arb_known_model(),
268 ) {
269 let tc = TokenCounter::new();
270 let count = tc.count(&text, &model);
271 let char_count = text.len() as u32;
272 prop_assert!(
273 count <= char_count,
274 "exact token count ({}) must be <= byte length ({}) for text",
275 count,
276 char_count
277 );
278 }
279
280 #[test]
286 fn prop37c_fast_fallback_within_reasonable_factor(
287 text in "[a-zA-Z0-9 .,;:!?]{8,256}",
288 model in arb_known_model(),
289 ) {
290 let tc = TokenCounter::new();
291 let exact = tc.count(&text, &model) as f64;
292 let fast = TokenCounter::count_fast(&text) as f64;
293 prop_assert!(exact > 0.0);
295 prop_assert!(fast > 0.0);
296 let ratio = exact / fast;
297 prop_assert!(
298 ratio >= 0.1 && ratio <= 10.0,
299 "exact/fast ratio ({:.2}) out of reasonable range [0.1, 10.0]; exact={}, fast={}",
300 ratio,
301 exact,
302 fast
303 );
304 }
305 }
306
307 fn tiktoken(text: &str) -> (u32, u32) {
308 let cl = tiktoken_rs::cl100k_base_singleton().lock().encode_with_special_tokens(text).len();
309 let o2 = tiktoken_rs::o200k_base_singleton().lock().encode_with_special_tokens(text).len();
310 (cl as u32, o2 as u32)
311 }
312
313 fn both(text: &str) -> (u32, u32) {
314 let tc = TokenCounter::new();
315 (tc.count(text, &ModelFamily::AnthropicClaude), tc.count(text, &ModelFamily::OpenAiGpt))
316 }
317
318 #[test]
319 fn count_is_exact_when_no_run_is_cut() {
320 let at_limit = |unit: &str| unit.repeat(MAX_RUN_BYTES / unit.len());
321 let texts = [
322 "fn main() { println!(\"hi\"); }\n".repeat(500),
323 format!("{}a{}", at_limit("="), at_limit(" ")),
324 format!("{}={}", at_limit("a"), at_limit("\n")),
325 format!("{}a{}", at_limit("─"), at_limit("/")),
326 "日本語のテキスト、句読点で区切る。".repeat(400),
327 "Привет мир строка номер 1\n".repeat(300),
328 ];
329 for text in &texts {
330 let head = crate::text_boundary::truncate_str(text, 40);
331 assert!(run_cuts(text).is_empty(), "{head:?}");
332 assert_eq!(both(text), tiktoken(text), "{head:?}");
333 }
334 }
335
336 #[test]
337 fn count_survives_a_run_of_a_million_characters() {
338 for unit in ["=", " "] {
339 let text = unit.repeat(1_100_000);
340 let (cl, o2) = both(&text);
341 assert!(cl > 0 && o2 > 0 && cl as usize <= text.len() && o2 as usize <= text.len(), "{unit:?}");
342 }
343 }
344
345 #[test]
346 fn every_class_of_run_is_cut_to_the_limit() {
347 for unit in ["=", " ", "\n", "a", "日", "\u{301}", "=\u{301}", "\n/", "Aa"] {
348 let text = unit.repeat((3 * MAX_RUN_BYTES + 7) / unit.len());
349 let cuts = run_cuts(&text);
350 assert!(cuts.len() >= 3, "{unit:?}: {cuts:?}");
351 let mut start = 0;
352 for end in cuts.into_iter().chain([text.len()]) {
353 assert!(end > start && end - start <= MAX_RUN_BYTES, "{unit:?}: {start}..{end}");
354 start = end;
355 }
356 }
357 }
358
359 #[test]
360 fn count_of_a_cut_run_stays_close() {
361 for unit in ["=", " ", "\n", "a", "ab", "─"] {
362 let text = format!("x {} y\n", unit.repeat(4200 / unit.len()));
363 let (want, got) = (tiktoken(&text), both(&text));
364 for (w, g) in [(want.0, got.0), (want.1, got.1)] {
365 assert!(w.abs_diff(g) <= w / 100 + 4, "{unit:?}: {g} vs {w}");
366 }
367 }
368 }
369
370 #[test]
371 fn count_is_linear_on_long_runs() {
372 let tc = TokenCounter::new();
373 let run = |bytes: usize| "abcdefghijklmnopqrstuvwxyz".repeat(bytes / 26);
374 let (small, large) = (run(8 << 10), run(80 << 10));
375 let ratio = crate::text_search::time_ratio(
376 || {
377 std::hint::black_box(tc.count(&small, &ModelFamily::AnthropicClaude));
378 },
379 || {
380 std::hint::black_box(tc.count(&large, &ModelFamily::AnthropicClaude));
381 },
382 );
383 assert!(ratio < 25.0, "80 KB took {ratio:.1}x as long as 8 KB");
384 }
385
386 #[test]
387 fn performance_10k_tokens_under_1ms() {
388 let tc = TokenCounter::new();
389 let text = "Hello world. This is a test sentence for benchmarking. ".repeat(800);
391
392 let _ = tc.count(&text, &ModelFamily::OpenAiGpt);
394
395 let start = std::time::Instant::now();
396 let _count = tc.count(&text, &ModelFamily::OpenAiGpt);
397 let elapsed = start.elapsed();
398
399 assert!(
402 elapsed.as_millis() < 500,
403 "token counting took {}ms, expected <500ms (debug build)",
404 elapsed.as_millis()
405 );
406 }
407}