Skip to main content

gproxy_tokenize/tokenize/
mod.rs

1//! Local token counting (§6.3): tiktoken for gpt families, bundled/downloaded
2//! HF tokenizers for the rest, char-estimate floor. Native-only behind
3//! `count-local` except the estimate, which serves the edge build.
4
5mod 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/// What `count` receives as the registry: a real handle under `count-local`,
16/// a unit on builds without it (edge) so call sites stay uniform.
17#[cfg(feature = "hf-registry")]
18pub type RegistryHandle<'a> = &'a TokenizerRegistry;
19#[cfg(not(feature = "hf-registry"))]
20pub type RegistryHandle<'a> = ();
21
22/// Per-message fixed overhead (role/markup framing), in tokens.
23const MSG_OVERHEAD: u64 = 4;
24
25/// Count a single text buffer with the same local fallback Clove uses for
26/// Claude Web usage synthesis: cl100k when local tokenizers are enabled,
27/// otherwise the cross-target character estimate.
28pub 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
95/// Count tokens of a provider-native request body. `map` = provider settings
96/// `tokenizer_map` (glob → vocab name). Never fails: worst case is the
97/// chars/2 estimate.
98pub 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
107/// Count with provenance and diagnostics. Unlike [`count`], malformed JSON is
108/// never indistinguishable from a genuinely empty request: it falls back to a
109/// conservative estimate over the raw body and records warnings.
110pub 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        // tokenizer_map glob hit → that vocab; otherwise resolve the model
163        // name itself. Miss → request a background hydrate/download and fall
164        // through.
165        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        // Bundled fallback vocab.
198        #[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
222/// Strict counting for correctness-sensitive paths. This rejects malformed
223/// JSON and, with `hf-registry`, waits for store hydration/download instead of
224/// returning a result that changes after a background load.
225pub 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/// `*`-wildcard glob matching, anchored at both ends. No other metacharacters.
291#[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/// Legacy JSON-object maps use deterministic "most specific pattern wins"
318/// semantics; ties are resolved by lexical pattern order. Callers no longer
319/// depend on `serde_json::Map`'s storage ordering.
320#[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
341/// gpt-family prefixes with a tiktoken builtin (o200k / cl100k).
342const O200K: &[&str] = &["gpt-4o", "gpt-4.1", "gpt-5", "o1", "o3", "o4"];
343const CL100K: &[&str] = &["gpt-3.5", "gpt-4"];
344
345/// Whether `model` belongs to a gpt family with an exact local tiktoken
346/// vocabulary (drives the §17 counting-ladder source label).
347pub fn is_gpt_family(model: &str) -> bool {
348    O200K.iter().chain(CL100K).any(|p| model.starts_with(p))
349}
350
351/// tiktoken builtin for gpt families; `None` = not a gpt model.
352#[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, &registry);
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    /// No-op upstream: the registry never dials out in these tests.
461    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, &reg);
505        let b = count("gpt-4o-mini", &chat_body(), None, &reg);
506        assert!(a > 0);
507        assert_eq!(a, b);
508        let result = count_detailed("gpt-4o-mini", &chat_body(), None, &reg);
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, &reg) > 0);
518        let result = count_detailed("qwen-max", &chat_body(), None, &reg);
519        assert_eq!(result.method, CountMethod::BundledFallback);
520    }
521}