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 = "count-local")]
7mod registry;
8
9pub use extract::harvest;
10#[cfg(feature = "count-local")]
11pub use registry::{TokenizerClient, TokenizerRegistry, TokenizerStore, VocabInfo, VocabSource};
12
13/// What `count` receives as the registry: a real handle under `count-local`,
14/// a unit on builds without it (edge) so call sites stay uniform.
15#[cfg(feature = "count-local")]
16pub type RegistryHandle<'a> = &'a TokenizerRegistry;
17#[cfg(not(feature = "count-local"))]
18pub type RegistryHandle<'a> = ();
19
20/// Per-message fixed overhead (role/markup framing), in tokens.
21const MSG_OVERHEAD: u64 = 4;
22
23/// Count a single text buffer with the same local fallback Clove uses for
24/// Claude Web usage synthesis: cl100k when local tokenizers are enabled,
25/// otherwise the cross-target character estimate.
26pub fn count_text(text: &str) -> u64 {
27    #[cfg(feature = "count-local")]
28    {
29        tiktoken_rs::cl100k_base_singleton()
30            .encode_ordinary(text)
31            .len() as u64
32    }
33    #[cfg(not(feature = "count-local"))]
34    {
35        (text.chars().count() as u64).div_ceil(2)
36    }
37}
38
39/// Count tokens of a provider-native request body. `map` = provider settings
40/// `tokenizer_map` (glob → vocab name). Never fails: worst case is the
41/// chars/2 estimate.
42pub fn count(
43    model: &str,
44    body: &[u8],
45    map: Option<&serde_json::Value>,
46    registry: RegistryHandle,
47) -> u64 {
48    let (texts, messages) = extract::harvest(body);
49    let overhead = messages * MSG_OVERHEAD;
50
51    #[cfg(feature = "count-local")]
52    {
53        let joined = texts.join("\n");
54        if let Some(bpe) = gpt_encoding(model) {
55            return bpe.encode_ordinary(&joined).len() as u64 + overhead;
56        }
57        // tokenizer_map glob hit → that vocab; otherwise resolve the model
58        // name itself. Miss → request a background hydrate/download and fall
59        // through.
60        let name = map
61            .and_then(|m| m.as_object())
62            .and_then(|obj| {
63                obj.iter()
64                    .find(|(pat, _)| glob_matches(pat, model))
65                    .and_then(|(_, v)| v.as_str().map(str::to_owned))
66            })
67            .unwrap_or_else(|| model.to_owned());
68        if let Some(tok) = registry.resolve(&name) {
69            if let Some(n) = encode_len(&tok, &joined) {
70                return n + overhead;
71            }
72        } else {
73            registry.request_load(&name);
74        }
75        // Bundled fallback vocab.
76        if let Some(tok) = registry.resolve("deepseek")
77            && let Some(n) = encode_len(&tok, &joined)
78        {
79            return n + overhead;
80        }
81    }
82    #[cfg(not(feature = "count-local"))]
83    let _ = (model, map, registry);
84
85    let chars: usize = texts.iter().map(|t| t.chars().count()).sum();
86    (chars as u64).div_ceil(2) + overhead
87}
88
89/// `*`-wildcard glob matching, anchored at both ends. No other metacharacters.
90#[cfg(feature = "count-local")]
91fn glob_matches(pattern: &str, value: &str) -> bool {
92    fn inner(p: &[u8], v: &[u8]) -> bool {
93        match p.split_first() {
94            None => v.is_empty(),
95            Some((b'*', rest)) => (0..=v.len()).any(|i| inner(rest, &v[i..])),
96            Some((c, rest)) => v
97                .split_first()
98                .is_some_and(|(vc, vrest)| vc == c && inner(rest, vrest)),
99        }
100    }
101    inner(pattern.as_bytes(), value.as_bytes())
102}
103
104/// gpt-family prefixes with a tiktoken builtin (o200k / cl100k).
105const O200K: &[&str] = &["gpt-4o", "gpt-4.1", "gpt-5", "o1", "o3", "o4"];
106const CL100K: &[&str] = &["gpt-3.5", "gpt-4"];
107
108/// Whether `model` belongs to a gpt family with an exact local tiktoken
109/// vocabulary (drives the §17 counting-ladder source label).
110pub fn is_gpt_family(model: &str) -> bool {
111    O200K.iter().chain(CL100K).any(|p| model.starts_with(p))
112}
113
114/// tiktoken builtin for gpt families; `None` = not a gpt model.
115#[cfg(feature = "count-local")]
116fn gpt_encoding(model: &str) -> Option<&'static tiktoken_rs::CoreBPE> {
117    if O200K.iter().any(|p| model.starts_with(p)) {
118        Some(tiktoken_rs::o200k_base_singleton())
119    } else if CL100K.iter().any(|p| model.starts_with(p)) {
120        Some(tiktoken_rs::cl100k_base_singleton())
121    } else {
122        None
123    }
124}
125
126#[cfg(feature = "count-local")]
127fn encode_len(tok: &tokenizers::Tokenizer, text: &str) -> Option<u64> {
128    Some(tok.encode(text, false).ok()?.get_ids().len() as u64)
129}
130
131#[cfg(all(test, feature = "count-local"))]
132mod tests {
133    use std::sync::Arc;
134
135    use bytes::Bytes;
136
137    use super::{TokenizerClient, TokenizerRegistry, TokenizerStore, count};
138
139    /// No-op upstream: the registry never dials out in these tests.
140    struct NoUpstream;
141
142    #[async_trait::async_trait]
143    impl TokenizerClient for NoUpstream {
144        async fn send(&self, _req: http::Request<Bytes>) -> anyhow::Result<http::Response<Bytes>> {
145            anyhow::bail!("no upstream in tests")
146        }
147    }
148
149    #[derive(Default)]
150    struct EmptyStore;
151
152    #[async_trait::async_trait]
153    impl TokenizerStore for EmptyStore {
154        async fn list_tokenizer_vocabs(&self) -> anyhow::Result<Vec<String>> {
155            Ok(Vec::new())
156        }
157
158        async fn get_tokenizer_vocab(&self, _name: &str) -> anyhow::Result<Option<Vec<u8>>> {
159            Ok(None)
160        }
161
162        async fn put_tokenizer_vocab(&self, _name: &str, _bytes: &[u8]) -> anyhow::Result<()> {
163            Ok(())
164        }
165    }
166
167    async fn registry() -> TokenizerRegistry {
168        TokenizerRegistry::new(Arc::new(EmptyStore), Arc::new(NoUpstream))
169    }
170
171    fn chat_body() -> Vec<u8> {
172        serde_json::json!({
173            "model": "x",
174            "messages": [{ "role": "user", "content": "Hello, how are you today?" }]
175        })
176        .to_string()
177        .into_bytes()
178    }
179
180    #[tokio::test]
181    async fn tiktoken_gpt_path_is_stable() {
182        let reg = registry().await;
183        let a = count("gpt-4o-mini", &chat_body(), None, &reg);
184        let b = count("gpt-4o-mini", &chat_body(), None, &reg);
185        assert!(a > 0);
186        assert_eq!(a, b);
187    }
188
189    #[tokio::test]
190    async fn bundled_deepseek_covers_unknown_models() {
191        let reg = registry().await;
192        assert!(reg.resolve("deepseek").is_some());
193        assert!(count("qwen-max", &chat_body(), None, &reg) > 0);
194    }
195}