Skip to main content

selfware/
token_count.rs

1//! Shared token counting utilities.
2//!
3//! Tries to load a Hugging Face tokenizer matching the configured model
4//! family, falls back to `tiktoken-rs` cl100k_base as a generic approximation,
5//! and finally to a conservative heuristic if tokenizer initialization fails.
6//!
7//! A per-content hash cache avoids redundant tokenization for repeated strings.
8//! The cache is capped at a fixed size and cleared entirely when full
9//! (simple eviction that avoids the overhead of an LRU bookkeeping structure).
10
11use once_cell::sync::Lazy;
12use std::collections::HashMap;
13use std::hash::{Hash, Hasher};
14use std::sync::RwLock;
15use tiktoken_rs::{cl100k_base, CoreBPE};
16use tokenizers::Tokenizer;
17use tracing::{debug, warn};
18
19/// Maximum number of cached token counts before the cache is cleared.
20const MAX_CACHE_ENTRIES: usize = 1_000;
21
22/// Model name registered by the CLI entry point once it has loaded the
23/// effective [`Config`](crate::config::Config) (see `set_configured_model`).
24/// The tokenizer must never reload the config file itself: a mid-session
25/// `Config::load(None)` ignored `--config`/`-c` and printed a spurious
26/// `config: <path>` line into the session output (P2-11).
27static CONFIGURED_MODEL: RwLock<Option<String>> = RwLock::new(None);
28
29/// Record the model name from the already-loaded config so the tokenizer can
30/// pick a matching HF tokenizer. Call once, early at startup — the tokenizer
31/// is built lazily on first use, so registration after the first token
32/// count has no effect.
33pub fn set_configured_model(model: &str) {
34    if let Ok(mut slot) = CONFIGURED_MODEL.write() {
35        *slot = Some(model.to_string());
36    }
37}
38
39/// Resolve the model whose tokenizer we should try to match.
40/// Precedence: `SELFWARE_MODEL` env var, then the registered config model.
41fn configured_model_name() -> Option<String> {
42    std::env::var("SELFWARE_MODEL")
43        .ok()
44        .or_else(|| CONFIGURED_MODEL.read().ok().and_then(|slot| slot.clone()))
45}
46
47// Try to load a tokenizer matching the configured model, fall back to tiktoken
48// cl100k_base, and finally to a heuristic.  TokenizerState is Send + Sync
49// (both Tokenizer and CoreBPE are), and count() only requires &self, so no
50// Mutex is needed — Lazy alone provides safe one-time initialization and
51// lock-free concurrent reads.
52static TOKENIZER: Lazy<TokenizerState> =
53    Lazy::new(|| TokenizerState::for_model(configured_model_name().as_deref()));
54
55/// Thread-safe cache mapping content hash (u64) -> token count.
56static TOKEN_CACHE: Lazy<RwLock<HashMap<u64, usize>>> =
57    Lazy::new(|| RwLock::new(HashMap::with_capacity(256)));
58
59enum TokenizerState {
60    /// HuggingFace tokenizer matched to the configured model family.
61    Hf(Box<Tokenizer>),
62    /// tiktoken cl100k_base — a reasonable generic approximation.
63    Tiktoken(CoreBPE),
64    /// Last-resort heuristic when no tokenizer can be loaded.
65    Heuristic,
66}
67
68impl TokenizerState {
69    /// Build a tokenizer state appropriate for the given model name.
70    ///
71    /// If `model` is `None` or we cannot determine a matching HF tokenizer
72    /// repo, we fall back to the tiktoken cl100k_base encoding, which is a
73    /// reasonable generic token-count approximation for most modern LLMs.
74    /// If that also fails, we use a character-based heuristic.
75    ///
76    /// This never panics — every fallthrough path produces a usable state.
77    fn for_model(model: Option<&str>) -> Self {
78        // Try to load a model-specific HF tokenizer when a model name is
79        // provided and looks like a HF repo id (contains '/') or a known
80        // family prefix.
81        if let Some(model) = model {
82            if let Some(repo) = hf_tokenizer_repo(model) {
83                match Tokenizer::from_pretrained(repo, None) {
84                    Ok(tokenizer) => {
85                        debug!("Loaded HF tokenizer '{}' for model '{}'", repo, model);
86                        return TokenizerState::Hf(Box::new(tokenizer));
87                    }
88                    Err(e) => {
89                        debug!(
90                            "Could not load HF tokenizer '{}' for model '{}': {}, \
91                             falling back to tiktoken cl100k",
92                            repo, model, e
93                        );
94                    }
95                }
96            } else {
97                debug!(
98                    "No HF tokenizer repo mapped for model '{}', using cl100k fallback",
99                    model
100                );
101            }
102        }
103
104        // Generic fallback: tiktoken cl100k_base
105        match cl100k_base() {
106            Ok(bpe) => {
107                debug!("Using tiktoken cl100k_base tokenizer as fallback");
108                TokenizerState::Tiktoken(bpe)
109            }
110            Err(e) => {
111                warn!(
112                    "Failed to initialize tiktoken cl100k_base: {}. \
113                     Using heuristic token estimate.",
114                    e
115                );
116                TokenizerState::Heuristic
117            }
118        }
119    }
120
121    fn count(&self, content: &str) -> usize {
122        match self {
123            TokenizerState::Hf(t) => t
124                .encode(content, false)
125                .map(|e| e.get_tokens().len())
126                .unwrap_or_else(|_| heuristic_estimate(content)),
127            TokenizerState::Tiktoken(bpe) => bpe.encode_with_special_tokens(content).len(),
128            TokenizerState::Heuristic => heuristic_estimate(content),
129        }
130    }
131}
132
133/// Map a model name to a Hugging Face tokenizer repo id when the family is
134/// known. Returns `None` for unrecognized models (caller falls back to
135/// cl100k).
136fn hf_tokenizer_repo(model: &str) -> Option<&'static str> {
137    let lower = model.to_ascii_lowercase();
138    if lower.contains("qwen") {
139        // All Qwen2.5 variants share the same tokenizer vocabulary.
140        Some("Qwen/Qwen2.5-Coder-32B")
141    } else if lower.contains("gpt-4") || lower.contains("gpt4") {
142        Some("Xenova/gpt-4")
143    } else if lower.contains("gpt-3.5") || lower.contains("gpt-3") {
144        Some("Xenova/gpt-3.5-turbo")
145    } else if lower.contains("llama") {
146        Some("hf-internal-testing/llama-tokenizer")
147    } else if lower.contains("glm") {
148        // GLM-4/5 family — use the public GLM-4 tokenizer which is
149        // compatible. If this repo is unavailable the caller falls back
150        // to cl100k.
151        Some("THUDM/glm-4-9b-chat")
152    } else if lower.contains("mistral") || lower.contains("mixtral") {
153        Some("mistralai/Mistral-7B-v0.1")
154    } else if lower.contains("deepseek") {
155        Some("deepseek-ai/deepseek-coder-7b-instruct-v1.5")
156    } else {
157        None
158    }
159}
160
161/// Estimate token count for content and add a fixed per-message overhead.
162#[inline]
163pub fn estimate_tokens_with_overhead(content: &str, message_overhead: usize) -> usize {
164    estimate_content_tokens(content) + message_overhead
165}
166
167/// Default token estimate per image when dimensions are unknown.
168/// Based on a 1024×1024 high-detail image: 4 tiles × 170 + 85 = 765.
169pub const DEFAULT_IMAGE_TOKEN_ESTIMATE: usize = 765;
170
171/// Estimate tokens for a string (never returns 0).
172pub fn estimate_tokens(text: &str) -> usize {
173    estimate_content_tokens(text).max(1)
174}
175
176/// Estimate tokens for a list of messages
177pub fn estimate_messages_tokens(messages: &[crate::api::types::Message]) -> usize {
178    let mut total = 0;
179
180    for msg in messages {
181        // Role overhead
182        total += 4;
183        // Content (use text_all to capture all text blocks)
184        total += estimate_tokens(&msg.content.text_all());
185        // Image tokens
186        total += msg.content.image_count() * DEFAULT_IMAGE_TOKEN_ESTIMATE;
187        // Tool calls if present
188        if let Some(ref tool_calls) = msg.tool_calls {
189            for call in tool_calls {
190                total += 10; // Overhead per tool call
191                total += estimate_tokens(&call.function.name);
192                total += estimate_tokens(&call.function.arguments);
193            }
194        }
195    }
196
197    total
198}
199
200/// Estimate the token count of tool definitions sent via native function calling.
201/// vLLM/OpenAI count these as input tokens, so they must be included in budget calculations.
202pub fn estimate_tool_definitions_tokens(tools: &[crate::api::types::ToolDefinition]) -> usize {
203    let mut total = 0;
204    for tool in tools {
205        // Each tool definition has overhead + name + description + parameter schema
206        total += 10; // structural overhead (type, function wrapper)
207        total += estimate_tokens(&tool.function.name);
208        total += estimate_tokens(&tool.function.description);
209        // Parameter schema JSON — serialize and estimate
210        let schema_str = serde_json::to_string(&tool.function.parameters).unwrap_or_default();
211        total += estimate_tokens(&schema_str);
212    }
213    total
214}
215
216/// Estimate tokens for raw content.
217///
218/// Results are cached by content hash to avoid redundant tokenization.
219#[inline]
220pub fn estimate_content_tokens(content: &str) -> usize {
221    let key = hash_content(content);
222
223    // Fast path: check the read-locked cache first.
224    if let Ok(cache) = TOKEN_CACHE.read() {
225        if let Some(&count) = cache.get(&key) {
226            return count;
227        }
228    }
229
230    // Cache miss — compute the token count.
231    let count = TOKENIZER.count(content);
232
233    // Store in cache (acquire write lock).
234    if let Ok(mut cache) = TOKEN_CACHE.write() {
235        // Simple eviction: clear when full rather than tracking LRU order.
236        if cache.len() >= MAX_CACHE_ENTRIES {
237            cache.clear();
238        }
239        cache.insert(key, count);
240    }
241
242    count
243}
244
245/// Compute a fast 64-bit hash of the content string for cache keying.
246fn hash_content(content: &str) -> u64 {
247    let mut hasher = std::collections::hash_map::DefaultHasher::new();
248    content.hash(&mut hasher);
249    hasher.finish()
250}
251
252fn heuristic_estimate(content: &str) -> usize {
253    // Heuristic fallback that remains biased toward overestimation for safety.
254    let factor = if content.contains('{') || content.contains(';') {
255        3
256    } else {
257        4
258    };
259    (content.len() / factor).max(1)
260}
261
262#[cfg(test)]
263#[path = "../tests/unit/token_count/token_count_test.rs"]
264mod tests;