litellm/litellm-rust/crates/token-counter/src/unicode_classes.rs
devin-ai-integration[bot] 359b7a8489
feat(rust): count tiktoken cl100k_base admission tokens in Rust (#40777)
* feat(rust): count tiktoken cl100k_base admission tokens in Rust

The Rust admission token counter only had the Anthropic tokenizer, so every
other model (OpenAI gpt-4 family, Azure, Gemini, Bedrock non-Claude, Mistral)
tokenized with tiktoken on the Python inference worker.

Add an exact cl100k_base counter to litellm-token-counter: the vendored rank
file (base64 token / rank lines, the bytes Python's tiktoken uses) is parsed
into a byte-level BPE model and the cl100k split pattern is a handwritten
scanner over the shared Unicode classes, so no regex engine runs per request.
Both tokenizers share the message, tool and reply-priming accounting.

The PyO3 TokenCounter gains a from_cl100k_ranks constructor; Python reads the
rank file and passes it in, the way claude_json_str already works. The bridge
selects the counter through the same predicates litellm.token_counter uses
(huggingface_tokenizer_kind, openai_tokenizer_encoding), declines o200k_base,
downloaded HuggingFace and custom tokenizers to Python, and budget reservation
counts once per distinct tokenizer a request names.

The legacy gpt-3.5-turbo-0301 message accounting (4 per message, -1 per name)
stays in Python: the selector declines it through the predicate token_counter
itself uses.

* feat(rust): count tiktoken o200k_base admission tokens in Rust (#40794)

Add a handwritten o200k_base split scanner and TokenCounter::from_o200k_ranks
next to the cl100k_base counter, sharing MergeRanks and the request
accounting. The Python bridge selects it when openai_tokenizer_encoding
names o200k_base, so gpt-4o, gpt-4.1, gpt-5, o1/o3/o4 and chatgpt-4o
requests stop tokenizing on the Python worker under LITELLM_RUST=true

Co-authored-by: yassin <yassin@berri.ai>

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 23:47:05 +00:00

164 lines
4.7 KiB
Rust

use std::cmp::Ordering;
use std::sync::LazyLock;
use tokenizers::utils::SysRegex;
struct Ranges(Box<[(u32, u32)]>);
pub(super) struct UnicodeClasses {
letters: Ranges,
numbers: Ranges,
spaces: Ranges,
uppers: Ranges,
lowers: Ranges,
}
static CLASSES: LazyLock<Option<UnicodeClasses>> = LazyLock::new(|| {
let scalars: String = (0..=u32::from(char::MAX))
.filter_map(char::from_u32)
.collect();
Some(UnicodeClasses {
letters: Ranges::load(r"\p{L}+", &scalars)?,
numbers: Ranges::load(r"\p{N}+", &scalars)?,
spaces: Ranges::load(r"\s+", &scalars)?,
uppers: Ranges::load(r"[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]+", &scalars)?,
lowers: Ranges::load(r"[\p{Ll}\p{Lm}\p{Lo}\p{M}]+", &scalars)?,
})
});
impl Ranges {
fn load(pattern: &str, scalars: &str) -> Option<Self> {
let regex = SysRegex::new(pattern).ok()?;
let ranges = regex
.find_iter(scalars)
.map(|(start, end)| {
let matched = scalars.get(start..end)?;
Some((
u32::from(matched.chars().next()?),
u32::from(matched.chars().next_back()?),
))
})
.collect::<Option<Box<[_]>>>()?;
Some(Self(ranges))
}
fn contains(&self, character: char) -> bool {
let code = u32::from(character);
self.0
.binary_search_by(|(low, high)| {
if *high < code {
Ordering::Less
} else if *low > code {
Ordering::Greater
} else {
Ordering::Equal
}
})
.is_ok()
}
}
impl UnicodeClasses {
pub(super) fn get() -> Option<&'static Self> {
CLASSES.as_ref()
}
fn is_letter(&self, character: char) -> bool {
self.letters.contains(character)
}
fn is_number(&self, character: char) -> bool {
self.numbers.contains(character)
}
fn is_space(&self, character: char) -> bool {
self.spaces.contains(character)
}
fn is_upper(&self, character: char) -> bool {
self.uppers.contains(character)
}
fn is_lower(&self, character: char) -> bool {
self.lowers.contains(character)
}
}
/// `\p{L}`, `\p{N}`, `\s` and everything else, the character classes the
/// split regexes are written in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Class {
Letter,
Number,
Space,
Other,
}
pub(super) fn class(character: char, unicode_classes: &UnicodeClasses) -> Class {
match character {
'A'..='Z' | 'a'..='z' => Class::Letter,
'0'..='9' => Class::Number,
'\t'..='\r' | ' ' => Class::Space,
_ if character.is_ascii() => Class::Other,
_ if unicode_classes.is_letter(character) => Class::Letter,
_ if unicode_classes.is_number(character) => Class::Number,
_ if unicode_classes.is_space(character) => Class::Space,
_ => Class::Other,
}
}
/// Byte length of the leading run of `run_class` characters.
pub(super) fn run_len(text: &str, run_class: Class, unicode_classes: &UnicodeClasses) -> usize {
text.char_indices()
.find(|(_, character)| class(*character, unicode_classes) != run_class)
.map_or(text.len(), |(index, _)| index)
}
/// Membership in the two letter classes of the o200k split regex,
/// `[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]` and `[\p{Ll}\p{Lm}\p{Lo}\p{M}]`; `Lm`,
/// `Lo` and `M` are in both.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Case {
Upper,
Lower,
Both,
Neither,
}
impl Case {
pub(super) fn is_upper(self) -> bool {
matches!(self, Case::Upper | Case::Both)
}
pub(super) fn is_lower(self) -> bool {
matches!(self, Case::Lower | Case::Both)
}
}
pub(super) fn case(character: char, unicode_classes: &UnicodeClasses) -> Case {
match character {
'A'..='Z' => Case::Upper,
'a'..='z' => Case::Lower,
_ if character.is_ascii() => Case::Neither,
_ => match (
unicode_classes.is_upper(character),
unicode_classes.is_lower(character),
) {
(true, true) => Case::Both,
(true, false) => Case::Upper,
(false, true) => Case::Lower,
(false, false) => Case::Neither,
},
}
}
/// Byte length of the leading run of characters whose case passes `in_class`.
pub(super) fn case_run_len(
text: &str,
in_class: fn(Case) -> bool,
unicode_classes: &UnicodeClasses,
) -> usize {
text.char_indices()
.find(|(_, character)| !in_class(case(*character, unicode_classes)))
.map_or(text.len(), |(index, _)| index)
}