1mod l1;
54
55use std::sync::Arc;
56
57pub use l1::{CacheEventFn, L1Cache, L1CacheStats};
58
59use crate::{
60 Encoding, Result, TokenIdType,
61 traits::{DecodeResult, Decoder, Encoder, Tokenizer},
62};
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct CacheTokenUsage {
70 pub cached_tokens: usize,
72 pub uncached_tokens: usize,
74}
75
76pub type CacheTokenUsageFn = Arc<dyn Fn(CacheTokenUsage) + Send + Sync>;
78
79pub struct CachedTokenizer {
84 inner: Arc<dyn Tokenizer>,
85 l1: L1Cache,
86 l1_enabled: bool,
90 extend_on_hit: bool,
93 token_observer: Option<CacheTokenUsageFn>,
95}
96
97impl CachedTokenizer {
98 pub fn new(
113 inner: Arc<dyn Tokenizer>,
114 special_tokens: Vec<String>,
115 max_memory_bytes: usize,
116 ) -> Result<Self> {
117 inner.validate_prefix_cache()?;
118
119 let l1_enabled = !special_tokens.is_empty();
120 Ok(Self {
121 inner,
122 l1: L1Cache::new(max_memory_bytes, special_tokens),
123 l1_enabled,
124 extend_on_hit: false,
125 token_observer: None,
126 })
127 }
128
129 pub fn with_extend(mut self, enabled: bool) -> Self {
134 self.extend_on_hit = enabled;
135 self
136 }
137
138 pub fn with_observer(mut self, on_hit: CacheEventFn, on_miss: CacheEventFn) -> Self {
142 self.l1.set_observer(on_hit, on_miss);
143 self
144 }
145
146 pub fn with_token_observer(mut self, observer: CacheTokenUsageFn) -> Self {
154 self.token_observer = Some(observer);
155 self
156 }
157
158 fn observe_token_usage(&self, cached_tokens: usize, total_tokens: usize) {
159 if let Some(observer) = &self.token_observer {
160 let uncached_tokens = total_tokens
161 .checked_sub(cached_tokens)
162 .expect("cached token count cannot exceed total token count");
163 observer(CacheTokenUsage {
164 cached_tokens,
165 uncached_tokens,
166 });
167 }
168 }
169
170 pub fn cache_stats(&self) -> L1CacheStats {
172 self.l1.stats()
173 }
174
175 pub fn clear_cache(&self) {
177 self.l1.clear();
178 }
179
180 pub fn inner(&self) -> &Arc<dyn Tokenizer> {
182 &self.inner
183 }
184}
185
186impl Encoder for CachedTokenizer {
187 fn encode(&self, input: &str) -> Result<Encoding> {
188 if !self.l1_enabled {
193 return self.inner.encode(input);
194 }
195
196 if let Some((prefix_tokens, prefix_len, deepest_boundary)) =
197 self.l1.longest_prefix_match(input)
198 {
199 let cached_tokens = prefix_tokens.len();
200 let suffix = &input[prefix_len..];
201 let encoding = if suffix.is_empty() {
202 Encoding::Sp(prefix_tokens.to_vec())
203 } else if self.extend_on_hit {
204 Encoding::Sp(self.l1.extend_after_match(
208 input,
209 prefix_tokens,
210 prefix_len,
211 deepest_boundary,
212 self.inner.as_ref(),
213 )?)
214 } else {
215 let suffix_enc = self.inner.encode(suffix)?;
216 let mut merged: Vec<TokenIdType> =
219 Vec::with_capacity(prefix_tokens.len() + suffix_enc.token_ids().len());
220 merged.extend_from_slice(&prefix_tokens);
221 merged.extend_from_slice(suffix_enc.token_ids());
222 Encoding::Sp(merged)
223 };
224 self.observe_token_usage(cached_tokens, encoding.token_ids().len());
225 return Ok(encoding);
226 }
227
228 let encoding = Encoding::Sp(self.l1.populate_and_encode(input, self.inner.as_ref())?);
234 self.observe_token_usage(0, encoding.token_ids().len());
235 Ok(encoding)
236 }
237
238 fn encode_batch(&self, inputs: &[&str]) -> Result<Vec<Encoding>> {
239 if !self.l1_enabled {
243 return self.inner.encode_batch(inputs);
244 }
245
246 inputs.iter().map(|&i| self.encode(i)).collect()
250 }
251}
252
253impl Decoder for CachedTokenizer {
254 fn decode(&self, token_ids: &[TokenIdType], skip_special_tokens: bool) -> Result<DecodeResult> {
255 self.inner.decode(token_ids, skip_special_tokens)
257 }
258}
259
260impl Tokenizer for CachedTokenizer {}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::HuggingFaceTokenizer;
266 use std::sync::{Mutex, atomic::AtomicU64, atomic::Ordering};
267
268 struct FailingTokenizer;
269
270 impl Encoder for FailingTokenizer {
271 fn encode(&self, _input: &str) -> Result<Encoding> {
272 Err(anyhow::anyhow!("intentional encode failure"))
273 }
274
275 fn encode_batch(&self, _inputs: &[&str]) -> Result<Vec<Encoding>> {
276 Err(anyhow::anyhow!("intentional encode failure"))
277 }
278 }
279
280 impl Decoder for FailingTokenizer {
281 fn decode(
282 &self,
283 _token_ids: &[TokenIdType],
284 _skip_special_tokens: bool,
285 ) -> Result<DecodeResult> {
286 Err(anyhow::anyhow!("intentional decode failure"))
287 }
288 }
289
290 impl Tokenizer for FailingTokenizer {
291 fn validate_prefix_cache(&self) -> Result<()> {
292 Ok(())
293 }
294 }
295
296 const TINYLLAMA_PATH: &str = concat!(
297 env!("CARGO_MANIFEST_DIR"),
298 "/tests/data/sample-models/TinyLlama_v1.1/tokenizer.json"
299 );
300
301 fn inner() -> Arc<dyn Tokenizer> {
302 Arc::new(HuggingFaceTokenizer::from_file(TINYLLAMA_PATH).expect("load TinyLlama"))
303 }
304
305 fn specials() -> Vec<String> {
306 vec!["<s>".into(), "</s>".into()]
307 }
308
309 fn collect_token_usage(
310 tokenizer: CachedTokenizer,
311 ) -> (CachedTokenizer, Arc<Mutex<Vec<CacheTokenUsage>>>) {
312 let events = Arc::new(Mutex::new(Vec::new()));
313 let observed = events.clone();
314 let tokenizer = tokenizer.with_token_observer(Arc::new(move |usage| {
315 observed.lock().unwrap().push(usage);
316 }));
317 (tokenizer, events)
318 }
319
320 #[test]
321 fn rejects_hf_tokenizer_that_adds_special_tokens() {
322 let tokenizer: Arc<dyn Tokenizer> = Arc::new(
323 HuggingFaceTokenizer::from_file(TINYLLAMA_PATH)
324 .expect("load TinyLlama")
325 .with_options(crate::TokenizerOptions {
326 add_special_tokens: true,
327 }),
328 );
329
330 let result = CachedTokenizer::new(tokenizer, specials(), 4096);
331 let Err(error) = result else {
332 panic!("add_special_tokens=true must be rejected");
333 };
334 assert_eq!(
335 error.to_string(),
336 "HuggingFace tokenizers configured with add_special_tokens=true must remain uncached"
337 );
338 }
339
340 #[test]
341 fn empty_specials_passes_through_correctly() {
342 let tok = inner();
347 let (cached, events) = collect_token_usage(
348 CachedTokenizer::new(tok.clone(), Vec::new(), 4096)
349 .expect("TinyLlama must support prefix caching"),
350 );
351 let s = "<s>hello world</s>";
352 let a = cached.encode(s).unwrap();
353 let b = tok.encode(s).unwrap();
354 assert_eq!(a.token_ids(), b.token_ids());
355 let stats = cached.cache_stats();
356 assert_eq!(stats.entries, 0);
357 assert_eq!(stats.misses, 0, "empty specials must not increment misses");
358 assert_eq!(stats.hits, 0);
359 assert!(
360 events.lock().unwrap().is_empty(),
361 "empty specials must not emit token usage"
362 );
363 }
364
365 #[test]
366 fn token_observer_reports_full_miss_and_partial_hit_with_and_without_extension() {
367 for extend_on_hit in [false, true] {
368 let tok = inner();
369 let hits = Arc::new(AtomicU64::new(0));
370 let misses = Arc::new(AtomicU64::new(0));
371 let hit_counter = hits.clone();
372 let miss_counter = misses.clone();
373 let cached = CachedTokenizer::new(tok, specials(), 64 * 1024)
374 .expect("TinyLlama must support prefix caching")
375 .with_extend(extend_on_hit)
376 .with_observer(
377 Arc::new(move || {
378 hit_counter.fetch_add(1, Ordering::Relaxed);
379 }),
380 Arc::new(move || {
381 miss_counter.fetch_add(1, Ordering::Relaxed);
382 }),
383 );
384 let (cached, events) = collect_token_usage(cached);
385
386 let shared = "<s>system\nYou are helpful.</s><s>user\n";
387 let first = format!("{shared}First question?</s>");
388 let second = format!("{shared}Second different prompt entirely.</s>");
389
390 let first_encoding = cached.encode(&first).unwrap();
391 let second_encoding = cached.encode(&second).unwrap();
392
393 let events = events.lock().unwrap();
394 assert_eq!(events.len(), 2);
395 assert_eq!(
396 events[0],
397 CacheTokenUsage {
398 cached_tokens: 0,
399 uncached_tokens: first_encoding.token_ids().len(),
400 }
401 );
402 assert!(events[1].cached_tokens > 0);
403 assert!(events[1].uncached_tokens > 0);
404 assert_eq!(
405 events[1].cached_tokens + events[1].uncached_tokens,
406 second_encoding.token_ids().len()
407 );
408 assert_eq!(hits.load(Ordering::Relaxed), 1);
409 assert_eq!(misses.load(Ordering::Relaxed), 1);
410 }
411 }
412
413 #[test]
414 fn token_observer_does_not_report_failed_encodes() {
415 let tokenizer: Arc<dyn Tokenizer> = Arc::new(FailingTokenizer);
416 let (cached, events) = collect_token_usage(
417 CachedTokenizer::new(tokenizer, specials(), 4096)
418 .expect("test tokenizer explicitly supports prefix caching"),
419 );
420
421 assert!(cached.encode("<s>this fails</s>").is_err());
422 assert!(events.lock().unwrap().is_empty());
423 }
424
425 #[test]
426 fn two_turn_chat_correctness_and_hit() {
427 let tok = inner();
428 let cached = CachedTokenizer::new(tok.clone(), specials(), 64 * 1024)
429 .expect("TinyLlama must support prefix caching");
430
431 let template = "<s>system\nYou are helpful.</s><s>user\n";
432 let first = format!("{template}First question?</s>");
433 let second = format!("{template}Second different prompt entirely.</s>");
434
435 let _ = cached.encode(&first).unwrap();
437
438 let cached_second = cached.encode(&second).unwrap();
440 let plain_second = tok.encode(&second).unwrap();
441 assert_eq!(
442 cached_second.token_ids(),
443 plain_second.token_ids(),
444 "cached encode must equal plain encode for second turn"
445 );
446
447 let stats = cached.cache_stats();
448 assert!(stats.hits >= 1, "expected L1 hit on second request");
449 }
450
451 #[test]
452 fn decode_passes_through() {
453 let tok = inner();
454 let cached = CachedTokenizer::new(tok.clone(), specials(), 4096)
455 .expect("TinyLlama must support prefix caching");
456 let enc = cached.encode("<s>hello</s>").unwrap();
457 let direct = tok.decode(enc.token_ids(), false).unwrap();
458 let through = cached.decode(enc.token_ids(), false).unwrap();
459 assert_eq!(direct, through);
460 }
461
462 #[test]
463 fn encode_batch_uses_cache() {
464 let tok = inner();
465 let (cached, events) = collect_token_usage(
466 CachedTokenizer::new(tok.clone(), specials(), 64 * 1024)
467 .expect("TinyLlama must support prefix caching"),
468 );
469 let shared = "<s>system\nShared persona.</s><s>user\n";
470 let inputs = [
471 format!("{shared}q1</s>"),
472 format!("{shared}q2</s>"),
473 format!("{shared}q3</s>"),
474 ];
475 let refs: Vec<&str> = inputs.iter().map(String::as_str).collect();
476 let outs = cached.encode_batch(&refs).unwrap();
477 assert_eq!(outs.len(), 3);
478 let events = events.lock().unwrap();
479 assert_eq!(events.len(), outs.len());
480 for (event, output) in events.iter().zip(&outs) {
481 assert_eq!(
482 event.cached_tokens + event.uncached_tokens,
483 output.token_ids().len()
484 );
485 }
486 assert_eq!(events[0].cached_tokens, 0);
487 assert!(events[1..].iter().all(|event| event.cached_tokens > 0));
488 assert!(cached.cache_stats().hits >= 2, "expected hits on q2 and q3");
490 }
491}