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;