1use std::sync::OnceLock;
11
12use bamboo_domain::Message;
13use tiktoken_rs::o200k_base;
14use tiktoken_rs::CoreBPE;
15
16static O200K_ENCODER: OnceLock<Option<CoreBPE>> = OnceLock::new();
23
24fn o200k_encoder() -> Option<&'static CoreBPE> {
30 O200K_ENCODER
31 .get_or_init(|| match o200k_base() {
32 Ok(encoder) => Some(encoder),
33 Err(err) => {
34 tracing::warn!(
35 error = %err,
36 "failed to load bundled o200k_base tokenizer; \
37 falling back to heuristic token counting"
38 );
39 None
40 }
41 })
42 .as_ref()
43}
44
45pub trait TokenCounter: Send + Sync {
47 fn count_message(&self, message: &Message) -> u32;
49
50 fn count_messages(&self, messages: &[Message]) -> u32 {
52 messages.iter().map(|m| self.count_message(m)).sum()
53 }
54
55 fn count_text(&self, text: &str) -> u32;
57}
58
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
64pub struct ProviderVisibleToolEstimate {
65 pub input_tokens: u32,
67 pub serialized_bytes: usize,
69 pub serialized_chars: usize,
71}
72
73#[derive(Debug, Clone)]
80pub struct HeuristicTokenCounter {
81 chars_per_token: f64,
83 safety_margin: f64,
85 metadata_overhead: u32,
87}
88
89impl HeuristicTokenCounter {
90 pub fn new(chars_per_token: f64, safety_margin: f64, metadata_overhead: u32) -> Self {
92 Self {
93 chars_per_token,
94 safety_margin,
95 metadata_overhead,
96 }
97 }
98
99 pub fn with_defaults() -> Self {
101 Self {
102 chars_per_token: 4.0,
103 safety_margin: 1.1,
104 metadata_overhead: 10,
105 }
106 }
107}
108
109impl Default for HeuristicTokenCounter {
110 fn default() -> Self {
111 Self::with_defaults()
112 }
113}
114
115impl TokenCounter for HeuristicTokenCounter {
116 fn count_message(&self, message: &Message) -> u32 {
117 let content_tokens = self.count_text(&message.content);
118
119 let tool_calls_tokens = message
121 .tool_calls
122 .as_ref()
123 .map(|tc| {
124 tc.iter()
125 .map(|c| {
126 let args_tokens = self.count_text(&c.function.arguments);
128 let id_tokens = self.count_text(&c.id);
129 let name_tokens = self.count_text(&c.function.name);
130 args_tokens
132 .saturating_add(id_tokens)
133 .saturating_add(name_tokens)
134 .saturating_add(5) })
136 .fold(0u32, |acc, x| acc.saturating_add(x))
137 })
138 .unwrap_or(0);
139
140 let tool_call_id_tokens = message
142 .tool_call_id
143 .as_ref()
144 .map(|id| self.count_text(id).saturating_add(3)) .unwrap_or(0);
146
147 content_tokens
149 .saturating_add(tool_calls_tokens)
150 .saturating_add(tool_call_id_tokens)
151 .saturating_add(self.metadata_overhead)
152 }
153
154 fn count_text(&self, text: &str) -> u32 {
155 if text.is_empty() {
156 return 0;
157 }
158
159 let char_count = text.chars().count() as f64;
160 let base_tokens = char_count / self.chars_per_token;
161 let adjusted_tokens = base_tokens * self.safety_margin;
162
163 adjusted_tokens.ceil() as u32
164 }
165}
166
167#[derive(Debug)]
172pub struct TiktokenTokenCounter {
173 metadata_overhead: u32,
175}
176
177impl TiktokenTokenCounter {
178 pub fn new(metadata_overhead: u32) -> Self {
180 Self { metadata_overhead }
181 }
182
183 pub fn estimate_provider_visible_tool_segments<'a>(
196 &self,
197 segments: impl IntoIterator<Item = &'a str>,
198 ) -> ProviderVisibleToolEstimate {
199 let mut estimate = ProviderVisibleToolEstimate::default();
200 for segment in segments {
201 estimate.input_tokens = estimate
202 .input_tokens
203 .saturating_add(self.count_text(segment));
204 estimate.serialized_bytes = estimate.serialized_bytes.saturating_add(segment.len());
205 estimate.serialized_chars = estimate
206 .serialized_chars
207 .saturating_add(segment.chars().count());
208 }
209 estimate
210 }
211
212 pub fn truncate_to_token_prefix(&self, text: &str, max_tokens: u32) -> String {
228 if max_tokens == 0 {
229 return String::new();
230 }
231 let Some(encoder) = o200k_encoder() else {
232 return heuristic_char_prefix(text, max_tokens);
233 };
234 let tokens = encoder.encode_with_special_tokens(text);
237 if (tokens.len() as u32) <= max_tokens {
238 return text.to_string();
239 }
240 let end = max_tokens as usize;
241 match encoder.decode_bytes(&tokens[..end]) {
242 Ok(bytes) => valid_utf8_prefix(bytes),
247 Err(_) => heuristic_char_prefix(text, max_tokens),
248 }
249 }
250
251 pub fn truncate_to_token_suffix(&self, text: &str, max_tokens: u32) -> String {
258 if max_tokens == 0 {
259 return String::new();
260 }
261 let Some(encoder) = o200k_encoder() else {
262 return heuristic_char_suffix(text, max_tokens);
263 };
264 let tokens = encoder.encode_with_special_tokens(text);
265 if (tokens.len() as u32) <= max_tokens {
266 return text.to_string();
267 }
268 let start = tokens.len() - (max_tokens as usize);
269 match encoder.decode_bytes(&tokens[start..]) {
270 Ok(bytes) => valid_utf8_suffix(bytes),
275 Err(_) => heuristic_char_suffix(text, max_tokens),
276 }
277 }
278}
279
280fn heuristic_char_prefix(text: &str, max_tokens: u32) -> String {
288 text.chars()
289 .take(heuristic_char_budget(max_tokens))
290 .collect()
291}
292
293fn heuristic_char_suffix(text: &str, max_tokens: u32) -> String {
295 let max_chars = heuristic_char_budget(max_tokens);
296 let skip = text.chars().count().saturating_sub(max_chars);
297 text.chars().skip(skip).collect()
298}
299
300fn heuristic_char_budget(max_tokens: u32) -> usize {
303 ((max_tokens as f64) * 4.0 / 1.1).floor() as usize
304}
305
306fn valid_utf8_prefix(bytes: Vec<u8>) -> String {
310 let valid_up_to = match std::str::from_utf8(&bytes) {
311 Ok(_) => bytes.len(),
312 Err(e) => e.valid_up_to(),
313 };
314 String::from_utf8_lossy(&bytes[..valid_up_to]).into_owned()
316}
317
318fn valid_utf8_suffix(bytes: Vec<u8>) -> String {
324 let mut start = 0;
325 while start < bytes.len() {
326 if std::str::from_utf8(&bytes[start..]).is_ok() {
327 return String::from_utf8_lossy(&bytes[start..]).into_owned();
328 }
329 start += 1;
330 }
331 String::new()
332}
333
334impl Default for TiktokenTokenCounter {
335 fn default() -> Self {
336 Self {
337 metadata_overhead: 10,
338 }
339 }
340}
341
342impl TokenCounter for TiktokenTokenCounter {
343 fn count_message(&self, message: &Message) -> u32 {
344 let content_tokens = self.count_text(&message.content);
345
346 let tool_calls_tokens = message
347 .tool_calls
348 .as_ref()
349 .map(|tc| {
350 tc.iter()
351 .map(|c| {
352 let args_tokens = self.count_text(&c.function.arguments);
353 let id_tokens = self.count_text(&c.id);
354 let name_tokens = self.count_text(&c.function.name);
355 args_tokens
356 .saturating_add(id_tokens)
357 .saturating_add(name_tokens)
358 .saturating_add(5)
359 })
360 .fold(0u32, |acc, x| acc.saturating_add(x))
361 })
362 .unwrap_or(0);
363
364 let tool_call_id_tokens = message
365 .tool_call_id
366 .as_ref()
367 .map(|id| self.count_text(id).saturating_add(3))
368 .unwrap_or(0);
369
370 content_tokens
371 .saturating_add(tool_calls_tokens)
372 .saturating_add(tool_call_id_tokens)
373 .saturating_add(self.metadata_overhead)
374 }
375
376 fn count_text(&self, text: &str) -> u32 {
377 if text.is_empty() {
378 return 0;
379 }
380 match o200k_encoder() {
381 Some(encoder) => encoder.encode_with_special_tokens(text).len() as u32,
383 None => HeuristicTokenCounter::default().count_text(text),
386 }
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use bamboo_domain::{FunctionCall, ToolCall};
394
395 #[test]
396 fn heuristic_counter_counts_text() {
397 let counter = HeuristicTokenCounter::default();
398
399 let tokens = counter.count_text("Hello, world!");
401 assert!(
402 (3..=5).contains(&tokens),
403 "Expected ~4 tokens, got {}",
404 tokens
405 );
406 }
407
408 #[test]
409 fn heuristic_counter_counts_empty_text() {
410 let counter = HeuristicTokenCounter::default();
411 assert_eq!(counter.count_text(""), 0);
412 }
413
414 #[test]
415 fn heuristic_counter_counts_user_message() {
416 let counter = HeuristicTokenCounter::default();
417 let message = Message::user("Hello, world!");
418
419 let tokens = counter.count_message(&message);
420 assert!(
422 tokens >= 10,
423 "Expected at least 10 tokens (content + metadata), got {}",
424 tokens
425 );
426 }
427
428 #[test]
429 fn heuristic_counter_counts_tool_calls() {
430 let counter = HeuristicTokenCounter::default();
431
432 let tool_call = ToolCall {
433 id: "call_123".to_string(),
434 tool_type: "function".to_string(),
435 function: FunctionCall {
436 name: "search".to_string(),
437 arguments: r#"{"query":"test"}"#.to_string(),
438 },
439 };
440
441 let message = Message::assistant("Let me search", Some(vec![tool_call]));
442
443 let tokens = counter.count_message(&message);
444 assert!(tokens >= 15, "Expected at least 15 tokens, got {}", tokens);
446 }
447
448 #[test]
449 fn heuristic_counter_counts_tool_result() {
450 let counter = HeuristicTokenCounter::default();
451 let message = Message::tool_result("call_123", "Search results here");
452
453 let tokens = counter.count_message(&message);
454 assert!(tokens >= 15, "Expected at least 15 tokens, got {}", tokens);
456 }
457
458 #[test]
459 fn heuristic_counter_counts_multiple_messages() {
460 let counter = HeuristicTokenCounter::default();
461 let messages = vec![
462 Message::system("You are helpful"),
463 Message::user("Hello"),
464 Message::assistant("Hi there", None),
465 ];
466
467 let total = counter.count_messages(&messages);
468 let sum: u32 = messages.iter().map(|m| counter.count_message(m)).sum();
469
470 assert_eq!(total, sum);
471 }
472
473 #[test]
474 fn custom_chars_per_token() {
475 let counter = HeuristicTokenCounter::new(2.0, 1.0, 0);
476 let tokens = counter.count_text("test");
478 assert_eq!(tokens, 2);
479 }
480
481 #[test]
482 fn safety_margin_applied() {
483 let counter_no_margin = HeuristicTokenCounter::new(4.0, 1.0, 0);
484 let counter_with_margin = HeuristicTokenCounter::new(4.0, 1.1, 0);
485
486 let text = "Hello world!"; let base = counter_no_margin.count_text(text);
488 let adjusted = counter_with_margin.count_text(text);
489
490 assert!(adjusted > base, "Safety margin should increase token count");
491 }
492
493 #[test]
496 fn tiktoken_counter_counts_text() {
497 let counter = TiktokenTokenCounter::default();
498 let tokens = counter.count_text("Hello, world!");
499 assert!(
501 (3..=6).contains(&tokens),
502 "Expected ~4 tokens, got {}",
503 tokens
504 );
505 }
506
507 #[test]
508 fn tiktoken_counter_counts_empty_text() {
509 let counter = TiktokenTokenCounter::default();
510 assert_eq!(counter.count_text(""), 0);
511 }
512
513 #[test]
514 fn provider_visible_tool_estimate_is_zero_for_no_segments() {
515 let counter = TiktokenTokenCounter::default();
516 assert_eq!(
517 counter.estimate_provider_visible_tool_segments(std::iter::empty()),
518 ProviderVisibleToolEstimate::default()
519 );
520 }
521
522 #[test]
523 fn provider_visible_tool_estimate_counts_each_ordered_position_once() {
524 let counter = TiktokenTokenCounter::default();
525 let segments = [
526 r#"{"type":"function","function":{"#.to_string(),
527 r#""name":"lookup","description":"café 工具","#.to_string(),
528 r#""parameters":{"type":"object"}}}"#.to_string(),
529 ];
530 let estimate =
531 counter.estimate_provider_visible_tool_segments(segments.iter().map(String::as_str));
532 let expected_tokens = segments.iter().fold(0u32, |total, segment| {
533 total.saturating_add(counter.count_text(segment))
534 });
535 let expected_bytes = segments.iter().map(String::len).sum::<usize>();
536 let expected_chars = segments
537 .iter()
538 .map(|segment| segment.chars().count())
539 .sum::<usize>();
540
541 assert_eq!(estimate.input_tokens, expected_tokens);
542 assert_eq!(estimate.serialized_bytes, expected_bytes);
543 assert_eq!(estimate.serialized_chars, expected_chars);
544 }
545
546 #[test]
547 fn provider_visible_tool_estimate_keeps_bpe_boundaries_between_positions() {
548 let counter = TiktokenTokenCounter::default();
549 let segments = ["a".to_string(), "b".to_string()];
550
551 let estimate =
552 counter.estimate_provider_visible_tool_segments(segments.iter().map(String::as_str));
553 let separate = counter
554 .count_text(&segments[0])
555 .saturating_add(counter.count_text(&segments[1]));
556
557 assert_eq!(estimate.input_tokens, separate);
558 assert_ne!(estimate.input_tokens, counter.count_text("ab"));
559 }
560
561 #[test]
562 fn larger_provider_visible_tool_segment_increases_every_measure() {
563 let counter = TiktokenTokenCounter::default();
564 let small = [r#"{"parameters":{}}"#.to_string()];
565 let large = [format!(
566 r#"{{"parameters":{{"description":"{}"}}}}"#,
567 "provider visible parameter ".repeat(128)
568 )];
569
570 let small =
571 counter.estimate_provider_visible_tool_segments(small.iter().map(String::as_str));
572 let large =
573 counter.estimate_provider_visible_tool_segments(large.iter().map(String::as_str));
574
575 assert!(large.input_tokens > small.input_tokens);
576 assert!(large.serialized_bytes > small.serialized_bytes);
577 assert!(large.serialized_chars > small.serialized_chars);
578 }
579
580 #[test]
581 fn tiktoken_counter_counts_cjk() {
582 let counter = TiktokenTokenCounter::default();
583 let tokens = counter.count_text("你好世界");
585 assert!(
586 (2..=8).contains(&tokens),
587 "Expected 2-8 tokens, got {}",
588 tokens
589 );
590 }
591
592 #[test]
593 fn tiktoken_counter_counts_user_message() {
594 let counter = TiktokenTokenCounter::default();
595 let message = Message::user("Hello, world!");
596 let tokens = counter.count_message(&message);
597 assert!(tokens >= 10, "Expected at least 10 tokens, got {}", tokens);
599 }
600
601 #[test]
602 fn tiktoken_counter_counts_tool_calls() {
603 let counter = TiktokenTokenCounter::default();
604 let tool_call = ToolCall {
605 id: "call_123".to_string(),
606 tool_type: "function".to_string(),
607 function: FunctionCall {
608 name: "search".to_string(),
609 arguments: r#"{"query":"test"}"#.to_string(),
610 },
611 };
612 let message = Message::assistant("Let me search", Some(vec![tool_call]));
613 let tokens = counter.count_message(&message);
614 assert!(tokens >= 15, "Expected at least 15 tokens, got {}", tokens);
615 }
616
617 #[test]
618 fn tiktoken_counter_more_accurate_than_heuristic() {
619 let heuristic = HeuristicTokenCounter::default();
620 let tiktoken = TiktokenTokenCounter::default();
621
622 let text = "The quick brown fox jumps over the lazy dog.";
623 let h_tokens = heuristic.count_text(text);
624 let t_tokens = tiktoken.count_text(text);
625
626 assert!(h_tokens > 0 && t_tokens > 0);
628 }
629
630 #[test]
631 fn bundled_o200k_encoder_loads_successfully() {
632 assert!(
637 o200k_base().is_ok(),
638 "bundled o200k_base tokenizer failed to load; \
639 suspected tiktoken-rs build/link regression"
640 );
641 }
642
643 #[test]
646 fn truncate_prefix_keeps_start_and_stays_within_budget() {
647 let counter = TiktokenTokenCounter::default();
648 let text = "The quick brown fox jumps over the lazy dog. ".repeat(50);
649 assert!(counter.count_text(&text) > 30);
651
652 let max_tokens = 30u32;
653 let prefix = counter.truncate_to_token_prefix(&text, max_tokens);
654
655 assert!(
657 text.starts_with(&prefix),
658 "prefix must be the START of text"
659 );
660 assert!(
661 !prefix.is_empty(),
662 "prefix should not be empty under budget"
663 );
664 let count = counter.count_text(&prefix);
666 assert!(
667 count <= max_tokens,
668 "prefix token count {count} exceeds budget {max_tokens}"
669 );
670 }
671
672 #[test]
673 fn truncate_suffix_keeps_end_and_stays_within_budget() {
674 let counter = TiktokenTokenCounter::default();
675 let text = "The quick brown fox jumps over the lazy dog. ".repeat(50);
676 assert!(counter.count_text(&text) > 30);
677
678 let max_tokens = 30u32;
679 let suffix = counter.truncate_to_token_suffix(&text, max_tokens);
680
681 assert!(text.ends_with(&suffix), "suffix must be the END of text");
683 assert!(
684 !suffix.is_empty(),
685 "suffix should not be empty under budget"
686 );
687 let count = counter.count_text(&suffix);
689 assert!(
690 count <= max_tokens,
691 "suffix token count {count} exceeds budget {max_tokens}"
692 );
693 }
694
695 #[test]
696 fn truncate_returns_text_unchanged_when_within_budget() {
697 let counter = TiktokenTokenCounter::default();
698 let text = "Hello, world!"; assert!(counter.count_text(text) <= 1000);
700
701 assert_eq!(counter.truncate_to_token_prefix(text, 1000), text);
702 assert_eq!(counter.truncate_to_token_suffix(text, 1000), text);
703 }
704
705 #[test]
706 fn truncate_max_tokens_zero_returns_empty() {
707 let counter = TiktokenTokenCounter::default();
708 assert_eq!(counter.truncate_to_token_prefix("Hello, world!", 0), "");
710 assert_eq!(counter.truncate_to_token_suffix("Hello, world!", 0), "");
711 }
712
713 #[test]
714 fn truncate_prefix_suffix_large_input_is_valid_and_within_budget() {
715 let counter = TiktokenTokenCounter::default();
718 let unit = "The quick brown fox 你好世界 jumps 1234567890 over.\n";
719 let text = unit.repeat(2_500);
720 assert!(text.len() > 100_000, "precondition: large input");
721 assert!(counter.count_text(&text) > 500);
722
723 let max_tokens = 500u32;
724
725 let prefix = counter.truncate_to_token_prefix(&text, max_tokens);
726 assert!(
727 text.starts_with(&prefix),
728 "prefix must be the START of text"
729 );
730 let pcount = counter.count_text(&prefix);
731 assert!(
732 pcount <= max_tokens,
733 "prefix token count {pcount} exceeds budget {max_tokens}"
734 );
735
736 let suffix = counter.truncate_to_token_suffix(&text, max_tokens);
737 assert!(text.ends_with(&suffix), "suffix must be the END of text");
738 let scount = counter.count_text(&suffix);
739 assert!(
740 scount <= max_tokens,
741 "suffix token count {scount} exceeds budget {max_tokens}"
742 );
743 }
744}