Skip to main content

cascade_agent/memory/
tokenizer.rs

1use llm_cascade::Message;
2use tokenizers::Tokenizer;
3
4use crate::error::Result;
5
6/// Token counter backed by a HuggingFace `tokenizers` tokenizer.
7///
8/// Provides accurate token counting for LLM context window management.
9/// Falls back to a character-based heuristic if tokenization fails.
10pub struct TokenCounter {
11    tokenizer: Option<Tokenizer>,
12    model_identifier: String,
13}
14
15impl TokenCounter {
16    /// Load a HuggingFace tokenizer by model identifier or local file path.
17    ///
18    /// Accepts:
19    /// - HuggingFace model names like `"Xenova/gpt-4o"` (fetched via the Hub)
20    /// - Local file paths to a tokenizer.json file
21    ///
22    /// If loading fails (e.g., no network for Hub model, missing file), the counter
23    /// still works using a character-based estimate fallback.
24    pub fn new(model_identifier: &str) -> Result<Self> {
25        let tokenizer = match Tokenizer::from_pretrained(model_identifier, None) {
26            Ok(t) => {
27                tracing::info!("Loaded tokenizer: {}", model_identifier);
28                Some(t)
29            }
30            Err(e) => {
31                tracing::warn!(
32                    "Failed to load tokenizer '{}': {}. Will use char-based estimate fallback.",
33                    model_identifier,
34                    e
35                );
36                None
37            }
38        };
39
40        Ok(Self {
41            tokenizer,
42            model_identifier: model_identifier.to_string(),
43        })
44    }
45
46    /// Count the number of tokens in a text string.
47    ///
48    /// If the tokenizer is available, encodes the text and returns the token count.
49    /// Falls back to `text.len() / 4` as a rough estimate.
50    pub fn count_text(&self, text: &str) -> usize {
51        if let Some(ref tok) = self.tokenizer {
52            match tok.encode(text, false) {
53                Ok(encoding) => encoding.len(),
54                Err(e) => {
55                    tracing::debug!("Tokenization failed, using estimate: {}", e);
56                    estimate_tokens(text)
57                }
58            }
59        } else {
60            estimate_tokens(text)
61        }
62    }
63
64    /// Count tokens for a list of chat messages, including role prefix overhead.
65    ///
66    /// Adds estimated tokens for role prefixes and message separators:
67    /// - `system: ` ≈ 2 tokens
68    /// - `user: ` ≈ 2 tokens
69    /// - `assistant: ` ≈ 3 tokens
70    /// - `tool: ` ≈ 2 tokens
71    /// - Plus 1 separator token per message
72    pub fn count_messages(&self, messages: &[Message]) -> usize {
73        let mut total = 0;
74        for msg in messages {
75            total += self.count_text(&msg.content);
76            total += role_overhead(&msg.role);
77            total += 1; // separator token between messages
78        }
79        // Add 3 tokens for the overall conversation framing (bos, eos, etc.)
80        total += 3;
81        total
82    }
83
84    /// Returns the model identifier this counter was created with.
85    pub fn model_identifier(&self) -> &str {
86        &self.model_identifier
87    }
88}
89
90/// Estimated token overhead for a message role prefix.
91fn role_overhead(role: &llm_cascade::MessageRole) -> usize {
92    match role {
93        llm_cascade::MessageRole::System => 2,    // "system: "
94        llm_cascade::MessageRole::User => 2,      // "user: "
95        llm_cascade::MessageRole::Assistant => 3, // "assistant: "
96        llm_cascade::MessageRole::Tool => 2,      // "tool: "
97    }
98}
99
100/// Rough character-based token estimate (≈4 chars per token for English text).
101fn estimate_tokens(text: &str) -> usize {
102    (text.len() / 4).max(1)
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn test_estimate_tokens() {
111        assert_eq!(estimate_tokens("hello"), 1); // 5 chars / 4 = 1
112        assert_eq!(estimate_tokens("hello world this is a test"), 6); // 27 chars / 4 = 6
113        assert!(estimate_tokens("") >= 1); // empty → max(1) = 1
114    }
115
116    #[test]
117    fn test_count_messages_empty() {
118        // Create a counter that will use fallback (no real tokenizer needed)
119        let counter = TokenCounter {
120            tokenizer: None,
121            model_identifier: "test".into(),
122        };
123        assert_eq!(counter.count_messages(&[]), 3); // just the framing tokens
124    }
125
126    #[test]
127    fn test_count_messages_with_roles() {
128        let counter = TokenCounter {
129            tokenizer: None,
130            model_identifier: "test".into(),
131        };
132        let msgs = vec![
133            Message::system("You are helpful."),
134            Message::user("Hello!"),
135            Message::assistant("Hi there!"),
136        ];
137        let count = counter.count_messages(&msgs);
138        // "You are helpful." = 16/4=4 + 2 (system) + 1 (sep) = 7
139        // "Hello!" = 6/4=1 + 2 (user) + 1 (sep) = 4
140        // "Hi there!" = 9/4=2 + 3 (assistant) + 1 (sep) = 6
141        // + 3 framing = 20
142        assert_eq!(count, 20);
143    }
144}