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
7use std::sync::OnceLock;
18use tiktoken::CoreBpe;
19
20fn 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
30fn heuristic_token_count(text: &str) -> usize {
32 text.len().div_ceil(4)
33}
34
35pub fn estimate_tokens(text: &str) -> usize {
40 if text.is_empty() {
41 return 0;
42 }
43 match bpe() {
44 Some(bpe) => bpe.count(text),
45 None => heuristic_token_count(text),
46 }
47}
48
49pub fn format_token_count(tokens: usize) -> String {
54 let (divisor, suffix) = match tokens {
55 999_500_000.. => (1_000_000_000, "B"),
56 999_500.. => (1_000_000, "M"),
57 1_000.. => (1_000, "k"),
58 _ => return tokens.to_string(),
59 };
60
61 let whole = tokens / divisor;
62 let decimal_step = divisor / 10;
63 let tenths = (tokens % divisor + decimal_step / 2) / decimal_step;
66 let (whole, tenths) = if tenths >= 10 {
67 (whole.saturating_add(1), 0)
68 } else {
69 (whole, tenths)
70 };
71 if tenths == 0 {
72 format!("{whole}{suffix}")
73 } else {
74 format!("{whole}.{tenths}{suffix}")
75 }
76}
77
78pub fn truncate_to_tokens(text: &str, max_tokens: usize) -> String {
84 if max_tokens == 0 || text.is_empty() {
85 return String::new();
86 }
87 let byte_truncate = || {
89 let end = text.floor_char_boundary(max_tokens.saturating_mul(4));
90 let mut result = text[..end].to_string();
91 result.push_str("...");
92 result
93 };
94 let Some(bpe) = bpe() else {
95 return byte_truncate();
96 };
97 let tokens = bpe.encode_with_special_tokens(text);
98 if tokens.len() <= max_tokens {
99 return text.to_string();
100 }
101 bpe.decode_to_string(&tokens[..max_tokens]).unwrap_or_else(|_| byte_truncate())
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn empty_string_returns_zero() {
110 assert_eq!(estimate_tokens(""), 0);
111 assert_eq!(truncate_to_tokens("", 10), "");
112 }
113
114 #[test]
115 fn count_is_reasonable() {
116 let count = estimate_tokens("Hello, how are you today?");
117 assert!((4..=12).contains(&count), "count={count}");
118 }
119
120 #[test]
121 fn truncate_respects_limit() {
122 let text = "the quick brown fox jumps over the lazy dog";
123 let truncated = truncate_to_tokens(text, 5);
124 let count = estimate_tokens(&truncated);
125 assert!(count <= 5 + 1, "count={count} should be <= 6");
126 }
127
128 #[test]
129 fn truncate_zero_returns_empty() {
130 assert_eq!(truncate_to_tokens("hello", 0), "");
131 }
132
133 #[test]
134 fn code_and_prose_tokenize() {
135 let code = "fn main() { println!(\"hello\"); }";
136 let prose = "the main function prints hello to console";
137 assert!(estimate_tokens(code) > 0);
138 assert!(estimate_tokens(prose) > 0);
139 }
140
141 #[test]
142 fn json_tokenizes() {
143 let json = r#"{"name":"test","value":123,"nested":{"key":"value"}}"#;
144 let count = estimate_tokens(json);
145 assert!((10..=40).contains(&count), "json count={count}");
146 }
147
148 #[test]
149 fn token_count_format_is_compact_and_rounds_across_suffix_boundaries() {
150 assert_eq!(format_token_count(999), "999");
151 assert_eq!(format_token_count(1_000), "1k");
152 assert_eq!(format_token_count(1_050), "1.1k");
153 assert_eq!(format_token_count(6_100), "6.1k");
154 assert_eq!(format_token_count(999_500), "1M");
155 assert_eq!(format_token_count(15_600_000), "15.6M");
156 assert_eq!(format_token_count(999_500_000), "1B");
157 assert_eq!(format_token_count(1_550_000_000), "1.6B");
158 assert_eq!(format_token_count(1_549_999_999), "1.5B");
159 }
160}