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 byte_truncate = || {
118 let end = text.floor_char_boundary(max_tokens.saturating_mul(CHARS_PER_TOKEN));
119 let mut result = text[..end].to_string();
120 result.push_str("...");
121 result
122 };
123 let Some(bpe) = bpe() else {
124 return byte_truncate();
125 };
126 let tokens = bpe.encode_with_special_tokens(text);
127 if tokens.len() <= max_tokens {
128 return text.to_string();
129 }
130 bpe.decode_to_string(&tokens[..max_tokens]).unwrap_or_else(|_| byte_truncate())
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn empty_string_returns_zero() {
139 assert_eq!(estimate_tokens(""), 0);
140 assert_eq!(truncate_to_tokens("", 10), "");
141 }
142
143 #[test]
144 fn count_is_reasonable() {
145 let count = estimate_tokens("Hello, how are you today?");
146 assert!((4..=12).contains(&count), "count={count}");
147 }
148
149 #[test]
150 fn heuristic_byte_len_rounds_up_at_boundaries() {
151 assert_eq!(heuristic_tokens_from_byte_len(0), 0);
152 assert_eq!(heuristic_tokens_from_byte_len(1), 1);
153 assert_eq!(heuristic_tokens_from_byte_len(3), 1);
154 assert_eq!(heuristic_tokens_from_byte_len(4), 1);
155 assert_eq!(heuristic_tokens_from_byte_len(5), 2);
156 assert_eq!(heuristic_tokens_from_byte_len(8), 2);
157 assert_eq!(heuristic_tokens_from_byte_len(9), 3);
158 }
159
160 #[test]
161 fn heuristic_u64_twin_matches_usize_flavor() {
162 for len in [0usize, 1, 3, 4, 5, 1_000, 1_000_003] {
163 assert_eq!(
164 heuristic_tokens_from_byte_len_u64(len as u64),
165 heuristic_tokens_from_byte_len(len) as u64,
166 "len={len}"
167 );
168 }
169 assert_eq!(heuristic_tokens_from_byte_len_u64(u64::MAX), u64::MAX.div_ceil(4));
171 }
172
173 #[test]
174 fn bpe_fallback_heuristic_delegates_to_byte_len_helper() {
175 for text in ["", "a", "abcd", "abcde", "Hello, world!", "\u{1f44b}\u{1f30d}"] {
176 assert_eq!(heuristic_token_count(text), heuristic_tokens_from_byte_len(text.len()), "text={text:?}");
177 }
178 }
179
180 #[test]
181 fn truncate_respects_limit() {
182 let text = "the quick brown fox jumps over the lazy dog";
183 let truncated = truncate_to_tokens(text, 5);
184 let count = estimate_tokens(&truncated);
185 assert!(count <= 5 + 1, "count={count} should be <= 6");
186 }
187
188 #[test]
189 fn truncate_zero_returns_empty() {
190 assert_eq!(truncate_to_tokens("hello", 0), "");
191 }
192
193 #[test]
194 fn code_and_prose_tokenize() {
195 let code = "fn main() { println!(\"hello\"); }";
196 let prose = "the main function prints hello to console";
197 assert!(estimate_tokens(code) > 0);
198 assert!(estimate_tokens(prose) > 0);
199 }
200
201 #[test]
202 fn json_tokenizes() {
203 let json = r#"{"name":"test","value":123,"nested":{"key":"value"}}"#;
204 let count = estimate_tokens(json);
205 assert!((10..=40).contains(&count), "json count={count}");
206 }
207
208 #[test]
209 fn token_count_format_is_compact_and_rounds_across_suffix_boundaries() {
210 assert_eq!(format_token_count(999), "999");
211 assert_eq!(format_token_count(1_000), "1k");
212 assert_eq!(format_token_count(1_050), "1.1k");
213 assert_eq!(format_token_count(6_100), "6.1k");
214 assert_eq!(format_token_count(999_500), "1M");
215 assert_eq!(format_token_count(15_600_000), "15.6M");
216 assert_eq!(format_token_count(999_500_000), "1B");
217 assert_eq!(format_token_count(1_550_000_000), "1.6B");
218 assert_eq!(format_token_count(1_549_999_999), "1.5B");
219 }
220}