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 = (max_tokens * 4).min(text.len());
90 let mut end = end;
91 while end > 0 && !text.is_char_boundary(end) {
92 end -= 1;
93 }
94 let mut result = text[..end].to_string();
95 result.push_str("...");
96 result
97 };
98 let Some(bpe) = bpe() else {
99 return byte_truncate();
100 };
101 let tokens = bpe.encode_with_special_tokens(text);
102 if tokens.len() <= max_tokens {
103 return text.to_string();
104 }
105 bpe.decode_to_string(&tokens[..max_tokens]).unwrap_or_else(|_| byte_truncate())
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn empty_string_returns_zero() {
114 assert_eq!(estimate_tokens(""), 0);
115 assert_eq!(truncate_to_tokens("", 10), "");
116 }
117
118 #[test]
119 fn count_is_reasonable() {
120 let count = estimate_tokens("Hello, how are you today?");
121 assert!((4..=12).contains(&count), "count={count}");
122 }
123
124 #[test]
125 fn truncate_respects_limit() {
126 let text = "the quick brown fox jumps over the lazy dog";
127 let truncated = truncate_to_tokens(text, 5);
128 let count = estimate_tokens(&truncated);
129 assert!(count <= 5 + 1, "count={count} should be <= 6");
130 }
131
132 #[test]
133 fn truncate_zero_returns_empty() {
134 assert_eq!(truncate_to_tokens("hello", 0), "");
135 }
136
137 #[test]
138 fn code_and_prose_tokenize() {
139 let code = "fn main() { println!(\"hello\"); }";
140 let prose = "the main function prints hello to console";
141 assert!(estimate_tokens(code) > 0);
142 assert!(estimate_tokens(prose) > 0);
143 }
144
145 #[test]
146 fn json_tokenizes() {
147 let json = r#"{"name":"test","value":123,"nested":{"key":"value"}}"#;
148 let count = estimate_tokens(json);
149 assert!((10..=40).contains(&count), "json count={count}");
150 }
151
152 #[test]
153 fn token_count_format_is_compact_and_rounds_across_suffix_boundaries() {
154 assert_eq!(format_token_count(999), "999");
155 assert_eq!(format_token_count(1_000), "1k");
156 assert_eq!(format_token_count(1_050), "1.1k");
157 assert_eq!(format_token_count(6_100), "6.1k");
158 assert_eq!(format_token_count(999_500), "1M");
159 assert_eq!(format_token_count(15_600_000), "15.6M");
160 assert_eq!(format_token_count(999_500_000), "1B");
161 assert_eq!(format_token_count(1_550_000_000), "1.6B");
162 assert_eq!(format_token_count(1_549_999_999), "1.5B");
163 }
164}