Skip to main content

ctx/
tokens.rs

1//! Token counting for LLM context management.
2//!
3//! This module provides token counting functionality using tiktoken-rs,
4//! compatible with OpenAI models (GPT-4, GPT-3.5-turbo, etc.).
5
6use std::path::Path;
7use std::str::FromStr;
8use tiktoken_rs::{cl100k_base, o200k_base, p50k_base, CoreBPE};
9
10/// Result of token counting for a text or file.
11#[derive(Debug, Clone)]
12pub struct TokenCount {
13    /// Number of tokens
14    pub count: usize,
15    /// Encoding used (e.g., "cl100k_base")
16    #[allow(dead_code)] // Part of public API
17    pub encoding: String,
18    /// Original text length in characters (if available)
19    #[allow(dead_code)] // Part of public API
20    pub char_count: Option<usize>,
21}
22
23impl TokenCount {
24    /// Create a new TokenCount.
25    #[allow(dead_code)] // Part of public API
26    pub fn new(count: usize, encoding: &str) -> Self {
27        Self {
28            count,
29            encoding: encoding.to_string(),
30            char_count: None,
31        }
32    }
33
34    /// Create a new TokenCount with character count.
35    pub fn with_char_count(count: usize, encoding: &str, char_count: usize) -> Self {
36        Self {
37            count,
38            encoding: encoding.to_string(),
39            char_count: Some(char_count),
40        }
41    }
42}
43
44/// Supported tokenizer encodings.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum Encoding {
47    /// cl100k_base - GPT-4, GPT-3.5-turbo, text-embedding-ada-002
48    #[default]
49    Cl100kBase,
50    /// o200k_base - GPT-4o, GPT-4.1, o1, o3
51    O200kBase,
52    /// p50k_base - Codex, text-davinci-002/003
53    P50kBase,
54}
55
56impl Encoding {
57    /// Get the encoding name as a string.
58    pub fn as_str(&self) -> &'static str {
59        match self {
60            Self::Cl100kBase => "cl100k_base",
61            Self::O200kBase => "o200k_base",
62            Self::P50kBase => "p50k_base",
63        }
64    }
65}
66
67impl FromStr for Encoding {
68    type Err = ();
69    fn from_str(s: &str) -> Result<Self, Self::Err> {
70        match s.to_lowercase().as_str() {
71            "cl100k_base" | "cl100k" => Ok(Encoding::Cl100kBase),
72            "o200k_base" | "o200k" => Ok(Encoding::O200kBase),
73            "p50k_base" | "p50k" => Ok(Encoding::P50kBase),
74            _ => Err(()),
75        }
76    }
77}
78
79/// Get the BPE tokenizer for the specified encoding.
80pub fn get_bpe(encoding: Encoding) -> Result<CoreBPE, String> {
81    match encoding {
82        Encoding::Cl100kBase => cl100k_base().map_err(|e| e.to_string()),
83        Encoding::O200kBase => o200k_base().map_err(|e| e.to_string()),
84        Encoding::P50kBase => p50k_base().map_err(|e| e.to_string()),
85    }
86}
87
88/// Count tokens in a text string using the default encoding (cl100k_base).
89#[allow(dead_code)] // Part of public API
90pub fn count_tokens(text: &str) -> Result<usize, String> {
91    count_tokens_with_encoding(text, Encoding::default())
92}
93
94/// Count tokens in a text string using the specified encoding.
95pub fn count_tokens_with_encoding(text: &str, encoding: Encoding) -> Result<usize, String> {
96    let bpe = get_bpe(encoding)?;
97    Ok(bpe.encode_with_special_tokens(text).len())
98}
99
100/// Count tokens with full details.
101pub fn count_tokens_detailed(text: &str, encoding: Encoding) -> Result<TokenCount, String> {
102    let bpe = get_bpe(encoding)?;
103    let count = bpe.encode_with_special_tokens(text).len();
104    Ok(TokenCount::with_char_count(
105        count,
106        encoding.as_str(),
107        text.len(),
108    ))
109}
110
111/// Count tokens in a file.
112pub fn count_file_tokens(path: &Path, encoding: Encoding) -> Result<TokenCount, String> {
113    let content = std::fs::read_to_string(path)
114        .map_err(|e| format!("Failed to read file {}: {}", path.display(), e))?;
115    count_tokens_detailed(&content, encoding)
116}
117
118/// File with its token count.
119#[derive(Debug, Clone)]
120#[allow(dead_code)] // Part of public API
121pub struct FileTokens {
122    /// File path
123    pub path: String,
124    /// Token count
125    pub tokens: usize,
126    /// File size in bytes
127    pub size_bytes: usize,
128}
129
130/// Trait for types that have a token count field.
131///
132/// Used by `select_by_token_budget` to generically select items
133/// that fit within a token budget.
134pub trait HasTokenCount {
135    /// Get the token count for this item.
136    fn token_count(&self) -> usize;
137}
138
139impl HasTokenCount for FileTokens {
140    fn token_count(&self) -> usize {
141        self.tokens
142    }
143}
144
145/// Select items that fit within a token budget (generic version).
146///
147/// Returns items in order of selection (highest priority first) that fit
148/// within the specified token limit. Items are selected greedily - if an item
149/// doesn't fit, it's skipped and the next item is tried.
150///
151/// # Arguments
152/// * `items` - List of items with token counts, in priority order
153/// * `max_tokens` - Maximum total tokens to include
154///
155/// # Returns
156/// * Tuple of (selected items, total tokens used, number of items omitted)
157pub fn select_by_token_budget<T: HasTokenCount>(
158    items: Vec<T>,
159    max_tokens: usize,
160) -> (Vec<T>, usize, usize) {
161    let mut selected = Vec::new();
162    let mut total_tokens = 0;
163    let mut omitted = 0;
164
165    for item in items {
166        if total_tokens + item.token_count() <= max_tokens {
167            total_tokens += item.token_count();
168            selected.push(item);
169        } else {
170            omitted += 1;
171        }
172    }
173
174    (selected, total_tokens, omitted)
175}
176
177/// Select files that fit within a token budget.
178///
179/// Returns files in order of selection (highest priority first) that fit
180/// within the specified token limit. Files are selected greedily - if a file
181/// doesn't fit, it's skipped and the next file is tried.
182///
183/// # Arguments
184/// * `files` - List of files with their token counts, in priority order
185/// * `max_tokens` - Maximum total tokens to include
186///
187/// # Returns
188/// * Tuple of (selected files, total tokens used, number of files omitted)
189#[allow(dead_code)] // Part of public API
190pub fn select_files_by_tokens(
191    files: &[FileTokens],
192    max_tokens: usize,
193) -> (Vec<FileTokens>, usize, usize) {
194    let mut selected = Vec::new();
195    let mut total_tokens = 0;
196    let mut omitted = 0;
197
198    for file in files {
199        if total_tokens + file.tokens <= max_tokens {
200            total_tokens += file.tokens;
201            selected.push(file.clone());
202        } else {
203            omitted += 1;
204        }
205    }
206
207    (selected, total_tokens, omitted)
208}
209
210/// Estimate tokens without loading the tokenizer (fast approximation).
211///
212/// Uses a rough heuristic of ~4 characters per token for English text.
213/// This is useful for quick estimates when exact counts aren't needed.
214#[allow(dead_code)] // Part of public API
215pub fn estimate_tokens(text: &str) -> usize {
216    // Rough approximation: ~4 characters per token for English
217    // This is a conservative estimate (actual tokens are usually fewer)
218    text.len().div_ceil(4)
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_count_tokens_basic() {
227        let text = "Hello, world!";
228        let count = count_tokens(text).unwrap();
229        assert!(count > 0);
230        assert!(count < text.len()); // Tokens should be fewer than characters
231    }
232
233    #[test]
234    fn test_count_tokens_encoding() {
235        let text = "fn main() { println!(\"Hello\"); }";
236
237        let cl100k = count_tokens_with_encoding(text, Encoding::Cl100kBase).unwrap();
238        let o200k = count_tokens_with_encoding(text, Encoding::O200kBase).unwrap();
239
240        // Both should give reasonable counts
241        assert!(cl100k > 0 && cl100k < 50);
242        assert!(o200k > 0 && o200k < 50);
243    }
244
245    #[test]
246    fn test_count_tokens_detailed() {
247        let text = "Hello, world!";
248        let result = count_tokens_detailed(text, Encoding::Cl100kBase).unwrap();
249
250        assert!(result.count > 0);
251        assert_eq!(result.encoding, "cl100k_base");
252        assert_eq!(result.char_count, Some(text.len()));
253    }
254
255    #[test]
256    fn test_select_files_by_tokens() {
257        let files = vec![
258            FileTokens {
259                path: "a.rs".to_string(),
260                tokens: 100,
261                size_bytes: 400,
262            },
263            FileTokens {
264                path: "b.rs".to_string(),
265                tokens: 200,
266                size_bytes: 800,
267            },
268            FileTokens {
269                path: "c.rs".to_string(),
270                tokens: 150,
271                size_bytes: 600,
272            },
273        ];
274
275        // All fit
276        let (selected, total, omitted) = select_files_by_tokens(&files, 500);
277        assert_eq!(selected.len(), 3);
278        assert_eq!(total, 450);
279        assert_eq!(omitted, 0);
280
281        // Only first two fit
282        let (selected, total, omitted) = select_files_by_tokens(&files, 300);
283        assert_eq!(selected.len(), 2);
284        assert_eq!(total, 300);
285        assert_eq!(omitted, 1);
286
287        // Only first fits
288        let (selected, total, omitted) = select_files_by_tokens(&files, 150);
289        assert_eq!(selected.len(), 1);
290        assert_eq!(total, 100);
291        assert_eq!(omitted, 2);
292    }
293
294    #[test]
295    fn test_encoding_from_str() {
296        use std::str::FromStr;
297        assert_eq!(
298            Encoding::from_str("cl100k_base").ok(),
299            Some(Encoding::Cl100kBase)
300        );
301        assert_eq!(
302            Encoding::from_str("o200k_base").ok(),
303            Some(Encoding::O200kBase)
304        );
305        assert_eq!(
306            Encoding::from_str("p50k_base").ok(),
307            Some(Encoding::P50kBase)
308        );
309        assert_eq!(Encoding::from_str("unknown").ok(), None);
310    }
311
312    #[test]
313    fn test_estimate_tokens() {
314        let text = "Hello, world!"; // 13 chars
315        let estimate = estimate_tokens(text);
316        // Should be roughly 3-4 tokens based on ~4 chars per token
317        assert!((3..=5).contains(&estimate));
318    }
319}