1mod l1;
57
58use std::sync::Arc;
59
60pub use l1::{CacheEventFn, L1Cache, L1CacheStats};
61
62use crate::{
63 EncodeSegment, Encoding, Result, TokenIdType,
64 traits::{DecodeResult, Decoder, Encoder, Tokenizer},
65};
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct CacheTokenUsage {
73 pub cached_tokens: usize,
75 pub uncached_tokens: usize,
77}
78
79pub type CacheTokenUsageFn = Arc<dyn Fn(CacheTokenUsage) + Send + Sync>;
81
82pub struct CachedTokenizer {
87 inner: Arc<dyn Tokenizer>,
88 l1: L1Cache,
89 l1_enabled: bool,
93 extend_on_hit: bool,
96 token_observer: Option<CacheTokenUsageFn>,
98}
99
100impl CachedTokenizer {
101 pub fn new(
116 inner: Arc<dyn Tokenizer>,
117 special_tokens: Vec<String>,
118 max_memory_bytes: usize,
119 ) -> Result<Self> {
120 inner.validate_prefix_cache()?;
121
122 let l1_enabled = !special_tokens.is_empty();
123 Ok(Self {
124 inner,
125 l1: L1Cache::new(max_memory_bytes, special_tokens),
126 l1_enabled,
127 extend_on_hit: false,
128 token_observer: None,
129 })
130 }
131
132 pub fn with_extend(mut self, enabled: bool) -> Self {
137 self.extend_on_hit = enabled;
138 self
139 }
140
141 pub fn with_observer(mut self, on_hit: CacheEventFn, on_miss: CacheEventFn) -> Self {
145 self.l1.set_observer(on_hit, on_miss);
146 self
147 }
148
149 pub fn with_token_observer(mut self, observer: CacheTokenUsageFn) -> Self {
157 self.token_observer = Some(observer);
158 self
159 }
160
161 fn observe_token_usage(&self, cached_tokens: usize, total_tokens: usize) {
162 if let Some(observer) = &self.token_observer {
163 let uncached_tokens = total_tokens
164 .checked_sub(cached_tokens)
165 .expect("cached token count cannot exceed total token count");
166 observer(CacheTokenUsage {
167 cached_tokens,
168 uncached_tokens,
169 });
170 }
171 }
172
173 pub fn cache_stats(&self) -> L1CacheStats {
175 self.l1.stats()
176 }
177
178 pub fn clear_cache(&self) {
180 self.l1.clear();
181 }
182
183 pub fn inner(&self) -> &Arc<dyn Tokenizer> {
185 &self.inner
186 }
187}
188
189impl Encoder for CachedTokenizer {
190 fn encode(&self, input: &str) -> Result<Encoding> {
191 if !self.l1_enabled {
196 return self.inner.encode(input);
197 }
198
199 if let Some((prefix_tokens, prefix_len, deepest_boundary)) =
200 self.l1.longest_prefix_match(input)
201 {
202 let cached_tokens = prefix_tokens.len();
203 let suffix = &input[prefix_len..];
204 let encoding = if suffix.is_empty() {
205 Encoding::Sp(prefix_tokens.to_vec())
206 } else if self.extend_on_hit {
207 Encoding::Sp(self.l1.extend_after_match(
211 input,
212 prefix_tokens,
213 prefix_len,
214 deepest_boundary,
215 self.inner.as_ref(),
216 )?)
217 } else {
218 let suffix_enc = self.inner.encode(suffix)?;
219 let mut merged: Vec<TokenIdType> =
222 Vec::with_capacity(prefix_tokens.len() + suffix_enc.token_ids().len());
223 merged.extend_from_slice(&prefix_tokens);
224 merged.extend_from_slice(suffix_enc.token_ids());
225 Encoding::Sp(merged)
226 };
227 self.observe_token_usage(cached_tokens, encoding.token_ids().len());
228 return Ok(encoding);
229 }
230
231 let encoding = Encoding::Sp(self.l1.populate_and_encode(input, self.inner.as_ref())?);
237 self.observe_token_usage(0, encoding.token_ids().len());
238 Ok(encoding)
239 }
240
241 fn encode_batch(&self, inputs: &[&str]) -> Result<Vec<Encoding>> {
242 if !self.l1_enabled {
246 return self.inner.encode_batch(inputs);
247 }
248
249 inputs.iter().map(|&i| self.encode(i)).collect()
253 }
254
255 fn encode_segments(&self, segments: &[EncodeSegment<'_>]) -> Result<Encoding> {
256 let encoding = self.inner.encode_segments(segments)?;
260 if self.l1_enabled {
261 self.observe_token_usage(0, encoding.token_ids().len());
262 }
263 Ok(encoding)
264 }
265}
266
267impl Decoder for CachedTokenizer {
268 fn decode(&self, token_ids: &[TokenIdType], skip_special_tokens: bool) -> Result<DecodeResult> {
269 self.inner.decode(token_ids, skip_special_tokens)
271 }
272}
273
274impl Tokenizer for CachedTokenizer {}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::HuggingFaceTokenizer;
280 use std::sync::{Mutex, atomic::AtomicU64, atomic::Ordering};
281
282 struct FailingTokenizer;
283
284 struct SegmentTokenizer;
285
286 impl Encoder for SegmentTokenizer {
287 fn encode(&self, input: &str) -> Result<Encoding> {
288 Ok(Encoding::Sp(vec![input.len() as u32]))
289 }
290
291 fn encode_batch(&self, inputs: &[&str]) -> Result<Vec<Encoding>> {
292 inputs.iter().map(|input| self.encode(input)).collect()
293 }
294
295 fn encode_segments(&self, segments: &[EncodeSegment<'_>]) -> Result<Encoding> {
296 let ids = segments
297 .iter()
298 .flat_map(|segment| [segment.allow_special as u32, segment.text.len() as u32])
299 .collect();
300 Ok(Encoding::Sp(ids))
301 }
302 }
303
304 impl Decoder for SegmentTokenizer {
305 fn decode(
306 &self,
307 _token_ids: &[TokenIdType],
308 _skip_special_tokens: bool,
309 ) -> Result<DecodeResult> {
310 Ok(DecodeResult::Complete(String::new()))
311 }
312 }
313
314 impl Tokenizer for SegmentTokenizer {
315 fn validate_prefix_cache(&self) -> Result<()> {
316 Ok(())
317 }
318 }
319
320 impl Encoder for FailingTokenizer {
321 fn encode(&self, _input: &str) -> Result<Encoding> {
322 Err(anyhow::anyhow!("intentional encode failure"))
323 }
324
325 fn encode_batch(&self, _inputs: &[&str]) -> Result<Vec<Encoding>> {
326 Err(anyhow::anyhow!("intentional encode failure"))
327 }
328 }
329
330 impl Decoder for FailingTokenizer {
331 fn decode(
332 &self,
333 _token_ids: &[TokenIdType],
334 _skip_special_tokens: bool,
335 ) -> Result<DecodeResult> {
336 Err(anyhow::anyhow!("intentional decode failure"))
337 }
338 }
339
340 impl Tokenizer for FailingTokenizer {
341 fn validate_prefix_cache(&self) -> Result<()> {
342 Ok(())
343 }
344 }
345
346 const TINYLLAMA_PATH: &str = concat!(
347 env!("CARGO_MANIFEST_DIR"),
348 "/tests/data/sample-models/TinyLlama_v1.1/tokenizer.json"
349 );
350
351 fn inner() -> Arc<dyn Tokenizer> {
352 Arc::new(HuggingFaceTokenizer::from_file(TINYLLAMA_PATH).expect("load TinyLlama"))
353 }
354
355 fn specials() -> Vec<String> {
356 vec!["<s>".into(), "</s>".into()]
357 }
358
359 fn collect_token_usage(
360 tokenizer: CachedTokenizer,
361 ) -> (CachedTokenizer, Arc<Mutex<Vec<CacheTokenUsage>>>) {
362 let events = Arc::new(Mutex::new(Vec::new()));
363 let observed = events.clone();
364 let tokenizer = tokenizer.with_token_observer(Arc::new(move |usage| {
365 observed.lock().unwrap().push(usage);
366 }));
367 (tokenizer, events)
368 }
369
370 #[test]
371 fn rejects_hf_tokenizer_that_adds_special_tokens() {
372 let tokenizer: Arc<dyn Tokenizer> = Arc::new(
373 HuggingFaceTokenizer::from_file(TINYLLAMA_PATH)
374 .expect("load TinyLlama")
375 .with_options(crate::TokenizerOptions {
376 add_special_tokens: true,
377 }),
378 );
379
380 let result = CachedTokenizer::new(tokenizer, specials(), 4096);
381 let Err(error) = result else {
382 panic!("add_special_tokens=true must be rejected");
383 };
384 assert_eq!(
385 error.to_string(),
386 "HuggingFace tokenizers configured with add_special_tokens=true must remain uncached"
387 );
388 }
389
390 #[test]
391 fn empty_specials_passes_through_correctly() {
392 let tok = inner();
397 let (cached, events) = collect_token_usage(
398 CachedTokenizer::new(tok.clone(), Vec::new(), 4096)
399 .expect("TinyLlama must support prefix caching"),
400 );
401 let s = "<s>hello world</s>";
402 let a = cached.encode(s).unwrap();
403 let b = tok.encode(s).unwrap();
404 assert_eq!(a.token_ids(), b.token_ids());
405 let stats = cached.cache_stats();
406 assert_eq!(stats.entries, 0);
407 assert_eq!(stats.misses, 0, "empty specials must not increment misses");
408 assert_eq!(stats.hits, 0);
409 assert!(
410 events.lock().unwrap().is_empty(),
411 "empty specials must not emit token usage"
412 );
413 }
414
415 #[test]
416 fn segmented_encoding_passes_through_without_caching() {
417 let inner: Arc<dyn Tokenizer> = Arc::new(SegmentTokenizer);
418 let segments = [
419 EncodeSegment::new("<ctl>", true),
420 EncodeSegment::new("user content", false),
421 ];
422 let expected = inner.encode_segments(&segments).unwrap();
423
424 for special_tokens in [Vec::new(), vec!["<ctl>".to_string()]] {
425 let l1_enabled = !special_tokens.is_empty();
426 let (cached, events) = collect_token_usage(
427 CachedTokenizer::new(inner.clone(), special_tokens, 4096)
428 .expect("test tokenizer supports prefix caching"),
429 );
430 let actual = cached.encode_segments(&segments).unwrap();
431
432 assert_eq!(actual.token_ids(), expected.token_ids());
433 let stats = cached.cache_stats();
434 assert_eq!(stats.entries, 0);
435 assert_eq!(stats.hits, 0);
436 assert_eq!(stats.misses, 0);
437 let events = events.lock().unwrap();
438 if l1_enabled {
439 assert_eq!(
440 events.as_slice(),
441 &[CacheTokenUsage {
442 cached_tokens: 0,
443 uncached_tokens: expected.token_ids().len(),
444 }]
445 );
446 } else {
447 assert!(events.is_empty());
448 }
449 }
450 }
451
452 #[test]
453 fn token_observer_reports_full_miss_and_partial_hit_with_and_without_extension() {
454 for extend_on_hit in [false, true] {
455 let tok = inner();
456 let hits = Arc::new(AtomicU64::new(0));
457 let misses = Arc::new(AtomicU64::new(0));
458 let hit_counter = hits.clone();
459 let miss_counter = misses.clone();
460 let cached = CachedTokenizer::new(tok, specials(), 64 * 1024)
461 .expect("TinyLlama must support prefix caching")
462 .with_extend(extend_on_hit)
463 .with_observer(
464 Arc::new(move || {
465 hit_counter.fetch_add(1, Ordering::Relaxed);
466 }),
467 Arc::new(move || {
468 miss_counter.fetch_add(1, Ordering::Relaxed);
469 }),
470 );
471 let (cached, events) = collect_token_usage(cached);
472
473 let shared = "<s>system\nYou are helpful.</s><s>user\n";
474 let first = format!("{shared}First question?</s>");
475 let second = format!("{shared}Second different prompt entirely.</s>");
476
477 let first_encoding = cached.encode(&first).unwrap();
478 let second_encoding = cached.encode(&second).unwrap();
479
480 let events = events.lock().unwrap();
481 assert_eq!(events.len(), 2);
482 assert_eq!(
483 events[0],
484 CacheTokenUsage {
485 cached_tokens: 0,
486 uncached_tokens: first_encoding.token_ids().len(),
487 }
488 );
489 assert!(events[1].cached_tokens > 0);
490 assert!(events[1].uncached_tokens > 0);
491 assert_eq!(
492 events[1].cached_tokens + events[1].uncached_tokens,
493 second_encoding.token_ids().len()
494 );
495 assert_eq!(hits.load(Ordering::Relaxed), 1);
496 assert_eq!(misses.load(Ordering::Relaxed), 1);
497 }
498 }
499
500 #[test]
501 fn token_observer_does_not_report_failed_encodes() {
502 let tokenizer: Arc<dyn Tokenizer> = Arc::new(FailingTokenizer);
503 let (cached, events) = collect_token_usage(
504 CachedTokenizer::new(tokenizer, specials(), 4096)
505 .expect("test tokenizer explicitly supports prefix caching"),
506 );
507
508 assert!(cached.encode("<s>this fails</s>").is_err());
509 assert!(events.lock().unwrap().is_empty());
510 }
511
512 #[test]
513 fn two_turn_chat_correctness_and_hit() {
514 let tok = inner();
515 let cached = CachedTokenizer::new(tok.clone(), specials(), 64 * 1024)
516 .expect("TinyLlama must support prefix caching");
517
518 let template = "<s>system\nYou are helpful.</s><s>user\n";
519 let first = format!("{template}First question?</s>");
520 let second = format!("{template}Second different prompt entirely.</s>");
521
522 let _ = cached.encode(&first).unwrap();
524
525 let cached_second = cached.encode(&second).unwrap();
527 let plain_second = tok.encode(&second).unwrap();
528 assert_eq!(
529 cached_second.token_ids(),
530 plain_second.token_ids(),
531 "cached encode must equal plain encode for second turn"
532 );
533
534 let stats = cached.cache_stats();
535 assert!(stats.hits >= 1, "expected L1 hit on second request");
536 }
537
538 #[test]
539 fn decode_passes_through() {
540 let tok = inner();
541 let cached = CachedTokenizer::new(tok.clone(), specials(), 4096)
542 .expect("TinyLlama must support prefix caching");
543 let enc = cached.encode("<s>hello</s>").unwrap();
544 let direct = tok.decode(enc.token_ids(), false).unwrap();
545 let through = cached.decode(enc.token_ids(), false).unwrap();
546 assert_eq!(direct, through);
547 }
548
549 #[test]
550 fn encode_batch_uses_cache() {
551 let tok = inner();
552 let (cached, events) = collect_token_usage(
553 CachedTokenizer::new(tok.clone(), specials(), 64 * 1024)
554 .expect("TinyLlama must support prefix caching"),
555 );
556 let shared = "<s>system\nShared persona.</s><s>user\n";
557 let inputs = [
558 format!("{shared}q1</s>"),
559 format!("{shared}q2</s>"),
560 format!("{shared}q3</s>"),
561 ];
562 let refs: Vec<&str> = inputs.iter().map(String::as_str).collect();
563 let outs = cached.encode_batch(&refs).unwrap();
564 assert_eq!(outs.len(), 3);
565 let events = events.lock().unwrap();
566 assert_eq!(events.len(), outs.len());
567 for (event, output) in events.iter().zip(&outs) {
568 assert_eq!(
569 event.cached_tokens + event.uncached_tokens,
570 output.token_ids().len()
571 );
572 }
573 assert_eq!(events[0].cached_tokens, 0);
574 assert!(events[1..].iter().all(|event| event.cached_tokens > 0));
575 assert!(cached.cache_stats().hits >= 2, "expected hits on q2 and q3");
577 }
578}