cerberust/scanners/
token_limit.rs1use crate::scanner::{
16 Direction, Disposition, HoldBack, ScanCtx, ScanResult, Scanner, ScannerId, Verdict,
17};
18
19const CHARS_PER_TOKEN: usize = 4;
23
24#[must_use]
28pub fn approx_token_count(text: &str) -> usize {
29 text.split_whitespace()
30 .map(|chunk| chunk.chars().count().div_ceil(CHARS_PER_TOKEN))
31 .sum()
32}
33
34#[derive(Debug, Clone)]
36pub struct TokenLimitScanner {
37 max_tokens: usize,
38}
39
40impl TokenLimitScanner {
41 pub const ID: ScannerId = ScannerId("native:token-limit");
43
44 #[must_use]
46 pub fn new(max_tokens: usize) -> Self {
47 Self { max_tokens }
48 }
49
50 #[must_use]
52 pub fn max_tokens(&self) -> usize {
53 self.max_tokens
54 }
55}
56
57impl Scanner for TokenLimitScanner {
58 fn id(&self) -> ScannerId {
59 Self::ID
60 }
61
62 fn direction(&self) -> Direction {
63 Direction::Input
64 }
65
66 fn disposition(&self) -> Disposition {
67 Disposition::Block
68 }
69
70 fn scan<'a>(&self, text: &'a str, _ctx: &mut ScanCtx) -> ScanResult<'a> {
71 let over = approx_token_count(text) > self.max_tokens;
72 let risk = if over { 1.0 } else { 0.0 };
73 Ok(Verdict::detected(text, risk, 1.0))
74 }
75
76 fn hold_back(&self) -> HoldBack {
77 HoldBack::WholeStream
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 #![allow(
87 clippy::unwrap_used,
88 clippy::expect_used,
89 reason = "tests assert on known-good values"
90 )]
91 use super::*;
92
93 #[test]
94 fn under_limit_passes() {
95 let scanner = TokenLimitScanner::new(10);
96 let mut ctx = ScanCtx::new();
97 let verdict = scanner.scan("a short prompt", &mut ctx).unwrap();
98 assert!(verdict.valid);
99 }
100
101 #[test]
102 fn over_limit_blocks() {
103 let scanner = TokenLimitScanner::new(3);
104 let mut ctx = ScanCtx::new();
105 let verdict = scanner
106 .scan("one two three four five six", &mut ctx)
107 .unwrap();
108 assert!(!verdict.valid);
109 assert!((verdict.risk - 1.0).abs() < f32::EPSILON);
110 }
111
112 #[test]
113 fn long_whitespace_free_run_counts_multiple_tokens() {
114 assert_eq!(approx_token_count(&"x".repeat(40)), 10);
116 }
117
118 #[test]
119 fn whitespace_only_is_zero_tokens() {
120 assert_eq!(approx_token_count(" \t\n "), 0);
121 }
122
123 #[test]
124 fn exactly_at_limit_passes() {
125 let scanner = TokenLimitScanner::new(4);
126 let mut ctx = ScanCtx::new();
127 assert!(scanner.scan("aa bb cc dd", &mut ctx).unwrap().valid);
129 }
130}