1pub const SYMBOL_FACTOR: f32 = 0.5;
12
13pub const MESSAGE_OVERHEAD_TOKENS: u32 = 4;
15
16#[derive(Debug, Clone, Copy)]
18pub struct TokenEstimator {
19 cjk_factor: f32,
21 latin_factor: f32,
23}
24
25impl Default for TokenEstimator {
26 fn default() -> Self {
27 Self {
28 cjk_factor: 1.0,
29 latin_factor: 0.25,
30 }
31 }
32}
33
34impl TokenEstimator {
35 pub const fn new(cjk_factor: f32, latin_factor: f32) -> Self {
45 Self {
46 cjk_factor,
47 latin_factor,
48 }
49 }
50
51 pub fn estimate(text: &str) -> usize {
61 DEFAULT_ESTIMATOR.estimate_with_config(text)
62 }
63
64 pub fn estimate_with_config(&self, text: &str) -> usize {
68 let bytes = text.as_bytes();
69
70 if text.is_ascii() {
72 let ascii_ws =
73 count_byte(bytes, b' ') + count_byte(bytes, b'\t') + count_byte(bytes, b'\n');
74 let non_ws = text.len() - ascii_ws;
75 let symbols = bytes.iter().filter(|&&b| b.is_ascii_punctuation()).count();
76 let letters = non_ws - symbols;
77 let tokens = letters as f32 * self.latin_factor
78 + symbols as f32 * SYMBOL_FACTOR
79 + ascii_ws as f32 * 0.5;
80 return tokens.round() as usize;
81 }
82
83 let mut count = 0.0f32;
84
85 let ascii_ws =
87 count_byte(bytes, b' ') + count_byte(bytes, b'\t') + count_byte(bytes, b'\n');
88 count += ascii_ws as f32 * 0.5;
89
90 for ch in text.chars() {
92 if ch.is_ascii_whitespace() || ch.is_ascii_control() {
93 continue;
94 }
95
96 if Self::is_cjk(ch) {
97 count += self.cjk_factor;
98 } else if ch.is_ascii_punctuation() {
99 count += SYMBOL_FACTOR;
101 } else if ch.is_ascii() {
102 count += self.latin_factor;
103 } else {
104 count += 1.0;
106 }
107 }
108
109 count.round() as usize
110 }
111
112 pub fn estimate_text(&self, text: &str) -> usize {
116 self.estimate_with_config(text)
117 }
118
119 #[inline]
121 pub fn is_cjk(ch: char) -> bool {
122 let code = ch as u32;
123 (0x4E00..=0x9FFF).contains(&code)
125 || (0x3400..=0x4DBF).contains(&code)
126 || (0x20000..=0x2A6DF).contains(&code)
127 || (0x3040..=0x309F).contains(&code)
129 || (0x30A0..=0x30FF).contains(&code)
130 || (0xAC00..=0xD7AF).contains(&code)
132 || (0x1100..=0x11FF).contains(&code)
133 || (0x3130..=0x318F).contains(&code)
134 }
135
136 pub fn find_split_point(&self, text: &str, max_tokens: usize) -> usize {
140 let mut bytes = 0;
141 let mut tokens = 0.0f32;
142 let mut last_break = 0; for ch in text.chars() {
145 let ch_bytes = ch.len_utf8();
146 let ch_tokens = if Self::is_cjk(ch) {
147 self.cjk_factor
148 } else if ch.is_ascii_punctuation() {
149 SYMBOL_FACTOR
150 } else if ch.is_ascii() && !ch.is_ascii_whitespace() && !ch.is_ascii_control() {
151 self.latin_factor
152 } else if ch.is_ascii_whitespace() {
153 0.5
154 } else {
155 1.0
156 };
157
158 if tokens + ch_tokens > max_tokens as f32 {
159 return if last_break > 0 { last_break } else { bytes };
161 }
162
163 tokens += ch_tokens;
164 bytes += ch_bytes;
165
166 if ch == '\n' || ch == ' ' {
168 last_break = bytes;
169 }
170 }
171
172 text.len()
173 }
174}
175
176#[inline]
178fn count_byte(bytes: &[u8], target: u8) -> usize {
179 bytes.iter().filter(|&&b| b == target).count()
180}
181
182static DEFAULT_ESTIMATOR: TokenEstimator = TokenEstimator {
184 cjk_factor: 1.0,
185 latin_factor: 0.25,
186};
187
188pub fn estimate_tokens(text: &str) -> usize {
192 TokenEstimator::estimate(text)
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn test_empty_text() {
201 assert_eq!(estimate_tokens(""), 0);
202 }
203
204 #[test]
205 fn test_ascii_text() {
206 let text = "Hello World";
207 let tokens = estimate_tokens(text);
208 assert_eq!(tokens, 3, "tokens: {}", tokens);
210 }
211
212 #[test]
213 fn test_cjk_text() {
214 let text = "你好,世界!";
215 let tokens = estimate_tokens(text);
216 assert_eq!(tokens, 6, "tokens: {}", tokens);
218 }
219
220 #[test]
221 fn test_mixed_text() {
222 let text = "Hello 世界";
223 let tokens = estimate_tokens(text);
224 assert_eq!(tokens, 4, "tokens: {}", tokens);
226 }
227
228 #[test]
229 fn test_whitespace() {
230 let text = "Hello World";
231 let tokens = estimate_tokens(text);
232 assert_eq!(tokens, 4, "tokens: {}", tokens);
234 }
235
236 #[test]
237 fn test_long_ascii() {
238 let text = "The quick brown fox jumps over the lazy dog";
239 let tokens = estimate_tokens(text);
240 assert!((10..=16).contains(&tokens), "tokens: {}", tokens);
242 }
243
244 #[test]
245 fn test_japanese() {
246 let text = "こんにちは世界";
247 let tokens = estimate_tokens(text);
248 assert_eq!(tokens, 7, "tokens: {}", tokens);
250 }
251
252 #[test]
253 fn test_korean() {
254 let text = "안녕하세요";
255 let tokens = estimate_tokens(text);
256 assert_eq!(tokens, 5, "tokens: {}", tokens);
258 }
259
260 #[test]
261 fn test_custom_estimator() {
262 let estimator = TokenEstimator::new(0.8, 0.3);
264
265 let text = "Hello";
266 let tokens = estimator.estimate_with_config(text);
267 assert_eq!(tokens, 2, "tokens: {}", tokens);
269
270 let text = "How are you?";
271 let tokens = estimator.estimate_with_config(text);
272 assert_eq!(tokens, 4, "tokens: {}", tokens);
274 }
275
276 #[test]
277 fn test_custom_estimator_ascii_fast_path() {
278 let estimator = TokenEstimator::new(1.0, 0.2); let text = "ABCDEFGHIJ"; let tokens = estimator.estimate_with_config(text);
283 assert_eq!(tokens, 2, "tokens: {}", tokens);
285 }
286
287 #[test]
288 fn test_default_estimator_consistency() {
289 let text = "Hello World 123";
291 let estimator = TokenEstimator::default();
292 assert_eq!(estimator.estimate_text(text), estimate_tokens(text));
293 }
294
295 #[test]
296 fn test_ascii_symbols_counted_separately() {
297 let text = "fn foo() -> u32";
298 let tokens = estimate_tokens(text);
299 assert_eq!(tokens, 6, "tokens: {}", tokens);
304 }
305
306 #[test]
307 fn test_symbol_estimation_matches_find_split_point() {
308 let text = "map(|x| x + 1) -> Result<(), Error>";
310 let estimator = TokenEstimator::default();
311 let total = estimator.estimate_text(text);
312 let split = estimator.find_split_point(text, total);
313 assert!(
316 estimator.estimate_text(&text[..split]) <= total,
317 "split text ({}) must fit within budget {}",
318 estimator.estimate_text(&text[..split]),
319 total
320 );
321 assert!(split > 0, "split point must be non-empty");
322 }
323
324 #[test]
325 fn test_find_split_point() {
326 let text = "This is a test string that is longer than the limit.";
327 let split = TokenEstimator::default().find_split_point(text, 5);
328 assert!(split < text.len());
329 assert!(split > 0);
330 }
331
332 #[test]
333 fn test_find_split_point_cjk() {
334 let text = "这是一个用于测试分段的较长的中文字符串内容";
335 let split = TokenEstimator::default().find_split_point(text, 8);
336 assert!(split < text.len());
337 assert!(split > 0);
338 assert!(
339 TokenEstimator::default().estimate_text(&text[..split]) <= 8,
340 "split text must fit within budget"
341 );
342 }
343}