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