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
30pub const CHARS_PER_TOKEN: usize = 4;
38
39#[must_use]
45pub fn heuristic_tokens_from_byte_len(byte_len: usize) -> usize {
46 byte_len.div_ceil(CHARS_PER_TOKEN)
47}
48
49#[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
56fn heuristic_token_count(text: &str) -> usize {
61 heuristic_tokens_from_byte_len(text.len())
62}
63
64pub 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
78pub 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 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
107pub 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 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}