use crate::scanner::{
Direction, Disposition, HoldBack, ScanCtx, ScanResult, Scanner, ScannerId, Verdict,
};
const CHARS_PER_TOKEN: usize = 4;
#[must_use]
pub fn approx_token_count(text: &str) -> usize {
text.split_whitespace()
.map(|chunk| chunk.chars().count().div_ceil(CHARS_PER_TOKEN))
.sum()
}
#[derive(Debug, Clone)]
pub struct TokenLimitScanner {
max_tokens: usize,
}
impl TokenLimitScanner {
pub const ID: ScannerId = ScannerId("native:token-limit");
#[must_use]
pub fn new(max_tokens: usize) -> Self {
Self { max_tokens }
}
#[must_use]
pub fn max_tokens(&self) -> usize {
self.max_tokens
}
}
impl Scanner for TokenLimitScanner {
fn id(&self) -> ScannerId {
Self::ID
}
fn direction(&self) -> Direction {
Direction::Input
}
fn disposition(&self) -> Disposition {
Disposition::Block
}
fn scan<'a>(&self, text: &'a str, _ctx: &mut ScanCtx) -> ScanResult<'a> {
let over = approx_token_count(text) > self.max_tokens;
let risk = if over { 1.0 } else { 0.0 };
Ok(Verdict::detected(text, risk, 1.0))
}
fn hold_back(&self) -> HoldBack {
HoldBack::WholeStream
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "tests assert on known-good values"
)]
use super::*;
#[test]
fn under_limit_passes() {
let scanner = TokenLimitScanner::new(10);
let mut ctx = ScanCtx::new();
let verdict = scanner.scan("a short prompt", &mut ctx).unwrap();
assert!(verdict.valid);
}
#[test]
fn over_limit_blocks() {
let scanner = TokenLimitScanner::new(3);
let mut ctx = ScanCtx::new();
let verdict = scanner
.scan("one two three four five six", &mut ctx)
.unwrap();
assert!(!verdict.valid);
assert!((verdict.risk - 1.0).abs() < f32::EPSILON);
}
#[test]
fn long_whitespace_free_run_counts_multiple_tokens() {
assert_eq!(approx_token_count(&"x".repeat(40)), 10);
}
#[test]
fn whitespace_only_is_zero_tokens() {
assert_eq!(approx_token_count(" \t\n "), 0);
}
#[test]
fn exactly_at_limit_passes() {
let scanner = TokenLimitScanner::new(4);
let mut ctx = ScanCtx::new();
assert!(scanner.scan("aa bb cc dd", &mut ctx).unwrap().valid);
}
}