gproxy_tokenize/tokenize/
mod.rs1mod 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#[cfg(feature = "count-local")]
16pub type RegistryHandle<'a> = &'a TokenizerRegistry;
17#[cfg(not(feature = "count-local"))]
18pub type RegistryHandle<'a> = ();
19
20const MSG_OVERHEAD: u64 = 4;
22
23pub 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
39pub 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 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 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#[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
104const O200K: &[&str] = &["gpt-4o", "gpt-4.1", "gpt-5", "o1", "o3", "o4"];
106const CL100K: &[&str] = &["gpt-3.5", "gpt-4"];
107
108pub fn is_gpt_family(model: &str) -> bool {
111 O200K.iter().chain(CL100K).any(|p| model.starts_with(p))
112}
113
114#[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 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, ®);
184 let b = count("gpt-4o-mini", &chat_body(), None, ®);
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, ®) > 0);
194 }
195}