use std::fmt;
use tiktoken_rs::CoreBPE;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum TokenizerKind {
#[default]
Heuristic,
Claude,
Gpt4,
Gpt35,
}
impl fmt::Display for TokenizerKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TokenizerKind::Heuristic => write!(f, "heuristic"),
TokenizerKind::Claude => write!(f, "claude"),
TokenizerKind::Gpt4 => write!(f, "gpt-4"),
TokenizerKind::Gpt35 => write!(f, "gpt-3.5"),
}
}
}
impl TokenizerKind {
pub fn parse_name(s: &str) -> Option<Self> {
match s {
"heuristic" => Some(TokenizerKind::Heuristic),
"claude" => Some(TokenizerKind::Claude),
"gpt-4" | "gpt4" => Some(TokenizerKind::Gpt4),
"gpt-3.5" | "gpt3.5" | "gpt-35" => Some(TokenizerKind::Gpt35),
_ => None,
}
}
pub fn valid_names() -> &'static str {
"heuristic, claude, gpt-4, gpt-3.5"
}
}
pub struct Tokenizer {
kind: TokenizerKind,
bpe: Option<CoreBPE>,
}
impl Tokenizer {
pub fn new(kind: TokenizerKind) -> Self {
let bpe = match &kind {
TokenizerKind::Heuristic => None,
TokenizerKind::Claude | TokenizerKind::Gpt4 | TokenizerKind::Gpt35 => {
match tiktoken_rs::cl100k_base() {
Ok(bpe) => Some(bpe),
Err(e) => {
eprintln!(
"Warning: failed to load {} tokenizer: {}, falling back to heuristic",
kind, e
);
None
}
}
}
};
Self { kind, bpe }
}
pub fn count_tokens(&self, content: &str, is_prose: bool) -> usize {
match &self.bpe {
Some(bpe) => bpe.encode_with_special_tokens(content).len(),
None => estimate_tokens_heuristic(content, is_prose),
}
}
pub fn kind(&self) -> &TokenizerKind {
&self.kind
}
pub fn is_real(&self) -> bool {
self.bpe.is_some()
}
}
pub fn estimate_tokens(content: &str, is_prose: bool) -> usize {
estimate_tokens_heuristic(content, is_prose)
}
fn estimate_tokens_heuristic(content: &str, is_prose: bool) -> usize {
let byte_count = content.len();
if is_prose {
byte_count / 4
} else {
byte_count / 3
}
}
pub fn is_prose_extension(ext: &str) -> bool {
matches!(
ext.to_lowercase().as_str(),
"md" | "txt" | "rst" | "adoc" | "textile" | "org" | "wiki"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_estimate_tokens_code() {
let code = "x".repeat(300);
assert_eq!(estimate_tokens(&code, false), 100);
}
#[test]
fn test_estimate_tokens_prose() {
let prose = "x".repeat(400);
assert_eq!(estimate_tokens(&prose, true), 100);
}
#[test]
fn test_estimate_tokens_empty() {
assert_eq!(estimate_tokens("", false), 0);
assert_eq!(estimate_tokens("", true), 0);
}
#[test]
fn test_is_prose_extension() {
assert!(is_prose_extension("md"));
assert!(is_prose_extension("txt"));
assert!(is_prose_extension("rst"));
assert!(!is_prose_extension("rs"));
assert!(!is_prose_extension("py"));
assert!(!is_prose_extension("ts"));
}
#[test]
fn test_tokenizer_kind_parse() {
assert_eq!(TokenizerKind::parse_name("heuristic"), Some(TokenizerKind::Heuristic));
assert_eq!(TokenizerKind::parse_name("claude"), Some(TokenizerKind::Claude));
assert_eq!(TokenizerKind::parse_name("gpt-4"), Some(TokenizerKind::Gpt4));
assert_eq!(TokenizerKind::parse_name("gpt4"), Some(TokenizerKind::Gpt4));
assert_eq!(TokenizerKind::parse_name("gpt-3.5"), Some(TokenizerKind::Gpt35));
assert_eq!(TokenizerKind::parse_name("gpt3.5"), Some(TokenizerKind::Gpt35));
assert_eq!(TokenizerKind::parse_name("invalid"), None);
}
#[test]
fn test_tokenizer_kind_default() {
assert_eq!(TokenizerKind::default(), TokenizerKind::Heuristic);
}
#[test]
fn test_heuristic_tokenizer() {
let tok = Tokenizer::new(TokenizerKind::Heuristic);
assert!(!tok.is_real());
let code = "x".repeat(300);
assert_eq!(tok.count_tokens(&code, false), 100);
}
#[test]
fn test_real_tokenizer_loads() {
let tok = Tokenizer::new(TokenizerKind::Gpt4);
assert!(tok.is_real());
}
#[test]
fn test_real_tokenizer_counts() {
let tok = Tokenizer::new(TokenizerKind::Gpt4);
let count = tok.count_tokens("Hello, world!", false);
assert!(count > 0 && count < 10, "Expected 1-9 tokens, got {}", count);
}
#[test]
fn test_real_tokenizer_known_value() {
let tok = Tokenizer::new(TokenizerKind::Gpt4);
let count = tok.count_tokens("Hello world", false);
assert_eq!(count, 2, "Expected 2 tokens for 'Hello world', got {}", count);
}
#[test]
fn test_real_vs_heuristic_comparison() {
let real = Tokenizer::new(TokenizerKind::Gpt4);
let heuristic = Tokenizer::new(TokenizerKind::Heuristic);
let code = "fn main() {\n println!(\"Hello, world!\");\n}\n";
let real_count = real.count_tokens(code, false);
let heuristic_count = heuristic.count_tokens(code, false);
assert!(real_count > 0);
assert!(heuristic_count > 0);
assert!(real_count < code.len(), "Real count should be less than byte length");
}
#[test]
fn test_heuristic_overestimates_code() {
let real = Tokenizer::new(TokenizerKind::Gpt4);
let heuristic = Tokenizer::new(TokenizerKind::Heuristic);
let code = r#"
use std::collections::HashMap;
pub struct Config {
pub name: String,
pub values: HashMap<String, Vec<u32>>,
}
impl Config {
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
values: HashMap::new(),
}
}
pub fn insert(&mut self, key: &str, val: u32) {
self.values.entry(key.to_string()).or_default().push(val);
}
}
"#;
let real_count = real.count_tokens(code, false);
let heuristic_count = heuristic.count_tokens(code, false);
assert!(
heuristic_count >= real_count,
"Heuristic ({}) should overestimate vs real ({}) for code",
heuristic_count,
real_count
);
}
#[test]
fn test_heuristic_overestimates_prose() {
let real = Tokenizer::new(TokenizerKind::Gpt4);
let heuristic = Tokenizer::new(TokenizerKind::Heuristic);
let prose = "The quick brown fox jumps over the lazy dog. \
This is a longer piece of prose text that should be tokenized \
differently from code. Natural language tends to have longer tokens \
on average compared to source code with its punctuation and symbols.";
let real_count = real.count_tokens(prose, true);
let heuristic_count = heuristic.count_tokens(prose, true);
assert!(real_count > 0, "Real tokenizer should produce tokens for prose");
assert!(heuristic_count > 0, "Heuristic should produce tokens for prose");
let ratio = heuristic_count as f64 / real_count as f64;
assert!(
ratio > 0.5 && ratio < 3.0,
"Heuristic ({}) and real ({}) should be within 3x of each other for prose (ratio: {:.2})",
heuristic_count,
real_count,
ratio
);
}
#[test]
fn test_all_real_tokenizers_produce_same_counts() {
let claude = Tokenizer::new(TokenizerKind::Claude);
let gpt4 = Tokenizer::new(TokenizerKind::Gpt4);
let gpt35 = Tokenizer::new(TokenizerKind::Gpt35);
let text = "fn main() { println!(\"Hello, world!\"); }";
let claude_count = claude.count_tokens(text, false);
let gpt4_count = gpt4.count_tokens(text, false);
let gpt35_count = gpt35.count_tokens(text, false);
assert_eq!(claude_count, gpt4_count, "Claude and GPT-4 should match");
assert_eq!(gpt4_count, gpt35_count, "GPT-4 and GPT-3.5 should match");
}
#[test]
fn test_real_tokenizer_empty_string() {
let tok = Tokenizer::new(TokenizerKind::Gpt4);
assert_eq!(tok.count_tokens("", false), 0);
assert_eq!(tok.count_tokens("", true), 0);
}
#[test]
fn test_real_tokenizer_whitespace_only() {
let tok = Tokenizer::new(TokenizerKind::Gpt4);
let count = tok.count_tokens(" \n\n\t ", false);
assert!(count > 0, "Whitespace should produce at least 1 token, got {}", count);
}
#[test]
fn test_tokenizer_kind_display() {
assert_eq!(format!("{}", TokenizerKind::Heuristic), "heuristic");
assert_eq!(format!("{}", TokenizerKind::Claude), "claude");
assert_eq!(format!("{}", TokenizerKind::Gpt4), "gpt-4");
assert_eq!(format!("{}", TokenizerKind::Gpt35), "gpt-3.5");
}
#[test]
fn test_tokenizer_kind_roundtrip() {
for kind in [
TokenizerKind::Heuristic,
TokenizerKind::Claude,
TokenizerKind::Gpt4,
TokenizerKind::Gpt35,
] {
let display = format!("{}", kind);
let parsed = TokenizerKind::parse_name(&display);
assert_eq!(
parsed,
Some(kind.clone()),
"Roundtrip failed for {}",
display
);
}
}
#[test]
fn test_tokenizer_kind_accessor() {
let tok = Tokenizer::new(TokenizerKind::Claude);
assert_eq!(*tok.kind(), TokenizerKind::Claude);
}
}