1mod extract;
6#[cfg(feature = "hf-registry")]
7mod registry;
8
9pub use extract::{harvest, try_harvest};
10#[cfg(feature = "hf-registry")]
11pub use registry::{
12 LoadRequestStatus, TokenizerClient, TokenizerRegistry, TokenizerStore, VocabInfo, VocabSource,
13};
14
15#[cfg(feature = "hf-registry")]
18pub type RegistryHandle<'a> = &'a TokenizerRegistry;
19#[cfg(not(feature = "hf-registry"))]
20pub type RegistryHandle<'a> = ();
21
22const MSG_OVERHEAD: u64 = 4;
24
25pub fn count_text(text: &str) -> u64 {
29 #[cfg(feature = "tiktoken")]
30 {
31 tiktoken_rs::cl100k_base_singleton()
32 .encode_ordinary(text)
33 .len() as u64
34 }
35 #[cfg(not(feature = "tiktoken"))]
36 {
37 (text.chars().count() as u64).div_ceil(2)
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum CountMethod {
43 Tiktoken,
44 HuggingFace,
45 BundledFallback,
46 CharacterEstimate,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum CountWarning {
51 ApproximateProviderFraming { tokens_per_message: u64 },
52 GenericJsonHarvest,
53 InvalidJson { reason: String },
54 RawBodyEstimate,
55 TokenizerLoadScheduled { vocab: String },
56 TokenizerLoadInFlight { vocab: String },
57 TokenizerNegativeCached { vocab: String },
58 TokioRuntimeUnavailable { vocab: String },
59 TokenizerEncodeFailed { vocab: String },
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct CountResult {
64 pub tokens: u64,
65 pub method: CountMethod,
66 pub vocab: Option<String>,
67 pub warnings: Vec<CountWarning>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum CountError {
72 InvalidJson(String),
73 TokenizerUnavailable(String),
74 TokenizerEncodeFailed(String),
75 Registry(String),
76}
77
78impl std::fmt::Display for CountError {
79 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match self {
81 Self::InvalidJson(reason) => write!(formatter, "invalid request JSON: {reason}"),
82 Self::TokenizerUnavailable(vocab) => {
83 write!(formatter, "tokenizer `{vocab}` is unavailable")
84 }
85 Self::TokenizerEncodeFailed(vocab) => {
86 write!(formatter, "tokenizer `{vocab}` failed to encode input")
87 }
88 Self::Registry(reason) => write!(formatter, "tokenizer registry failed: {reason}"),
89 }
90 }
91}
92
93impl std::error::Error for CountError {}
94
95pub fn count(
99 model: &str,
100 body: &[u8],
101 map: Option<&serde_json::Value>,
102 registry: RegistryHandle,
103) -> u64 {
104 count_detailed(model, body, map, registry).tokens
105}
106
107pub fn count_detailed(
111 model: &str,
112 body: &[u8],
113 map: Option<&serde_json::Value>,
114 registry: RegistryHandle,
115) -> CountResult {
116 let (texts, messages, warnings) = match extract::try_harvest(body) {
117 Ok((texts, messages)) => (
118 texts,
119 messages,
120 vec![
121 CountWarning::ApproximateProviderFraming {
122 tokens_per_message: MSG_OVERHEAD,
123 },
124 CountWarning::GenericJsonHarvest,
125 ],
126 ),
127 Err(error) => (
128 vec![String::from_utf8_lossy(body).into_owned()],
129 0,
130 vec![
131 CountWarning::InvalidJson {
132 reason: error.to_string(),
133 },
134 CountWarning::RawBodyEstimate,
135 ],
136 ),
137 };
138 #[cfg(feature = "hf-registry")]
139 let mut warnings = warnings;
140 let overhead = messages * MSG_OVERHEAD;
141 #[cfg(any(feature = "tiktoken", feature = "hf-registry"))]
142 let joined = texts.join("\n");
143
144 #[cfg(feature = "tiktoken")]
145 {
146 if let Some(bpe) = gpt_encoding(model) {
147 let vocab = if O200K.iter().any(|prefix| model.starts_with(prefix)) {
148 "o200k_base"
149 } else {
150 "cl100k_base"
151 };
152 return CountResult {
153 tokens: bpe.encode_ordinary(&joined).len() as u64 + overhead,
154 method: CountMethod::Tiktoken,
155 vocab: Some(vocab.to_owned()),
156 warnings,
157 };
158 }
159 }
160 #[cfg(feature = "hf-registry")]
161 {
162 let name = select_vocab(map, model).unwrap_or_else(|| model.to_owned());
166 if let Some(tok) = registry.resolve(&name) {
167 if let Some(n) = encode_len(&tok, &joined) {
168 return CountResult {
169 tokens: n + overhead,
170 method: if matches!(name.as_str(), "deepseek" | "deepseek-v4-pro") {
171 CountMethod::BundledFallback
172 } else {
173 CountMethod::HuggingFace
174 },
175 vocab: Some(name),
176 warnings,
177 };
178 }
179 warnings.push(CountWarning::TokenizerEncodeFailed { vocab: name });
180 } else {
181 let warning = match registry.request_load(&name) {
182 LoadRequestStatus::Scheduled => CountWarning::TokenizerLoadScheduled {
183 vocab: name.clone(),
184 },
185 LoadRequestStatus::AlreadyInFlight => CountWarning::TokenizerLoadInFlight {
186 vocab: name.clone(),
187 },
188 LoadRequestStatus::NegativeCached => CountWarning::TokenizerNegativeCached {
189 vocab: name.clone(),
190 },
191 LoadRequestStatus::NoRuntime => CountWarning::TokioRuntimeUnavailable {
192 vocab: name.clone(),
193 },
194 };
195 warnings.push(warning);
196 }
197 #[cfg(feature = "bundled-fallback")]
199 if let Some(tok) = registry.resolve("deepseek")
200 && let Some(n) = encode_len(&tok, &joined)
201 {
202 return CountResult {
203 tokens: n + overhead,
204 method: CountMethod::BundledFallback,
205 vocab: Some("deepseek-v4-pro".to_owned()),
206 warnings,
207 };
208 }
209 }
210 #[cfg(not(feature = "hf-registry"))]
211 let _ = (model, map, registry);
212
213 let chars: usize = texts.iter().map(|t| t.chars().count()).sum();
214 CountResult {
215 tokens: (chars as u64).div_ceil(2) + overhead,
216 method: CountMethod::CharacterEstimate,
217 vocab: None,
218 warnings,
219 }
220}
221
222pub async fn try_count(
226 model: &str,
227 body: &[u8],
228 map: Option<&serde_json::Value>,
229 registry: RegistryHandle<'_>,
230) -> Result<CountResult, CountError> {
231 let (texts, messages) =
232 extract::try_harvest(body).map_err(|error| CountError::InvalidJson(error.to_string()))?;
233 let joined = texts.join("\n");
234 let overhead = messages * MSG_OVERHEAD;
235 let warnings = vec![
236 CountWarning::ApproximateProviderFraming {
237 tokens_per_message: MSG_OVERHEAD,
238 },
239 CountWarning::GenericJsonHarvest,
240 ];
241
242 #[cfg(feature = "tiktoken")]
243 if let Some(bpe) = gpt_encoding(model) {
244 let vocab = if O200K.iter().any(|prefix| model.starts_with(prefix)) {
245 "o200k_base"
246 } else {
247 "cl100k_base"
248 };
249 return Ok(CountResult {
250 tokens: bpe.encode_ordinary(&joined).len() as u64 + overhead,
251 method: CountMethod::Tiktoken,
252 vocab: Some(vocab.to_owned()),
253 warnings,
254 });
255 }
256
257 #[cfg(feature = "hf-registry")]
258 {
259 let name = select_vocab(map, model).unwrap_or_else(|| model.to_owned());
260 let tokenizer = match registry.resolve(&name) {
261 Some(tokenizer) => Some(tokenizer),
262 None => registry
263 .resolve_or_load(&name)
264 .await
265 .map_err(|error| CountError::Registry(error.to_string()))?,
266 }
267 .ok_or_else(|| CountError::TokenizerUnavailable(name.clone()))?;
268 let tokens = encode_len(&tokenizer, &joined)
269 .ok_or_else(|| CountError::TokenizerEncodeFailed(name.clone()))?
270 + overhead;
271 Ok(CountResult {
272 tokens,
273 method: if matches!(name.as_str(), "deepseek" | "deepseek-v4-pro") {
274 CountMethod::BundledFallback
275 } else {
276 CountMethod::HuggingFace
277 },
278 vocab: Some(name),
279 warnings,
280 })
281 }
282
283 #[cfg(not(feature = "hf-registry"))]
284 {
285 let _ = (joined, overhead, warnings, map, registry);
286 Err(CountError::TokenizerUnavailable(model.to_owned()))
287 }
288}
289
290#[cfg(feature = "hf-registry")]
292fn glob_matches(pattern: &str, value: &str) -> bool {
293 let (pattern, value) = (pattern.as_bytes(), value.as_bytes());
294 let (mut p, mut v, mut star, mut retry_v) = (0, 0, None, 0);
295 while v < value.len() {
296 if p < pattern.len() && pattern[p] == value[v] {
297 p += 1;
298 v += 1;
299 } else if p < pattern.len() && pattern[p] == b'*' {
300 star = Some(p);
301 p += 1;
302 retry_v = v;
303 } else if let Some(star_index) = star {
304 p = star_index + 1;
305 retry_v += 1;
306 v = retry_v;
307 } else {
308 return false;
309 }
310 }
311 while p < pattern.len() && pattern[p] == b'*' {
312 p += 1;
313 }
314 p == pattern.len()
315}
316
317#[cfg(feature = "hf-registry")]
321fn select_vocab(map: Option<&serde_json::Value>, model: &str) -> Option<String> {
322 let mut best: Option<(&str, usize, &str)> = None;
323 for (pattern, value) in map?.as_object()? {
324 let Some(vocab) = value.as_str() else {
325 continue;
326 };
327 if !glob_matches(pattern, model) {
328 continue;
329 }
330 let specificity = pattern.bytes().filter(|byte| *byte != b'*').count();
331 if best.is_none_or(|(best_pattern, best_specificity, _)| {
332 specificity > best_specificity
333 || (specificity == best_specificity && pattern.as_str() < best_pattern)
334 }) {
335 best = Some((pattern, specificity, vocab));
336 }
337 }
338 best.map(|(_, _, vocab)| vocab.to_owned())
339}
340
341const O200K: &[&str] = &["gpt-4o", "gpt-4.1", "gpt-5", "o1", "o3", "o4"];
343const CL100K: &[&str] = &["gpt-3.5", "gpt-4"];
344
345pub fn is_gpt_family(model: &str) -> bool {
348 O200K.iter().chain(CL100K).any(|p| model.starts_with(p))
349}
350
351#[cfg(feature = "tiktoken")]
353fn gpt_encoding(model: &str) -> Option<&'static tiktoken_rs::CoreBPE> {
354 if O200K.iter().any(|p| model.starts_with(p)) {
355 Some(tiktoken_rs::o200k_base_singleton())
356 } else if CL100K.iter().any(|p| model.starts_with(p)) {
357 Some(tiktoken_rs::cl100k_base_singleton())
358 } else {
359 None
360 }
361}
362
363#[cfg(feature = "hf-registry")]
364fn encode_len(tok: &tokenizers::Tokenizer, text: &str) -> Option<u64> {
365 Some(tok.encode(text, false).ok()?.get_ids().len() as u64)
366}
367
368#[cfg(test)]
369mod general_tests {
370 use super::*;
371
372 #[test]
373 fn malformed_json_uses_raw_body_estimate_with_diagnostics() {
374 let body = br#"{"messages":[{"content":"important text"}"#;
375 #[cfg(feature = "hf-registry")]
376 let registry = test_registry();
377 #[cfg(feature = "hf-registry")]
378 let result = count_detailed("unknown", body, None, ®istry);
379 #[cfg(not(feature = "hf-registry"))]
380 let result = count_detailed("unknown", body, None, ());
381 assert!(result.tokens > 0);
382 assert!(
383 result
384 .warnings
385 .iter()
386 .any(|warning| matches!(warning, CountWarning::InvalidJson { .. }))
387 );
388 assert!(result.warnings.contains(&CountWarning::RawBodyEstimate));
389 }
390
391 #[cfg(feature = "hf-registry")]
392 fn test_registry() -> TokenizerRegistry {
393 use std::sync::Arc;
394
395 struct Store;
396 #[async_trait::async_trait]
397 impl TokenizerStore for Store {
398 async fn list_tokenizer_vocabs(&self) -> anyhow::Result<Vec<String>> {
399 Ok(Vec::new())
400 }
401 async fn get_tokenizer_vocab(&self, _: &str) -> anyhow::Result<Option<Vec<u8>>> {
402 Ok(None)
403 }
404 async fn put_tokenizer_vocab(&self, _: &str, _: &[u8]) -> anyhow::Result<()> {
405 Ok(())
406 }
407 }
408 struct Client;
409 #[async_trait::async_trait]
410 impl TokenizerClient for Client {
411 async fn send(
412 &self,
413 _: http::Request<bytes::Bytes>,
414 ) -> anyhow::Result<http::Response<bytes::Bytes>> {
415 anyhow::bail!("not used")
416 }
417 }
418 TokenizerRegistry::new(Arc::new(Store), Arc::new(Client))
419 }
420
421 #[cfg(feature = "hf-registry")]
422 #[test]
423 fn glob_selection_is_specific_and_stable() {
424 let map = serde_json::json!({
425 "*": "generic",
426 "claude-*": "claude",
427 "claude-3-*": "claude-3"
428 });
429 assert_eq!(
430 select_vocab(Some(&map), "claude-3-opus").as_deref(),
431 Some("claude-3")
432 );
433 assert_eq!(
434 select_vocab(Some(&map), "claude-next").as_deref(),
435 Some("claude")
436 );
437 }
438
439 #[cfg(feature = "hf-registry")]
440 #[test]
441 fn background_load_without_runtime_is_explicit() {
442 let registry = test_registry();
443 assert_eq!(
444 registry.request_load("owner/model"),
445 LoadRequestStatus::NoRuntime
446 );
447 }
448}
449
450#[cfg(all(test, feature = "count-local"))]
451mod tests {
452 use std::sync::Arc;
453
454 use bytes::Bytes;
455
456 use super::{
457 CountMethod, TokenizerClient, TokenizerRegistry, TokenizerStore, count, count_detailed,
458 };
459
460 struct NoUpstream;
462
463 #[async_trait::async_trait]
464 impl TokenizerClient for NoUpstream {
465 async fn send(&self, _req: http::Request<Bytes>) -> anyhow::Result<http::Response<Bytes>> {
466 anyhow::bail!("no upstream in tests")
467 }
468 }
469
470 #[derive(Default)]
471 struct EmptyStore;
472
473 #[async_trait::async_trait]
474 impl TokenizerStore for EmptyStore {
475 async fn list_tokenizer_vocabs(&self) -> anyhow::Result<Vec<String>> {
476 Ok(Vec::new())
477 }
478
479 async fn get_tokenizer_vocab(&self, _name: &str) -> anyhow::Result<Option<Vec<u8>>> {
480 Ok(None)
481 }
482
483 async fn put_tokenizer_vocab(&self, _name: &str, _bytes: &[u8]) -> anyhow::Result<()> {
484 Ok(())
485 }
486 }
487
488 async fn registry() -> TokenizerRegistry {
489 TokenizerRegistry::new(Arc::new(EmptyStore), Arc::new(NoUpstream))
490 }
491
492 fn chat_body() -> Vec<u8> {
493 serde_json::json!({
494 "model": "x",
495 "messages": [{ "role": "user", "content": "Hello, how are you today?" }]
496 })
497 .to_string()
498 .into_bytes()
499 }
500
501 #[tokio::test]
502 async fn tiktoken_gpt_path_is_stable() {
503 let reg = registry().await;
504 let a = count("gpt-4o-mini", &chat_body(), None, ®);
505 let b = count("gpt-4o-mini", &chat_body(), None, ®);
506 assert!(a > 0);
507 assert_eq!(a, b);
508 let result = count_detailed("gpt-4o-mini", &chat_body(), None, ®);
509 assert_eq!(result.method, CountMethod::Tiktoken);
510 assert_eq!(result.vocab.as_deref(), Some("o200k_base"));
511 }
512
513 #[tokio::test]
514 async fn bundled_deepseek_covers_unknown_models() {
515 let reg = registry().await;
516 assert!(reg.resolve("deepseek").is_some());
517 assert!(count("qwen-max", &chat_body(), None, ®) > 0);
518 let result = count_detailed("qwen-max", &chat_body(), None, ®);
519 assert_eq!(result.method, CountMethod::BundledFallback);
520 }
521}