1mod l1;
61
62use std::sync::Arc;
63
64pub use l1::{CacheEventFn, L1Cache, L1CacheStats};
65
66use crate::{
67 EncodeSegment, Encoding, Result, TokenIdType,
68 traits::{DecodeResult, Decoder, Encoder, Tokenizer},
69};
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct CacheTokenUsage {
77 pub cached_tokens: usize,
79 pub uncached_tokens: usize,
81}
82
83pub type CacheTokenUsageFn = Arc<dyn Fn(CacheTokenUsage) + Send + Sync>;
85
86pub struct CachedTokenizer {
91 inner: Arc<dyn Tokenizer>,
92 l1: L1Cache,
93 l1_enabled: bool,
94 extend_on_hit: bool,
97 token_observer: Option<CacheTokenUsageFn>,
99}
100
101impl CachedTokenizer {
102 pub fn new(
118 inner: Arc<dyn Tokenizer>,
119 mut special_tokens: Vec<String>,
120 max_memory_bytes: usize,
121 ) -> Result<Self> {
122 inner.validate_prefix_cache()?;
123 special_tokens.retain(|token| !token.is_empty());
124
125 let overlapping_specials = match l1::first_unsafe_overlap(&special_tokens) {
128 Some((first, second)) => {
129 tracing::warn!(
130 target: "tokenizer",
131 first_token = first,
132 second_token = second,
133 special_token_count = special_tokens.len(),
134 "special tokens can overlap; tokenizer prefix cache disabled"
135 );
136 true
137 }
138 None => false,
139 };
140
141 let l1_enabled = !special_tokens.is_empty() && !overlapping_specials;
142 let cache_tokens = if l1_enabled {
143 special_tokens
144 } else {
145 Vec::new()
146 };
147 Ok(Self {
148 inner,
149 l1: L1Cache::new(max_memory_bytes, cache_tokens),
150 l1_enabled,
151 extend_on_hit: false,
152 token_observer: None,
153 })
154 }
155
156 pub fn with_extend(mut self, enabled: bool) -> Self {
161 self.extend_on_hit = enabled;
162 self
163 }
164
165 pub fn with_observer(mut self, on_hit: CacheEventFn, on_miss: CacheEventFn) -> Self {
169 self.l1.set_observer(on_hit, on_miss);
170 self
171 }
172
173 pub fn with_token_observer(mut self, observer: CacheTokenUsageFn) -> Self {
181 self.token_observer = Some(observer);
182 self
183 }
184
185 fn observe_token_usage(&self, cached_tokens: usize, total_tokens: usize) {
186 if let Some(observer) = &self.token_observer {
187 let uncached_tokens = total_tokens
188 .checked_sub(cached_tokens)
189 .expect("cached token count cannot exceed total token count");
190 observer(CacheTokenUsage {
191 cached_tokens,
192 uncached_tokens,
193 });
194 }
195 }
196
197 pub fn cache_stats(&self) -> L1CacheStats {
199 self.l1.stats()
200 }
201
202 pub fn clear_cache(&self) {
204 self.l1.clear();
205 }
206
207 pub fn inner(&self) -> &Arc<dyn Tokenizer> {
209 &self.inner
210 }
211}
212
213impl Encoder for CachedTokenizer {
214 fn encode(&self, input: &str) -> Result<Encoding> {
215 if !self.l1_enabled {
220 return self.inner.encode(input);
221 }
222
223 if let Some((prefix_tokens, prefix_len, deepest_boundary)) =
224 self.l1.longest_prefix_match(input)
225 {
226 let cached_tokens = prefix_tokens.len();
227 let suffix = &input[prefix_len..];
228 let encoding = if suffix.is_empty() {
229 Encoding::Sp(prefix_tokens.to_vec())
230 } else if self.extend_on_hit {
231 Encoding::Sp(self.l1.extend_after_match(
235 input,
236 prefix_tokens,
237 prefix_len,
238 deepest_boundary,
239 self.inner.as_ref(),
240 )?)
241 } else {
242 let suffix_enc = self.inner.encode(suffix)?;
243 let mut merged: Vec<TokenIdType> =
246 Vec::with_capacity(prefix_tokens.len() + suffix_enc.token_ids().len());
247 merged.extend_from_slice(&prefix_tokens);
248 merged.extend_from_slice(suffix_enc.token_ids());
249 Encoding::Sp(merged)
250 };
251 self.observe_token_usage(cached_tokens, encoding.token_ids().len());
252 return Ok(encoding);
253 }
254
255 let encoding = Encoding::Sp(self.l1.populate_and_encode(input, self.inner.as_ref())?);
261 self.observe_token_usage(0, encoding.token_ids().len());
262 Ok(encoding)
263 }
264
265 fn encode_batch(&self, inputs: &[&str]) -> Result<Vec<Encoding>> {
266 if !self.l1_enabled {
270 return self.inner.encode_batch(inputs);
271 }
272
273 inputs.iter().map(|&i| self.encode(i)).collect()
277 }
278
279 fn encode_segments(&self, segments: &[EncodeSegment<'_>]) -> Result<Encoding> {
280 let encoding = self.inner.encode_segments(segments)?;
284 if self.l1_enabled {
285 self.observe_token_usage(0, encoding.token_ids().len());
286 }
287 Ok(encoding)
288 }
289}
290
291impl Decoder for CachedTokenizer {
292 fn decode(&self, token_ids: &[TokenIdType], skip_special_tokens: bool) -> Result<DecodeResult> {
293 self.inner.decode(token_ids, skip_special_tokens)
295 }
296}
297
298impl Tokenizer for CachedTokenizer {
299 fn vocab_size(&self) -> Option<usize> {
300 self.inner.vocab_size()
301 }
302
303 fn token_to_id(&self, token: &str) -> Result<Option<TokenIdType>> {
304 self.inner.token_to_id(token)
305 }
306
307 fn special_token_ids(&self) -> Result<Vec<TokenIdType>> {
308 self.inner.special_token_ids()
309 }
310
311 fn num_special_tokens_added(&self) -> Result<usize> {
312 Ok(0)
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::HuggingFaceTokenizer;
320 use std::sync::{Mutex, atomic::AtomicU64, atomic::Ordering};
321 use tokenizers::Tokenizer as HfTokenizer;
322
323 struct FailingTokenizer;
324
325 struct SegmentTokenizer;
326
327 impl Encoder for SegmentTokenizer {
328 fn encode(&self, input: &str) -> Result<Encoding> {
329 Ok(Encoding::Sp(vec![input.len() as u32]))
330 }
331
332 fn encode_batch(&self, inputs: &[&str]) -> Result<Vec<Encoding>> {
333 inputs.iter().map(|input| self.encode(input)).collect()
334 }
335
336 fn encode_segments(&self, segments: &[EncodeSegment<'_>]) -> Result<Encoding> {
337 let ids = segments
338 .iter()
339 .flat_map(|segment| [segment.allow_special as u32, segment.text.len() as u32])
340 .collect();
341 Ok(Encoding::Sp(ids))
342 }
343 }
344
345 impl Decoder for SegmentTokenizer {
346 fn decode(
347 &self,
348 _token_ids: &[TokenIdType],
349 _skip_special_tokens: bool,
350 ) -> Result<DecodeResult> {
351 Ok(DecodeResult::Complete(String::new()))
352 }
353 }
354
355 impl Tokenizer for SegmentTokenizer {
356 fn validate_prefix_cache(&self) -> Result<()> {
357 Ok(())
358 }
359 }
360
361 impl Encoder for FailingTokenizer {
362 fn encode(&self, _input: &str) -> Result<Encoding> {
363 Err(anyhow::anyhow!("intentional encode failure"))
364 }
365
366 fn encode_batch(&self, _inputs: &[&str]) -> Result<Vec<Encoding>> {
367 Err(anyhow::anyhow!("intentional encode failure"))
368 }
369 }
370
371 impl Decoder for FailingTokenizer {
372 fn decode(
373 &self,
374 _token_ids: &[TokenIdType],
375 _skip_special_tokens: bool,
376 ) -> Result<DecodeResult> {
377 Err(anyhow::anyhow!("intentional decode failure"))
378 }
379 }
380
381 impl Tokenizer for FailingTokenizer {
382 fn validate_prefix_cache(&self) -> Result<()> {
383 Ok(())
384 }
385
386 fn vocab_size(&self) -> Option<usize> {
387 None
388 }
389 }
390
391 const TINYLLAMA_PATH: &str = concat!(
392 env!("CARGO_MANIFEST_DIR"),
393 "/tests/data/sample-models/TinyLlama_v1.1/tokenizer.json"
394 );
395
396 fn inner() -> Arc<dyn Tokenizer> {
397 Arc::new(HuggingFaceTokenizer::from_file(TINYLLAMA_PATH).expect("load TinyLlama"))
398 }
399
400 fn specials() -> Vec<String> {
401 vec!["<s>".into(), "</s>".into()]
402 }
403
404 fn collect_token_usage(
405 tokenizer: CachedTokenizer,
406 ) -> (CachedTokenizer, Arc<Mutex<Vec<CacheTokenUsage>>>) {
407 let events = Arc::new(Mutex::new(Vec::new()));
408 let observed = events.clone();
409 let tokenizer = tokenizer.with_token_observer(Arc::new(move |usage| {
410 observed.lock().unwrap().push(usage);
411 }));
412 (tokenizer, events)
413 }
414
415 #[test]
416 fn rejects_hf_tokenizer_that_adds_special_tokens() {
417 let tokenizer: Arc<dyn Tokenizer> = Arc::new(
418 HuggingFaceTokenizer::from_file(TINYLLAMA_PATH)
419 .expect("load TinyLlama")
420 .with_options(crate::TokenizerOptions {
421 add_special_tokens: true,
422 }),
423 );
424
425 let result = CachedTokenizer::new(tokenizer, specials(), 4096);
426 let Err(error) = result else {
427 panic!("add_special_tokens=true must be rejected");
428 };
429 assert_eq!(
430 error.to_string(),
431 "HuggingFace tokenizers configured with add_special_tokens=true must remain uncached"
432 );
433 }
434
435 #[test]
436 fn empty_specials_passes_through_correctly() {
437 let tok = inner();
439 let (cached, events) = collect_token_usage(
440 CachedTokenizer::new(tok.clone(), vec![String::new()], 4096)
441 .expect("TinyLlama must support prefix caching"),
442 );
443 let s = "<s>hello world</s>";
444 let a = cached.encode(s).unwrap();
445 let b = tok.encode(s).unwrap();
446 assert_eq!(a.token_ids(), b.token_ids());
447 let stats = cached.cache_stats();
448 assert_eq!(stats.entries, 0);
449 assert_eq!(stats.misses, 0, "empty specials must not increment misses");
450 assert_eq!(stats.hits, 0);
451 assert!(
452 events.lock().unwrap().is_empty(),
453 "empty specials must not emit token usage"
454 );
455 }
456
457 #[test]
458 fn laguna_overlapping_specials_bypass_cache() {
459 const TOKENIZER_JSON: &str = r#"{
460 "version": "1.0",
461 "truncation": null,
462 "padding": null,
463 "added_tokens": [
464 {"id": 0, "content": "<unk>", "special": true, "single_word": false, "lstrip": false, "rstrip": false, "normalized": false},
465 {"id": 2, "content": "〈|EOS|〉", "special": true, "single_word": false, "lstrip": false, "rstrip": false, "normalized": false},
466 {"id": 14, "content": "〈|", "special": true, "single_word": false, "lstrip": false, "rstrip": false, "normalized": false},
467 {"id": 15, "content": "|〉", "special": true, "single_word": false, "lstrip": false, "rstrip": false, "normalized": false}
468 ],
469 "normalizer": null,
470 "pre_tokenizer": null,
471 "post_processor": null,
472 "decoder": null,
473 "model": {
474 "type": "WordLevel",
475 "vocab": {"<unk>": 0, "〈|EOS|〉": 2, "〈|": 14, "|〉": 15, "tail": 16},
476 "unk_token": "<unk>"
477 }
478 }"#;
479
480 let hf = HfTokenizer::from_bytes(TOKENIZER_JSON).expect("load test tokenizer");
481 let tok: Arc<dyn Tokenizer> = Arc::new(HuggingFaceTokenizer::from_tokenizer(hf));
482 let overlapping = vec!["〈|EOS|〉".into(), "〈|".into(), "|〉".into()];
483 let (cached, events) = collect_token_usage(
484 CachedTokenizer::new(tok.clone(), overlapping, 4096)
485 .expect("HuggingFace tokenizer must support prefix caching"),
486 );
487
488 let expected = tok.encode("〈|EOS|〉").unwrap();
489 assert_eq!(expected.token_ids(), &[2]);
490 assert_eq!(
491 cached.encode("〈|EOS|〉").unwrap().token_ids(),
492 expected.token_ids()
493 );
494 let stats = cached.cache_stats();
495 assert_eq!(stats.entries, 0);
496 assert_eq!(
497 stats.misses, 0,
498 "overlapping specials must not increment misses"
499 );
500 assert_eq!(stats.hits, 0);
501 assert!(
502 events.lock().unwrap().is_empty(),
503 "overlapping specials must not emit token usage"
504 );
505 }
506
507 #[test]
508 fn segmented_encoding_passes_through_without_caching() {
509 let inner: Arc<dyn Tokenizer> = Arc::new(SegmentTokenizer);
510 let segments = [
511 EncodeSegment::new("<ctl>", true),
512 EncodeSegment::new("user content", false),
513 ];
514 let expected = inner.encode_segments(&segments).unwrap();
515
516 for special_tokens in [Vec::new(), vec!["<ctl>".to_string()]] {
517 let l1_enabled = !special_tokens.is_empty();
518 let (cached, events) = collect_token_usage(
519 CachedTokenizer::new(inner.clone(), special_tokens, 4096)
520 .expect("test tokenizer supports prefix caching"),
521 );
522 let actual = cached.encode_segments(&segments).unwrap();
523
524 assert_eq!(actual.token_ids(), expected.token_ids());
525 let stats = cached.cache_stats();
526 assert_eq!(stats.entries, 0);
527 assert_eq!(stats.hits, 0);
528 assert_eq!(stats.misses, 0);
529 let events = events.lock().unwrap();
530 if l1_enabled {
531 assert_eq!(
532 events.as_slice(),
533 &[CacheTokenUsage {
534 cached_tokens: 0,
535 uncached_tokens: expected.token_ids().len(),
536 }]
537 );
538 } else {
539 assert!(events.is_empty());
540 }
541 }
542 }
543
544 #[test]
545 fn token_observer_reports_full_miss_and_partial_hit_with_and_without_extension() {
546 for extend_on_hit in [false, true] {
547 let tok = inner();
548 let hits = Arc::new(AtomicU64::new(0));
549 let misses = Arc::new(AtomicU64::new(0));
550 let hit_counter = hits.clone();
551 let miss_counter = misses.clone();
552 let cached = CachedTokenizer::new(tok, specials(), 64 * 1024)
553 .expect("TinyLlama must support prefix caching")
554 .with_extend(extend_on_hit)
555 .with_observer(
556 Arc::new(move || {
557 hit_counter.fetch_add(1, Ordering::Relaxed);
558 }),
559 Arc::new(move || {
560 miss_counter.fetch_add(1, Ordering::Relaxed);
561 }),
562 );
563 let (cached, events) = collect_token_usage(cached);
564
565 let shared = "<s>system\nYou are helpful.</s><s>user\n";
566 let first = format!("{shared}First question?</s>");
567 let second = format!("{shared}Second different prompt entirely.</s>");
568
569 let first_encoding = cached.encode(&first).unwrap();
570 let second_encoding = cached.encode(&second).unwrap();
571
572 let events = events.lock().unwrap();
573 assert_eq!(events.len(), 2);
574 assert_eq!(
575 events[0],
576 CacheTokenUsage {
577 cached_tokens: 0,
578 uncached_tokens: first_encoding.token_ids().len(),
579 }
580 );
581 assert!(events[1].cached_tokens > 0);
582 assert!(events[1].uncached_tokens > 0);
583 assert_eq!(
584 events[1].cached_tokens + events[1].uncached_tokens,
585 second_encoding.token_ids().len()
586 );
587 assert_eq!(hits.load(Ordering::Relaxed), 1);
588 assert_eq!(misses.load(Ordering::Relaxed), 1);
589 }
590 }
591
592 #[test]
593 fn token_observer_does_not_report_failed_encodes() {
594 let tokenizer: Arc<dyn Tokenizer> = Arc::new(FailingTokenizer);
595 let (cached, events) = collect_token_usage(
596 CachedTokenizer::new(tokenizer, specials(), 4096)
597 .expect("test tokenizer explicitly supports prefix caching"),
598 );
599
600 assert!(cached.encode("<s>this fails</s>").is_err());
601 assert!(events.lock().unwrap().is_empty());
602 }
603
604 #[test]
605 fn two_turn_chat_correctness_and_hit() {
606 let tok = inner();
607 let cached = CachedTokenizer::new(tok.clone(), specials(), 64 * 1024)
608 .expect("TinyLlama must support prefix caching");
609
610 let template = "<s>system\nYou are helpful.</s><s>user\n";
611 let first = format!("{template}First question?</s>");
612 let second = format!("{template}Second different prompt entirely.</s>");
613
614 let _ = cached.encode(&first).unwrap();
616
617 let cached_second = cached.encode(&second).unwrap();
619 let plain_second = tok.encode(&second).unwrap();
620 assert_eq!(
621 cached_second.token_ids(),
622 plain_second.token_ids(),
623 "cached encode must equal plain encode for second turn"
624 );
625
626 let stats = cached.cache_stats();
627 assert!(stats.hits >= 1, "expected L1 hit on second request");
628 }
629
630 #[test]
631 fn decode_passes_through() {
632 let tok = inner();
633 let cached = CachedTokenizer::new(tok.clone(), specials(), 4096)
634 .expect("TinyLlama must support prefix caching");
635 let enc = cached.encode("<s>hello</s>").unwrap();
636 let direct = tok.decode(enc.token_ids(), false).unwrap();
637 let through = cached.decode(enc.token_ids(), false).unwrap();
638 assert_eq!(direct, through);
639 }
640
641 #[test]
642 fn encode_batch_uses_cache() {
643 let tok = inner();
644 let (cached, events) = collect_token_usage(
645 CachedTokenizer::new(tok.clone(), specials(), 64 * 1024)
646 .expect("TinyLlama must support prefix caching"),
647 );
648 let shared = "<s>system\nShared persona.</s><s>user\n";
649 let inputs = [
650 format!("{shared}q1</s>"),
651 format!("{shared}q2</s>"),
652 format!("{shared}q3</s>"),
653 ];
654 let refs: Vec<&str> = inputs.iter().map(String::as_str).collect();
655 let outs = cached.encode_batch(&refs).unwrap();
656 assert_eq!(outs.len(), 3);
657 let events = events.lock().unwrap();
658 assert_eq!(events.len(), outs.len());
659 for (event, output) in events.iter().zip(&outs) {
660 assert_eq!(
661 event.cached_tokens + event.uncached_tokens,
662 output.token_ids().len()
663 );
664 }
665 assert_eq!(events[0].cached_tokens, 0);
666 assert!(events[1..].iter().all(|event| event.cached_tokens > 0));
667 assert!(cached.cache_stats().hits >= 2, "expected hits on q2 and q3");
669 }
670
671 #[test]
672 fn vocab_introspection_forwards_to_inner() {
673 let tok = inner();
674 let cached = CachedTokenizer::new(tok.clone(), specials(), 4096)
675 .expect("TinyLlama must support prefix caching");
676 assert_eq!(cached.vocab_size(), tok.vocab_size());
677 assert_eq!(
678 cached.token_to_id("<s>").unwrap(),
679 tok.token_to_id("<s>").unwrap()
680 );
681 assert_eq!(
682 cached.special_token_ids().unwrap(),
683 tok.special_token_ids().unwrap()
684 );
685 }
686
687 #[test]
688 fn special_token_accounting_matches_cached_encoder_behavior() {
689 let cached = CachedTokenizer::new(inner(), specials(), 4096)
690 .expect("TinyLlama must support prefix caching")
691 .with_options(crate::TokenizerOptions {
692 add_special_tokens: true,
693 });
694 let cached_ids = cached.encode("hello").unwrap();
695 let hf_ids = HuggingFaceTokenizer::from_file(TINYLLAMA_PATH)
696 .expect("load TinyLlama")
697 .with_options(crate::TokenizerOptions {
698 add_special_tokens: true,
699 })
700 .encode("hello")
701 .unwrap();
702
703 assert_eq!(cached.num_special_tokens_added().unwrap(), 0);
704 assert_eq!(hf_ids.token_ids().len(), cached_ids.token_ids().len() + 1);
705 assert_eq!(&hf_ids.token_ids()[1..], cached_ids.token_ids());
706 }
707
708 #[test]
709 fn unoverridden_introspection_methods_use_defaults() {
710 let tokenizer = SegmentTokenizer;
711 assert_eq!(tokenizer.vocab_size(), None);
712 assert!(tokenizer.token_to_id("anything").is_err());
713 assert!(tokenizer.special_token_ids().is_err());
714 assert!(tokenizer.num_special_tokens_added().is_err());
715 }
716}