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>
This commit is contained in:
devin-ai-integration[bot] 2026-09-11 16:37:18 -07:00 committed by GitHub
parent b1373d2456
commit f6d332f577
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 5776 additions and 192 deletions

View file

@ -38,6 +38,11 @@ impl TokenCounter {
Self::load(py, || CoreTokenCounter::from_cl100k_ranks(rank_file))
}
#[staticmethod]
fn from_o200k_ranks(py: Python<'_>, rank_file: &str) -> PyResult<Self> {
Self::load(py, || CoreTokenCounter::from_o200k_ranks(rank_file))
}
fn acount_request<'py>(&self, py: Python<'py>, body: &[u8]) -> PyResult<Bound<'py, PyAny>> {
let counter = Arc::clone(&self.inner);
let encode_slots = Arc::clone(&self.encode_slots);

View file

@ -1,61 +1,14 @@
//! Exact token counting for tiktoken's `cl100k_base`. A scanner reproduces the
//! piece boundaries of the encoding's split regex,
//! `'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}++|\p{N}{1,3}+| ?[^\s\p{L}\p{N}]++[\r\n]*+|\s++$|\s*[\r\n]|\s+(?!\S)|\s`,
//! and each piece is merged with the rank file. Special tokens are ordinary
//! text, as with `encode(text, disallowed_special=())`.
//! Scanner for tiktoken's `cl100k_base` split regex,
//! `'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}++|\p{N}{1,3}+| ?[^\s\p{L}\p{N}]++[\r\n]*+|\s++$|\s*[\r\n]|\s+(?!\S)|\s`.
use std::iter;
use super::tiktoken::{MergeRanks, MergeScratch};
use super::scanner::{contraction_len, digit_run_len, is_newline};
use super::unicode_classes::{Class, UnicodeClasses, class, run_len};
use crate::Error;
const MAX_DIGITS_PER_PIECE: usize = 3;
pub(super) struct Cl100kCounter {
ranks: MergeRanks,
unicode_classes: &'static UnicodeClasses,
}
impl Cl100kCounter {
pub(super) fn from_ranks(rank_file: &str) -> Result<Self, Error> {
Ok(Self {
ranks: MergeRanks::parse(rank_file)?,
unicode_classes: UnicodeClasses::get().ok_or(Error::UnicodeClasses)?,
})
}
pub(super) fn count(&self, text: &str) -> usize {
let mut scratch = MergeScratch::default();
pieces(text, self.unicode_classes)
.map(|piece| self.ranks.count_piece(piece.as_bytes(), &mut scratch))
.sum()
}
}
/// The regex matches every character, so the pieces tile the text.
fn pieces<'a>(
text: &'a str,
unicode_classes: &'static UnicodeClasses,
) -> impl Iterator<Item = &'a str> {
iter::successors(split_piece(text, unicode_classes), move |(_, rest)| {
split_piece(rest, unicode_classes)
})
.map(|(piece, _)| piece)
}
fn split_piece<'a>(text: &'a str, unicode_classes: &UnicodeClasses) -> Option<(&'a str, &'a str)> {
let first = text.chars().next()?;
Some(text.split_at(piece_len(text, first, unicode_classes)))
}
/// The alternatives in regex order; the possessive quantifiers mean an
/// alternative that starts matching and runs out of input fails as a whole.
fn piece_len(text: &str, first: char, unicode_classes: &UnicodeClasses) -> usize {
if first == '\''
&& let Some(len) = contraction_len(&text[1..])
{
return 1 + len;
pub(super) fn piece_len(text: &str, first: char, unicode_classes: &UnicodeClasses) -> usize {
if let Some(len) = contraction_len(text) {
return len;
}
let first_class = class(first, unicode_classes);
match first_class {
@ -80,32 +33,6 @@ fn piece_len(text: &str, first: char, unicode_classes: &UnicodeClasses) -> usize
space_run_len(text, unicode_classes)
}
/// `(?i:[sdmt]|ll|ve|re)` after the apostrophe. Simple case folding also maps
/// U+017F (long s) onto `s`.
fn contraction_len(rest: &str) -> Option<usize> {
let mut characters = rest.chars();
let first = characters.next()?;
match first {
's' | 'S' | '\u{17F}' | 'd' | 'D' | 'm' | 'M' | 't' | 'T' => Some(first.len_utf8()),
'l' | 'L' => matches!(characters.next(), Some('l' | 'L')).then_some(2),
'v' | 'V' | 'r' | 'R' => matches!(characters.next(), Some('e' | 'E')).then_some(2),
_ => None,
}
}
fn is_newline(character: char) -> bool {
matches!(character, '\r' | '\n')
}
/// `\p{N}{1,3}+`
fn digit_run_len(text: &str, unicode_classes: &UnicodeClasses) -> usize {
text.chars()
.take(MAX_DIGITS_PER_PIECE)
.take_while(|character| class(*character, unicode_classes) == Class::Number)
.map(char::len_utf8)
.sum()
}
/// `[^\s\p{L}\p{N}]++[\r\n]*+`
fn symbol_run_len(text: &str, unicode_classes: &UnicodeClasses) -> usize {
let symbols = run_len(text, Class::Other, unicode_classes);
@ -139,10 +66,12 @@ mod tests {
use rstest::rstest;
use super::*;
use crate::scanner::pieces;
fn split(text: &str) -> Vec<&str> {
pieces(
text,
piece_len,
UnicodeClasses::get().expect("Oniguruma exposes Unicode classes"),
)
.collect()

View file

@ -2,8 +2,8 @@ use serde::Serialize;
use crate::Error;
use crate::byte_level::ByteLevelCounter;
use crate::cl100k::Cl100kCounter;
use crate::python_json;
use crate::scanner::{SplitPattern, TiktokenCounter};
use crate::tools::format_function_definitions;
use crate::types::{
ContentBlock, ContentItem, CountableRequest, Message, MessageContent, TextValue, ToolChoice,
@ -29,7 +29,7 @@ enum Encoder {
tokenizer: Box<tokenizers::Tokenizer>,
byte_level: Option<ByteLevelCounter>,
},
Cl100k(Cl100kCounter),
Tiktoken(TiktokenCounter),
}
/// A loaded tokenizer plus the message accounting Python applies on top of
@ -57,14 +57,24 @@ impl TokenCounter {
/// Load tiktoken's `cl100k_base` rank file (`base64(token) rank` lines).
/// The host reads the file.
pub fn from_cl100k_ranks(rank_file: &str) -> Result<Self, Error> {
Self::from_tiktoken_ranks(SplitPattern::Cl100k, rank_file)
}
/// Load tiktoken's `o200k_base` rank file (`base64(token) rank` lines).
/// The host reads the file.
pub fn from_o200k_ranks(rank_file: &str) -> Result<Self, Error> {
Self::from_tiktoken_ranks(SplitPattern::O200k, rank_file)
}
fn from_tiktoken_ranks(split: SplitPattern, rank_file: &str) -> Result<Self, Error> {
Ok(Self {
encoder: Encoder::Cl100k(Cl100kCounter::from_ranks(rank_file)?),
encoder: Encoder::Tiktoken(TiktokenCounter::from_ranks(split, rank_file)?),
})
}
pub fn count_text(&self, text: &str) -> Result<usize, Error> {
match &self.encoder {
Encoder::Cl100k(counter) => Ok(counter.count(text)),
Encoder::Tiktoken(counter) => Ok(counter.count(text)),
Encoder::HuggingFace {
tokenizer,
byte_level,

View file

@ -8,7 +8,9 @@ mod byte_level;
mod cl100k;
mod counter;
mod error;
mod o200k;
mod python_json;
mod scanner;
mod tiktoken;
mod tools;
mod types;

View file

@ -0,0 +1,217 @@
//! Scanner for tiktoken's `o200k_base` split regex,
//! `[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]*[\p{Ll}\p{Lm}\p{Lo}\p{M}]+(?i:'s|'t|'re|'ve|'m|'ll|'d)?|[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]+[\p{Ll}\p{Lm}\p{Lo}\p{M}]*(?i:'s|'t|'re|'ve|'m|'ll|'d)?|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n/]*|\s*[\r\n]+|\s+(?!\S)|\s+`.
//! tiktoken runs it with a backtracking engine, so the letter alternatives
//! below reproduce where the greedy quantifiers settle, not only what the
//! classes say.
use super::scanner::{contraction_len, digit_run_len, is_newline};
use super::unicode_classes::{Case, Class, UnicodeClasses, case, case_run_len, class, run_len};
/// The alternatives in regex order: a number is never a letter piece, a
/// letter always is, and only whitespace and symbols reach the last three.
pub(super) fn piece_len(text: &str, first: char, unicode_classes: &UnicodeClasses) -> usize {
let first_class = class(first, unicode_classes);
if first_class == Class::Number {
return digit_run_len(text, unicode_classes);
}
if let Some(len) = letter_piece_len(text, first, first_class, unicode_classes) {
return len;
}
if first_class == Class::Other {
return symbol_run_len(text, unicode_classes);
}
let rest = &text[first.len_utf8()..];
if first == ' '
&& rest
.chars()
.next()
.is_some_and(|character| class(character, unicode_classes) == Class::Other)
{
return 1 + symbol_run_len(rest, unicode_classes);
}
space_run_len(text, unicode_classes)
}
/// The two letter alternatives, each first with then without the optional
/// `[^\r\n\p{L}\p{N}]` prefix: the order the engine tries them in.
fn letter_piece_len(
text: &str,
first: char,
first_class: Class,
unicode_classes: &UnicodeClasses,
) -> Option<usize> {
let prefix = (!is_newline(first) && matches!(first_class, Class::Space | Class::Other))
.then(|| first.len_utf8());
let after_prefix = |shape: fn(&str, &UnicodeClasses) -> Option<usize>| {
prefix.and_then(|prefix| shape(&text[prefix..], unicode_classes).map(|len| prefix + len))
};
after_prefix(upper_then_lower_len)
.or_else(|| upper_then_lower_len(text, unicode_classes))
.or_else(|| after_prefix(upper_run_len))
.or_else(|| upper_run_len(text, unicode_classes))
}
/// `[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]*[\p{Ll}\p{Lm}\p{Lo}\p{M}]+(?i:'s|'t|'re|'ve|'m|'ll|'d)?`.
/// The upper run is greedy; when no lower character follows it, the engine
/// gives characters back until the one it just gave back is lower too, and
/// that single character is the lower run.
fn upper_then_lower_len(text: &str, unicode_classes: &UnicodeClasses) -> Option<usize> {
let upper = case_run_len(text, Case::is_upper, unicode_classes);
let lower = case_run_len(&text[upper..], Case::is_lower, unicode_classes);
let letters = if lower > 0 {
upper + lower
} else {
let (index, last_both) = text[..upper]
.char_indices()
.rev()
.find(|(_, character)| case(*character, unicode_classes).is_lower())?;
index + last_both.len_utf8()
};
Some(letters + contraction_len(&text[letters..]).unwrap_or(0))
}
/// `[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]+[\p{Ll}\p{Lm}\p{Lo}\p{M}]*(?i:'s|'t|'re|'ve|'m|'ll|'d)?`
fn upper_run_len(text: &str, unicode_classes: &UnicodeClasses) -> Option<usize> {
let upper = case_run_len(text, Case::is_upper, unicode_classes);
if upper == 0 {
return None;
}
let letters = upper + case_run_len(&text[upper..], Case::is_lower, unicode_classes);
Some(letters + contraction_len(&text[letters..]).unwrap_or(0))
}
/// `[^\s\p{L}\p{N}]+[\r\n/]*`
fn symbol_run_len(text: &str, unicode_classes: &UnicodeClasses) -> usize {
let symbols = run_len(text, Class::Other, unicode_classes);
symbols
+ text[symbols..]
.bytes()
.take_while(|byte| matches!(byte, b'\r' | b'\n' | b'/'))
.count()
}
/// `\s*[\r\n]+|\s+(?!\S)|\s+`: a run with a newline ends at its last newline,
/// even at the end of the text; otherwise whitespace to the end of the text
/// is one piece, or the run leaves its last character for the next piece's
/// optional leading space.
fn space_run_len(text: &str, unicode_classes: &UnicodeClasses) -> usize {
let run = run_len(text, Class::Space, unicode_classes);
if let Some(newline) = text[..run].rfind(['\r', '\n']) {
return newline + 1;
}
if run == text.len() {
return run;
}
let last = text[..run].chars().next_back().map_or(0, char::len_utf8);
match run - last {
0 => run,
shorter => shorter,
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use tokenizers::utils::SysRegex;
use super::*;
use crate::scanner::pieces;
fn split(text: &str) -> Vec<&str> {
pieces(
text,
piece_len,
UnicodeClasses::get().expect("Oniguruma exposes Unicode classes"),
)
.collect()
}
#[rstest]
#[case("", &[])]
#[case("Hello world", &["Hello", " world"])]
#[case("camelCase PascalCase ABCdef ABCdeF ABC", &["camel", "Case", " Pascal", "Case", " ABCdef", " ABCde", "F", " ABC"])]
#[case("日本ABC ABC日本 日本語abc abc日本語", &["日本", "ABC", " ABC日本", " 日本語abc", " abc日本語"])]
#[case("\u{301}ABC \u{301}abc \u{301}\u{301}A A\u{301}\u{301} E\u{301}A aE\u{301}", &["\u{301}", "ABC", " \u{301}abc", " \u{301}\u{301}", "A", " A\u{301}\u{301}", " E\u{301}", "A", " a", "E\u{301}"])]
#[case("ᵃbc ᵃBC Aᵃbc Aᵃ ᵃ' ᵃ's", &["ᵃbc", "", "BC", " Aᵃbc", " Aᵃ", "", "'", " ᵃ's"])]
#[case("Džungla aDžB ADžB ADžb", &["Džungla", " a", "DžB", " ADžB", " ADžb"])]
#[case("don'tx ABC's abc'S abc'ſ ABC'ſx IT'SOK it'Dbe", &["don't", "x", " ABC's", " abc'S", " abc'ſ", " ABC'ſ", "x", " IT'S", "OK", " it'D", "be"])]
#[case("'sabc x's 's 'Sx'Tx 9'9 a'9 ' s", &["'sabc", " x's", " '", "s", " '", "Sx'T", "x", " ", "9", "'", "9", " a", "'", "9", " '", " s"])]
#[case("!ABC !AbC !!abc !!\u{301}a \u{a0}\u{301}A", &["!ABC", " !", "Ab", "C", " !!", "abc", " !!\u{301}", "a", " ", "\u{a0}\u{301}", "A"])]
#[case("!!/\n/x a/b !!\n/x /x //", &["!!/\n/", "x", " a", "/b", " !!\n/", "x", " ", " /", "x", " ", " //"])]
#[case("12345 6 1abc abc1", &["123", "45", " ", "6", " ", "1", "abc", " abc", "1"])]
#[case("x \n x \r\n \r\n y", &["x", " \n", " x", " \r\n \r\n", " y"])]
#[case("x \n ", &["x", " \n", " "])]
#[case("a b \n\n c", &["a", " ", " b", " \n\n", " ", " c"])]
#[case("x\t\ty x\t\t", &["x", "\t", "\ty", " x", "\t\t"])]
#[case("end ", &["end", " "])]
#[case("\u{a0}abc\u{a0}!", &["\u{a0}abc", "\u{a0}", "!"])]
#[case("<|endoftext|>", &["<|", "endoftext", "|>"])]
#[case("İstanbul ΣΊΣΥΦΟΣ Ελληνικά Русский", &["İstanbul", " ΣΊΣΥΦΟΣ", " Ελληνικά", " Русский"])]
#[case("日本語 ١٢٣٤", &["日本語", " ", "١٢٣", "٤"])]
fn scanner_splits_like_the_regex(#[case] text: &str, #[case] expected: &[&str]) {
assert_eq!(split(text), expected);
}
#[test]
fn every_scalar_alone_is_one_piece() {
for character in (0..=0x10FFFFu32).filter_map(char::from_u32) {
let text = character.to_string();
assert_eq!(
split(&text),
[text.as_str()],
"U+{:04X}",
u32::from(character)
);
}
}
#[test]
fn cases_match_oniguruma() {
let unicode_classes = UnicodeClasses::get().expect("Oniguruma exposes Unicode classes");
let upper = SysRegex::new(r"[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]").expect("regex");
let lower = SysRegex::new(r"[\p{Ll}\p{Lm}\p{Lo}\p{M}]").expect("regex");
let whole =
|regex: &SysRegex, text: &str| regex.find_iter(text).next() == Some((0, text.len()));
let mut text = String::new();
for character in (0..=0x10FFFFu32).filter_map(char::from_u32) {
text.clear();
text.push(character);
let expected = match (whole(&upper, &text), whole(&lower, &text)) {
(true, true) => Case::Both,
(true, false) => Case::Upper,
(false, true) => Case::Lower,
(false, false) => Case::Neither,
};
assert_eq!(
case(character, unicode_classes),
expected,
"U+{:04X}",
u32::from(character)
);
}
}
#[derive(serde::Deserialize)]
struct TextFixture {
text: String,
pieces: Vec<String>,
}
#[test]
fn scanner_splits_the_fixture_corpus_like_tiktoken_regex() {
let fixtures: Vec<TextFixture> = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/o200k/texts.jsonl"
))
.lines()
.map(|line| serde_json::from_str(line).expect("fixture line is json"))
.collect();
assert!(fixtures.len() > 3000);
let mismatches: Vec<_> = fixtures
.iter()
.filter(|fixture| split(&fixture.text) != fixture.pieces)
.map(|fixture| (&fixture.text, split(&fixture.text), &fixture.pieces))
.collect();
assert!(mismatches.is_empty(), "{mismatches:#?}");
}
}

View file

@ -0,0 +1,107 @@
//! Exact token counting for tiktoken encodings. A hand-written scanner
//! reproduces the piece boundaries of the encoding's split regex, and each
//! piece is merged with the rank file. Special tokens are ordinary text, as
//! with `encode(text, disallowed_special=())`.
use std::iter;
use super::tiktoken::{MergeRanks, MergeScratch};
use super::unicode_classes::{Class, UnicodeClasses, class};
use super::{cl100k, o200k};
use crate::Error;
const MAX_DIGITS_PER_PIECE: usize = 3;
/// Byte length of the piece the split regex matches at the start of the
/// text, given the text's first character.
pub(super) type PieceLen = fn(&str, char, &UnicodeClasses) -> usize;
#[derive(Clone, Copy, Debug)]
pub(super) enum SplitPattern {
Cl100k,
O200k,
}
impl SplitPattern {
fn piece_len(self) -> PieceLen {
match self {
Self::Cl100k => cl100k::piece_len,
Self::O200k => o200k::piece_len,
}
}
}
pub(super) struct TiktokenCounter {
ranks: MergeRanks,
piece_len: PieceLen,
unicode_classes: &'static UnicodeClasses,
}
impl TiktokenCounter {
pub(super) fn from_ranks(split: SplitPattern, rank_file: &str) -> Result<Self, Error> {
Ok(Self {
ranks: MergeRanks::parse(rank_file)?,
piece_len: split.piece_len(),
unicode_classes: UnicodeClasses::get().ok_or(Error::UnicodeClasses)?,
})
}
pub(super) fn count(&self, text: &str) -> usize {
let mut scratch = MergeScratch::default();
pieces(text, self.piece_len, self.unicode_classes)
.map(|piece| self.ranks.count_piece(piece.as_bytes(), &mut scratch))
.sum()
}
}
/// The regex matches every character, so the pieces tile the text.
pub(super) fn pieces<'a>(
text: &'a str,
piece_len: PieceLen,
unicode_classes: &'static UnicodeClasses,
) -> impl Iterator<Item = &'a str> {
iter::successors(
split_piece(text, piece_len, unicode_classes),
move |(_, rest)| split_piece(rest, piece_len, unicode_classes),
)
.map(|(piece, _)| piece)
}
fn split_piece<'a>(
text: &'a str,
piece_len: PieceLen,
unicode_classes: &UnicodeClasses,
) -> Option<(&'a str, &'a str)> {
let first = text.chars().next()?;
Some(text.split_at(piece_len(text, first, unicode_classes)))
}
/// `'(?i:s|t|re|ve|m|ll|d)`, the contraction both encodings spell out. Simple
/// case folding also maps U+017F (long s) onto `s`.
pub(super) fn contraction_len(text: &str) -> Option<usize> {
let mut characters = text.chars();
if characters.next()? != '\'' {
return None;
}
let first = characters.next()?;
let len = match first {
's' | 'S' | '\u{17F}' | 'd' | 'D' | 'm' | 'M' | 't' | 'T' => first.len_utf8(),
'l' | 'L' => matches!(characters.next(), Some('l' | 'L')).then_some(2)?,
'v' | 'V' | 'r' | 'R' => matches!(characters.next(), Some('e' | 'E')).then_some(2)?,
_ => return None,
};
Some(1 + len)
}
pub(super) fn is_newline(character: char) -> bool {
matches!(character, '\r' | '\n')
}
/// `\p{N}{1,3}`
pub(super) fn digit_run_len(text: &str, unicode_classes: &UnicodeClasses) -> usize {
text.chars()
.take(MAX_DIGITS_PER_PIECE)
.take_while(|character| class(*character, unicode_classes) == Class::Number)
.map(char::len_utf8)
.sum()
}

View file

@ -9,6 +9,8 @@ pub(super) struct UnicodeClasses {
letters: Ranges,
numbers: Ranges,
spaces: Ranges,
uppers: Ranges,
lowers: Ranges,
}
static CLASSES: LazyLock<Option<UnicodeClasses>> = LazyLock::new(|| {
@ -19,6 +21,8 @@ static CLASSES: LazyLock<Option<UnicodeClasses>> = LazyLock::new(|| {
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)?,
})
});
@ -70,6 +74,14 @@ impl UnicodeClasses {
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
@ -101,3 +113,52 @@ pub(super) fn run_len(text: &str, run_class: Class, unicode_classes: &UnicodeCla
.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)
}

File diff suppressed because it is too large Load diff

View file

@ -1,24 +1,27 @@
"""Pin tiktoken cl100k_base reference counts for the Rust parity tests.
"""Pin tiktoken reference counts for the Rust parity tests of one encoding.
Run from the repository root with the project environment:
Run from the repository root with the project environment, once per encoding:
uv run --no-sync python litellm-rust/crates/token-counter/tests/fixtures/cl100k/generate.py
uv run --no-sync python litellm-rust/crates/token-counter/tests/fixtures/generate.py cl100k_base
uv run --no-sync python litellm-rust/crates/token-counter/tests/fixtures/generate.py o200k_base
`texts.jsonl` holds `{"text", "tokens", "pieces"}` lines: `tokens` counted with
`tiktoken.get_encoding("cl100k_base").encode(text, disallowed_special=())`, the
same call `litellm.token_counter` makes, and `pieces` the installed encoding's
split pattern applied with the `regex` module tiktoken itself uses, so a scanner
that splits differently fails even where BPE would count the same. `requests.jsonl` holds
`{"body", "input_tokens"}` lines, `body` being the exact request bytes as a JSON
string, counted with the proxy's admission counter
(`_count_input_tokens(body, "gpt-4")`). Every message in the 50k-token body is
shorter than the Python chunk size so the chunked Python count equals the exact
whole-text tiktoken count the Rust counter produces.
`<encoding>/texts.jsonl` holds `{"text", "tokens", "pieces"}` lines: `tokens`
counted with `tiktoken.get_encoding(name).encode(text, disallowed_special=())`,
the same call `litellm.token_counter` makes, and `pieces` the installed
encoding's split pattern applied with the `regex` module tiktoken itself uses,
so a scanner that splits differently fails even where BPE would count the same.
`<encoding>/requests.jsonl` holds `{"body", "input_tokens"}` lines, `body` being
the exact request bytes as a JSON string, counted with the proxy's admission
counter (`_count_input_tokens(body, model)`) for a model Python counts with that
encoding. Every message in the 50k-token body is shorter than the Python chunk
size so the chunked Python count equals the exact whole-text tiktoken count the
Rust counter produces.
"""
import itertools
import json
import random
import sys
from collections.abc import Iterator
from pathlib import Path
from typing import Final
@ -27,13 +30,19 @@ import regex
import tiktoken
from litellm.constants import TIKTOKEN_ENCODE_CHUNK_SIZE_CHARS
from litellm.litellm_core_utils.token_counter import openai_tokenizer_encoding
from litellm.proxy.spend_tracking.budget_reservation import _count_input_tokens
HERE: Final = Path(__file__).resolve().parent
ENCODING: Final = tiktoken.get_encoding("cl100k_base")
MODELS: Final = {"cl100k_base": "gpt-4", "o200k_base": "gpt-4o"}
ENCODING_NAME: Final = sys.argv[1]
MODEL: Final = MODELS[ENCODING_NAME]
ENCODING: Final = tiktoken.get_encoding(ENCODING_NAME)
assert openai_tokenizer_encoding(MODEL).name == ENCODING_NAME
SPLIT_PATTERN: Final = regex.compile(ENCODING._pat_str) # pyright: ignore[reportPrivateUsage] # tiktoken has no public accessor
OUT: Final = HERE / ENCODING_NAME.removesuffix("_base")
# Mirrors ALPHABET in src/byte_level.rs, plus the pieces the cl100k pattern treats differently.
# Mirrors ALPHABET in src/byte_level.rs, plus the pieces the tiktoken patterns treat differently.
ALPHABET: Final = (
"a",
"Z",
@ -114,6 +123,24 @@ ALPHABET: Final = (
"Dž",
)
# The pieces the o200k case-shaped letter branch and slash-absorbing symbol branch split differently.
CASE_ALPHABET: Final = ALPHABET + (
"B",
"Ab",
"aB",
"ABC",
"",
"camelCase",
"HTTPServer",
"iOS",
"Džungla",
"/",
"\n/",
"/\r\n",
" \n ",
"a/b",
)
CORPUS: Final = (
"",
"Hello, how are you today?",
@ -159,6 +186,15 @@ CORPUS: Final = (
"'s't're've'm'll'd 'S'T'RE'VE'M'LL'D ''s '''s",
"9'9 9's a'9 '9 ' 's' ' 's",
"١٢٣٤ ½⅓¼ ⅣⅤ 𝟘𝟙𝟚𝟛𝟜𝟝𝟞𝟟𝟠𝟡 ①②③",
"camelCase PascalCase ABCdef ABCdeF ABC aB Ab ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzABC",
"日本ABC ABC日本 日本語abc abc日本語 漢字Kanji kanji漢字 KANJI漢字kanji مرحباABC ABCمرحبا abcمرحبا",
"\u0301ABC \u0301abc \u0301\u0301A A\u0301\u0301 E\u0301A aE\u0301 !!\u0301a \u00a0\u0301A x\u0308Y X\u0308y",
"ᵃbc ᵃBC Aᵃbc Aᵃ ᵃ''s Džungla aDžB ADžB ADžb DžDž Ljx İi ΣΊΣΥΦΟΣσ ΣσΣ",
"don'tx ABC's abc'S abc'ſ ABC'ſx IT'SOK it'Dbe 'sabc x's 's 'Sx'Tx xs X'LLx X'Ll",
"!ABC !AbC !!abc #camelCase (ABCdef) \u00a0ABC\u00a0abc\u00a0Abc \tABC\tabc",
"!!/\n/x a/b !!\n/x /x // path/to/file.rs http://x.y/z?a=b/c \\/\\/ //\r\n//\n",
"x \n x \r\n \r\n y x \n a b \n\n c x\t\ty x\t\t end \n \n",
"12345 6 1abc abc1 ABC123abc 123ABC ١٢٣٤٥abc",
)
WORDS: Final = (
@ -244,8 +280,8 @@ WORDS: Final = (
)
def random_text(rng: random.Random) -> str:
return "".join(rng.choice(ALPHABET) for _ in range(rng.randrange(0, 40)))
def random_text(rng: random.Random, alphabet: tuple[str, ...]) -> str:
return "".join(rng.choice(alphabet) for _ in range(rng.randrange(0, 40)))
def paragraph(rng: random.Random, words: int) -> str:
@ -265,7 +301,7 @@ def chat_body(rng: random.Random, target_tokens: int) -> dict[str, object]:
turns: Final = next(index for index, total in enumerate(running) if total >= target_tokens) + 1
contents: Final = candidates[: turns + (turns % 2)]
return {
"model": "gpt-4",
"model": MODEL,
"messages": [
{"role": "system", "content": "You are a helpful assistant. Answer precisely and cite sources."},
*(
@ -305,9 +341,9 @@ TOOLS: Final = [
]
SMALL_REQUESTS: Final = (
{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello, how are you today?"}]},
{"model": MODEL, "messages": [{"role": "user", "content": "Hello, how are you today?"}]},
{
"model": "gpt-4",
"model": MODEL,
"messages": [
{"role": "system", "content": "You are a terse assistant."},
{
@ -322,21 +358,21 @@ SMALL_REQUESTS: Final = (
],
},
{
"model": "gpt-4",
"model": MODEL,
"messages": [{"role": "user", "content": "weather?"}],
"tools": TOOLS,
"tool_choice": {"type": "function", "function": {"name": "get_weather"}},
},
{
"model": "gpt-4",
"model": MODEL,
"messages": [{"role": "system", "content": "sys"}, {"role": "user", "content": "weather?"}],
"tools": TOOLS,
"tool_choice": "none",
},
{"model": "gpt-4", "prompt": "Write a haiku about ships."},
{"model": "gpt-4", "prompt": ["first prompt", "second prompt"]},
{"model": MODEL, "prompt": "Write a haiku about ships."},
{"model": MODEL, "prompt": ["first prompt", "second prompt"]},
{
"model": "gpt-4",
"model": MODEL,
"input": [
{
"role": "user",
@ -348,9 +384,9 @@ SMALL_REQUESTS: Final = (
],
"instructions": "be terse",
},
{"model": "gpt-4", "input": [[101, 2023, 5], [7]], "encoding_format": "float"},
{"model": MODEL, "input": [[101, 2023, 5], [7]], "encoding_format": "float"},
{
"model": "gpt-4",
"model": MODEL,
"query": "best harbour",
"documents": [
"doc one",
@ -362,16 +398,22 @@ SMALL_REQUESTS: Final = (
def main() -> None:
rng: Final = random.Random(2026)
texts: Final = tuple(CORPUS) + tuple(random_text(rng) for _ in range(3000))
with (HERE / "texts.jsonl").open("w", encoding="utf-8") as handle:
case_rng: Final = random.Random(200_000)
texts: Final = (
tuple(CORPUS)
+ tuple(random_text(rng, ALPHABET) for _ in range(3000))
+ tuple(random_text(case_rng, CASE_ALPHABET) for _ in range(1000))
)
OUT.mkdir(exist_ok=True)
with (OUT / "texts.jsonl").open("w", encoding="utf-8") as handle:
for text in texts:
tokens = len(ENCODING.encode(text, disallowed_special=()))
pieces = SPLIT_PATTERN.findall(text)
handle.write(json.dumps({"text": text, "tokens": tokens, "pieces": pieces}, ensure_ascii=False) + "\n")
bodies: Final = tuple(SMALL_REQUESTS) + (chat_body(rng, 50_000),)
with (HERE / "requests.jsonl").open("w", encoding="utf-8") as handle:
with (OUT / "requests.jsonl").open("w", encoding="utf-8") as handle:
for body in bodies:
input_tokens = _count_input_tokens(dict(body), "gpt-4")
input_tokens = _count_input_tokens(dict(body), MODEL)
assert input_tokens is not None
handle.write(json.dumps({"body": json.dumps(body), "input_tokens": input_tokens}) + "\n")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -176,21 +176,47 @@ fn loading_a_bad_tokenizer_is_a_load_error() {
assert!(matches!(TokenCounter::from_json("{}"), Err(Error::Load(_))));
}
fn cl100k_counter() -> TokenCounter {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../litellm/litellm_core_utils/tokenizers/9b5ad71b2ce5302211f9c61530b329a4922fc6a4"
);
let ranks = std::fs::read_to_string(path).expect("cl100k rank file is in the repo");
TokenCounter::from_cl100k_ranks(&ranks).expect("cl100k ranks load")
/// A tiktoken encoding: its fixture directory, the vendored rank file Python
/// loads, the constructor, and the model `generate.py` counted the requests for.
#[derive(Clone, Copy)]
struct TiktokenEncoding {
fixtures: &'static str,
rank_file: &'static str,
load: fn(&str) -> Result<TokenCounter, Error>,
model: &'static str,
}
fn cl100k_fixture(name: &str) -> String {
const CL100K: TiktokenEncoding = TiktokenEncoding {
fixtures: "cl100k",
rank_file: "9b5ad71b2ce5302211f9c61530b329a4922fc6a4",
load: TokenCounter::from_cl100k_ranks,
model: "gpt-4",
};
const O200K: TiktokenEncoding = TiktokenEncoding {
fixtures: "o200k",
rank_file: "fb374d419588a4632f3f557e76b4b70aebbca790",
load: TokenCounter::from_o200k_ranks,
model: "gpt-4o",
};
fn tiktoken_counter(encoding: TiktokenEncoding) -> TokenCounter {
let path = format!(
"{}/tests/fixtures/cl100k/{name}",
env!("CARGO_MANIFEST_DIR")
"{}/../../../litellm/litellm_core_utils/tokenizers/{}",
env!("CARGO_MANIFEST_DIR"),
encoding.rank_file
);
std::fs::read_to_string(&path).expect("fixture generated by tests/fixtures/cl100k/generate.py")
let ranks = std::fs::read_to_string(&path).expect("rank file is in the repo");
(encoding.load)(&ranks).expect("ranks load")
}
fn tiktoken_fixture(encoding: TiktokenEncoding, name: &str) -> String {
let path = format!(
"{}/tests/fixtures/{}/{name}",
env!("CARGO_MANIFEST_DIR"),
encoding.fixtures
);
std::fs::read_to_string(&path).expect("fixture generated by tests/fixtures/generate.py")
}
#[derive(Deserialize)]
@ -205,12 +231,14 @@ struct RequestFixture {
input_tokens: usize,
}
/// Reference counts come from `tiktoken.get_encoding("cl100k_base")`; see
/// `tests/fixtures/cl100k/generate.py`.
#[test]
fn cl100k_text_counts_match_tiktoken() {
let counter = cl100k_counter();
let fixtures: Vec<TextFixture> = cl100k_fixture("texts.jsonl")
/// Reference counts come from `tiktoken.get_encoding(name)`; see
/// `tests/fixtures/generate.py`.
#[rstest]
#[case::cl100k(CL100K)]
#[case::o200k(O200K)]
fn tiktoken_text_counts_match_tiktoken(#[case] encoding: TiktokenEncoding) {
let counter = tiktoken_counter(encoding);
let fixtures: Vec<TextFixture> = tiktoken_fixture(encoding, "texts.jsonl")
.lines()
.map(|line| serde_json::from_str(line).expect("fixture line is json"))
.collect();
@ -229,12 +257,14 @@ fn cl100k_text_counts_match_tiktoken() {
}
/// Reference counts come from the proxy's admission counter
/// (`_count_input_tokens(body, "gpt-4")`), so this pins the shared message,
/// tool and reply-priming accounting on the cl100k path as well.
#[test]
fn cl100k_request_counts_match_python_admission_counter() {
let counter = cl100k_counter();
let fixtures: Vec<RequestFixture> = cl100k_fixture("requests.jsonl")
/// (`_count_input_tokens(body, model)`), so this pins the shared message,
/// tool and reply-priming accounting on the tiktoken paths as well.
#[rstest]
#[case::cl100k(CL100K)]
#[case::o200k(O200K)]
fn tiktoken_request_counts_match_python_admission_counter(#[case] encoding: TiktokenEncoding) {
let counter = tiktoken_counter(encoding);
let fixtures: Vec<RequestFixture> = tiktoken_fixture(encoding, "requests.jsonl")
.lines()
.map(|line| serde_json::from_str(line).expect("fixture line is json"))
.collect();
@ -243,7 +273,7 @@ fn cl100k_request_counts_match_python_admission_counter() {
.map(|fixture| {
let request = CountableRequest::parse(fixture.body.as_bytes()).expect("fixture parses");
let count = counter.count_request(&request).expect("fixture counts");
assert_eq!(count.model.as_deref(), Some("gpt-4"));
assert_eq!(count.model.as_deref(), Some(encoding.model));
assert_eq!(count.input_tokens, fixture.input_tokens, "{}", fixture.body);
count.input_tokens
})
@ -251,9 +281,13 @@ fn cl100k_request_counts_match_python_admission_counter() {
assert!(counts.last().is_some_and(|tokens| *tokens >= 50_000));
}
#[test]
fn cl100k_shares_the_message_accounting_with_the_anthropic_path() {
let counter = cl100k_counter();
#[rstest]
#[case::cl100k(CL100K)]
#[case::o200k(O200K)]
fn tiktoken_shares_the_message_accounting_with_the_anthropic_path(
#[case] encoding: TiktokenEncoding,
) {
let counter = tiktoken_counter(encoding);
let count = |body: &str| {
counter
.count_request(&CountableRequest::parse(body.as_bytes()).expect("parses"))
@ -279,9 +313,9 @@ fn cl100k_shares_the_message_accounting_with_the_anthropic_path() {
#[case::missing_rank("YQ==")]
#[case::rank_not_a_number("YQ== x")]
#[case::single_byte_tokens_missing("YWI= 0")]
fn loading_a_bad_rank_file_is_a_load_error(#[case] rank_file: &str) {
assert!(matches!(
TokenCounter::from_cl100k_ranks(rank_file),
Err(Error::Ranks(_))
));
fn loading_a_bad_rank_file_is_a_load_error(
#[case] rank_file: &str,
#[values(CL100K, O200K)] encoding: TiktokenEncoding,
) {
assert!(matches!((encoding.load)(rank_file), Err(Error::Ranks(_))));
}

View file

@ -16,6 +16,7 @@ except (ImportError, AttributeError):
filename = pkg_resources.resource_filename(__name__, "litellm_core_utils/tokenizers")
CL100K_BASE_RANK_FILE: Final = "9b5ad71b2ce5302211f9c61530b329a4922fc6a4"
O200K_BASE_RANK_FILE: Final = "fb374d419588a4632f3f557e76b4b70aebbca790"
def cl100k_base_rank_file() -> str:
@ -23,6 +24,11 @@ def cl100k_base_rank_file() -> str:
return Path(filename, CL100K_BASE_RANK_FILE).read_text(encoding="ascii")
def o200k_base_rank_file() -> str:
"""The vendored tiktoken `o200k_base` rank file (`base64(token) rank` lines)."""
return Path(filename, O200K_BASE_RANK_FILE).read_text(encoding="ascii")
# Always default TIKTOKEN_CACHE_DIR to the bundled tokenizers directory
# unless the user explicitly overrides it via CUSTOM_TIKTOKEN_CACHE_DIR.
# This keeps tiktoken fully offline-capable by default (see #1071).

View file

@ -1365,8 +1365,8 @@ async def count_request_input_tokens(
Tokenizing is the reservation path's dominant CPU cost and is O(prompt), so
counting a large prompt inline stalls every other request on the worker.
Models whose tokenizer the Rust bridge ports (Anthropic, tiktoken cl100k_base)
are counted from the raw body by the bridge when it is enabled, once per
Models whose tokenizer the Rust bridge ports (Anthropic, tiktoken cl100k_base
and o200k_base) are counted from the raw body by the bridge when it is enabled, once per
distinct tokenizer, which parses and tokenizes with the GIL released.
Everything it declines is counted in Python, large prompts in a worker
thread. The counts are reused by both the max-cost and the input-cost

View file

@ -11,14 +11,14 @@ from pydantic import TypeAdapter
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.default_encoding import cl100k_base_rank_file
from litellm.litellm_core_utils.default_encoding import cl100k_base_rank_file, o200k_base_rank_file
from litellm.litellm_core_utils.token_counter import openai_tokenizer_encoding, uses_legacy_message_accounting
from litellm.rust_bridge.bindings import NativeBinding
from litellm.rust_bridge.configuration import rust_enabled
from litellm.rust_bridge.runtime import BridgeErrorContext, RustHandled, aattempt
from litellm.utils import claude_json_str, huggingface_tokenizer_kind
RustTokenizer = Literal["anthropic", "cl100k_base"]
RustTokenizer = Literal["anthropic", "cl100k_base", "o200k_base"]
class RustTokenCounter(Protocol):
@ -33,6 +33,9 @@ class RustTokenCounterFactory(Protocol):
def from_cl100k_ranks(self, rank_file: str) -> RustTokenCounter:
raise NotImplementedError
def from_o200k_ranks(self, rank_file: str) -> RustTokenCounter:
raise NotImplementedError
@dataclass(frozen=True, slots=True)
class InputTokenCount:
@ -60,8 +63,9 @@ def rust_tokenizer(model: str) -> RustTokenizer | None:
"""The Rust counter for the tokenizer `litellm.token_counter` selects for `model`, `None` when Python must count.
Mirrors `_select_tokenizer_helper`: the Anthropic tokenizer has a Rust port, the other HuggingFace
downloads do not, and of the tiktoken encodings only `cl100k_base` does. Rust prices every message with
the default constants, so the legacy `gpt-3.5-turbo-0301` accounting stays in Python."""
downloads do not, and of the tiktoken encodings `cl100k_base` and `o200k_base` do (p50k/r50k do not). Rust
prices every message with the default constants, so the legacy `gpt-3.5-turbo-0301` accounting stays in
Python."""
if litellm.disable_token_counter is True:
return None
kind: Final = None if litellm.disable_hf_tokenizer_download is True else huggingface_tokenizer_kind(model)
@ -69,7 +73,13 @@ def rust_tokenizer(model: str) -> RustTokenizer | None:
return "anthropic"
if kind is not None or uses_legacy_message_accounting(model):
return None
return "cl100k_base" if openai_tokenizer_encoding(model).name == "cl100k_base" else None
match openai_tokenizer_encoding(model).name:
case "cl100k_base":
return "cl100k_base"
case "o200k_base":
return "o200k_base"
case _:
return None
@lru_cache(maxsize=4)
@ -79,6 +89,8 @@ def _counter(factory: RustTokenCounterFactory, tokenizer: RustTokenizer) -> Rust
return factory(claude_json_str)
case "cl100k_base":
return factory.from_cl100k_ranks(cl100k_base_rank_file())
case "o200k_base":
return factory.from_o200k_ranks(o200k_base_rank_file())
async def count_input_tokens(body: bytes, tokenizer: RustTokenizer) -> InputTokenCount | None:

View file

@ -211,9 +211,12 @@ def test_deployment_pricing_update_invalidates_cached_estimate() -> None:
ANTHROPIC_TOKENIZER_MODEL: Final = "claude-sonnet-4-5-20250929"
CL100K_MODEL: Final = "gpt-4"
O200K_MODEL: Final = "gpt-4o"
RUST_COUNTED_BODY: Final = {"model": ANTHROPIC_TOKENIZER_MODEL, "max_tokens": 16, "messages": ANTHROPIC_MESSAGES}
RUST_INPUT_TOKENS: Final = 4_321
RUST_INPUT_TOKENS_BY_TOKENIZER: Final = MappingProxyType({"anthropic": RUST_INPUT_TOKENS, "cl100k_base": 1_234})
RUST_INPUT_TOKENS_BY_TOKENIZER: Final = MappingProxyType(
{"anthropic": RUST_INPUT_TOKENS, "cl100k_base": 1_234, "o200k_base": 2_345}
)
class _FakeDeclined(Exception):
@ -242,7 +245,7 @@ class _RecordingCounter:
class _RecordingFactory:
"""Stands in for the native `TokenCounter` class: called with tokenizer JSON, or `from_cl100k_ranks`."""
"""Stands in for the native `TokenCounter` class: called with tokenizer JSON, or `from_*_ranks`."""
def __init__(self) -> None:
self.calls: list[tuple[rust_token_counter.RustTokenizer, bytes]] = []
@ -253,6 +256,9 @@ class _RecordingFactory:
def from_cl100k_ranks(self, rank_file: str) -> _RecordingCounter:
return _RecordingCounter(self, "cl100k_base")
def from_o200k_ranks(self, rank_file: str) -> _RecordingCounter:
return _RecordingCounter(self, "o200k_base")
class _DecliningCounter:
async def acount_request(self, body: bytes) -> object:
@ -266,6 +272,9 @@ class _DecliningFactory:
def from_cl100k_ranks(self, rank_file: str) -> _DecliningCounter:
return _DecliningCounter()
def from_o200k_ranks(self, rank_file: str) -> _DecliningCounter:
return _DecliningCounter()
@pytest.fixture
def rust_counter(monkeypatch: pytest.MonkeyPatch):
@ -323,12 +332,36 @@ async def test_tiktoken_cl100k_models_are_counted_by_rust(rust_counter: None, mo
assert factory.calls == [("cl100k_base", raw_body)]
@pytest.mark.asyncio
@pytest.mark.parametrize("model", (O200K_MODEL, "gpt-5", "o3", "gpt-4.1", "chatgpt-4o-latest"))
async def test_tiktoken_o200k_models_are_counted_by_rust(rust_counter: None, model: str) -> None:
factory: Final = _RecordingFactory()
litellm.rust(True)
rust_token_counter.TOKEN_COUNTER.override(factory)
body: Final = {"model": model, "messages": ANTHROPIC_MESSAGES}
raw_body: Final = json.dumps(body).encode()
counts: Final = await count_request_input_tokens(
request_body=body, route="/v1/chat/completions", llm_router=None, raw_body=raw_body
)
assert dict(counts) == {model: RUST_INPUT_TOKENS_BY_TOKENIZER["o200k_base"]}
assert factory.calls == [("o200k_base", raw_body)]
@pytest.mark.asyncio
async def test_multi_model_request_counts_once_per_tokenizer_and_python_for_the_rest(rust_counter: None) -> None:
factory: Final = _RecordingFactory()
litellm.rust(True)
rust_token_counter.TOKEN_COUNTER.override(factory)
models: Final = (CL100K_MODEL, ANTHROPIC_TOKENIZER_MODEL, "gemini/gemini-2.5-pro", "gpt-4o")
models: Final = (
CL100K_MODEL,
ANTHROPIC_TOKENIZER_MODEL,
"gemini/gemini-2.5-pro",
O200K_MODEL,
"gpt-5",
"replicate/meta/llama-2-70b-chat",
)
body: Final = {"model": list(models), "messages": ANTHROPIC_MESSAGES}
raw_body: Final = json.dumps(body).encode()
python_counts: Final = await count_request_input_tokens(
@ -339,18 +372,20 @@ async def test_multi_model_request_counts_once_per_tokenizer_and_python_for_the_
request_body=body, route="/v1/chat/completions", llm_router=None, raw_body=raw_body
)
assert factory.calls == [("cl100k_base", raw_body), ("anthropic", raw_body)]
assert factory.calls == [("cl100k_base", raw_body), ("anthropic", raw_body), ("o200k_base", raw_body)]
assert dict(counts) == {
CL100K_MODEL: RUST_INPUT_TOKENS_BY_TOKENIZER["cl100k_base"],
"gemini/gemini-2.5-pro": RUST_INPUT_TOKENS_BY_TOKENIZER["cl100k_base"],
ANTHROPIC_TOKENIZER_MODEL: RUST_INPUT_TOKENS,
"gpt-4o": python_counts["gpt-4o"],
O200K_MODEL: RUST_INPUT_TOKENS_BY_TOKENIZER["o200k_base"],
"gpt-5": RUST_INPUT_TOKENS_BY_TOKENIZER["o200k_base"],
"replicate/meta/llama-2-70b-chat": python_counts["replicate/meta/llama-2-70b-chat"],
}
assert counts["gpt-4o"] not in RUST_INPUT_TOKENS_BY_TOKENIZER.values()
assert counts["replicate/meta/llama-2-70b-chat"] not in RUST_INPUT_TOKENS_BY_TOKENIZER.values()
@pytest.mark.asyncio
@pytest.mark.parametrize("model", (ANTHROPIC_TOKENIZER_MODEL, CL100K_MODEL))
@pytest.mark.parametrize("model", (ANTHROPIC_TOKENIZER_MODEL, CL100K_MODEL, O200K_MODEL))
async def test_rust_decline_falls_back_to_python_count(rust_counter: None, model: str) -> None:
litellm.rust(True)
rust_token_counter.TOKEN_COUNTER.override(_DecliningFactory())
@ -373,7 +408,7 @@ async def test_disabled_rust_never_sees_the_raw_body(rust_counter: None) -> None
factory: Final = _RecordingFactory()
litellm.rust(False)
rust_token_counter.TOKEN_COUNTER.override(factory)
body: Final = {"model": [ANTHROPIC_TOKENIZER_MODEL, CL100K_MODEL], "messages": ANTHROPIC_MESSAGES}
body: Final = {"model": [ANTHROPIC_TOKENIZER_MODEL, CL100K_MODEL, O200K_MODEL], "messages": ANTHROPIC_MESSAGES}
counts: Final = await count_request_input_tokens(
request_body=body,
@ -383,13 +418,18 @@ async def test_disabled_rust_never_sees_the_raw_body(rust_counter: None) -> None
)
assert factory.calls == []
assert set(counts) == {ANTHROPIC_TOKENIZER_MODEL, CL100K_MODEL}
assert set(counts) == {ANTHROPIC_TOKENIZER_MODEL, CL100K_MODEL, O200K_MODEL}
assert not set(counts.values()) & set(RUST_INPUT_TOKENS_BY_TOKENIZER.values())
@pytest.mark.asyncio
@pytest.mark.parametrize("model", ("gpt-4o", "o3", "replicate/meta/llama-2-70b-chat"))
async def test_models_without_a_rust_tokenizer_stay_in_python(rust_counter: None, model: str) -> None:
@pytest.mark.parametrize("model", ("replicate/meta/llama-2-70b-chat", "meta-llama/Llama-3-8b", "text-davinci-003"))
async def test_models_without_a_rust_tokenizer_stay_in_python(
rust_counter: None, monkeypatch: pytest.MonkeyPatch, model: str
) -> None:
monkeypatch.setattr(
litellm, "open_ai_chat_completion_models", litellm.open_ai_chat_completion_models | {"text-davinci-003"}
)
factory: Final = _RecordingFactory()
litellm.rust(True)
rust_token_counter.TOKEN_COUNTER.override(factory)

View file

@ -8,6 +8,7 @@ cases need the extension and are skipped when it is not built.
from __future__ import annotations
import json
from types import MappingProxyType
from typing import Final
import pytest
@ -16,6 +17,7 @@ from tokenizers import Tokenizer
import litellm
from litellm.constants import TIKTOKEN_ENCODE_CHUNK_SIZE_CHARS
from litellm.litellm_core_utils.token_counter import openai_tokenizer_encoding
from litellm.proxy.spend_tracking.budget_reservation import _count_input_tokens
from litellm.rust_bridge import bindings, configuration
from litellm.rust_bridge import token_counter as bridge
@ -23,6 +25,9 @@ from litellm.utils import claude_json_str
MODEL: Final = "claude-sonnet-4-5-20250929"
CL100K_MODEL: Final = "gpt-4"
O200K_MODEL: Final = "gpt-4o"
TOKENIZERS: Final[tuple[bridge.RustTokenizer, ...]] = ("anthropic", "cl100k_base", "o200k_base")
RANK_FILE_LINES: Final = MappingProxyType({"cl100k_base": 100_256, "o200k_base": 199_998})
BODY: Final = json.dumps({"model": MODEL, "messages": [{"role": "user", "content": "hello"}]}).encode()
@ -50,7 +55,7 @@ class _RecordingCounter:
class _RecordingFactory:
"""Stands in for the native `TokenCounter` class: callable for tokenizer JSON, `from_cl100k_ranks` for ranks."""
"""Stands in for the native `TokenCounter` class: callable for tokenizer JSON, `from_*_ranks` for rank files."""
def __init__(self) -> None:
self.counters: list[_RecordingCounter] = []
@ -65,6 +70,10 @@ class _RecordingFactory:
self.rank_files.append(rank_file)
return self("cl100k_base")
def from_o200k_ranks(self, rank_file: str) -> _RecordingCounter:
self.rank_files.append(rank_file)
return self("o200k_base")
class _RaisingCounter:
def __init__(self, error: Exception) -> None:
@ -86,6 +95,9 @@ class _RaisingFactory:
def from_cl100k_ranks(self, rank_file: str) -> _RaisingCounter:
return _RaisingCounter(self.error)
def from_o200k_ranks(self, rank_file: str) -> _RaisingCounter:
return _RaisingCounter(self.error)
@pytest.fixture(autouse=True)
def _reset_bridge(monkeypatch: pytest.MonkeyPatch):
@ -100,7 +112,7 @@ def _reset_bridge(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.asyncio
@pytest.mark.parametrize("tokenizer", ("anthropic", "cl100k_base"))
@pytest.mark.parametrize("tokenizer", TOKENIZERS)
async def test_disabled_bridge_never_constructs_a_counter(tokenizer: bridge.RustTokenizer) -> None:
factory: Final = _RecordingFactory()
litellm.rust(False)
@ -127,18 +139,20 @@ async def test_enabled_bridge_returns_typed_count_and_reuses_one_counter() -> No
@pytest.mark.asyncio
async def test_cl100k_counter_is_built_from_the_vendored_rank_file_once() -> None:
@pytest.mark.parametrize("tokenizer", ("cl100k_base", "o200k_base"))
async def test_tiktoken_counter_is_built_from_the_vendored_rank_file_once(tokenizer: bridge.RustTokenizer) -> None:
factory: Final = _RecordingFactory()
litellm.rust(True)
bridge.TOKEN_COUNTER.override(factory)
first: Final = await bridge.count_input_tokens(BODY, "cl100k_base")
second: Final = await bridge.count_input_tokens(BODY, "cl100k_base")
first: Final = await bridge.count_input_tokens(BODY, tokenizer)
second: Final = await bridge.count_input_tokens(BODY, tokenizer)
assert first == second == bridge.InputTokenCount(model=MODEL, input_tokens=42)
assert len(factory.rank_files) == 1
assert factory.rank_files[0].startswith("IQ== 0\n")
assert factory.rank_files[0].count("\n") == 100_256
assert factory.rank_files[0].count("\n") == RANK_FILE_LINES[tokenizer]
assert factory.counters[0].tokenizer_json == tokenizer
assert factory.counters[0].bodies == [BODY, BODY]
@ -150,10 +164,12 @@ async def test_each_tokenizer_gets_its_own_cached_counter() -> None:
await bridge.count_input_tokens(BODY, "anthropic")
await bridge.count_input_tokens(BODY, "cl100k_base")
await bridge.count_input_tokens(BODY, "o200k_base")
await bridge.count_input_tokens(BODY, "anthropic")
await bridge.count_input_tokens(BODY, "o200k_base")
assert [counter.tokenizer_json == "cl100k_base" for counter in factory.counters] == [False, True]
assert [len(counter.bodies) for counter in factory.counters] == [2, 1]
assert [counter.tokenizer_json for counter in factory.counters][1:] == ["cl100k_base", "o200k_base"]
assert [len(counter.bodies) for counter in factory.counters] == [2, 1, 2]
@pytest.mark.asyncio
@ -161,12 +177,11 @@ async def test_missing_native_module_falls_back(monkeypatch: pytest.MonkeyPatch)
litellm.rust(True)
monkeypatch.setattr(bindings, "get_native_bridge", lambda: None)
assert await bridge.count_input_tokens(BODY, "anthropic") is None
assert await bridge.count_input_tokens(BODY, "cl100k_base") is None
assert [await bridge.count_input_tokens(BODY, tokenizer) for tokenizer in TOKENIZERS] == [None, None, None]
@pytest.mark.asyncio
@pytest.mark.parametrize("tokenizer", ("anthropic", "cl100k_base"))
@pytest.mark.parametrize("tokenizer", TOKENIZERS)
async def test_declined_request_falls_back(tokenizer: bridge.RustTokenizer) -> None:
litellm.rust(True)
bridge.TOKEN_COUNTER.override(_RaisingFactory(_FakeDeclined("request has no messages")))
@ -175,7 +190,7 @@ async def test_declined_request_falls_back(tokenizer: bridge.RustTokenizer) -> N
@pytest.mark.asyncio
@pytest.mark.parametrize("tokenizer", ("anthropic", "cl100k_base"))
@pytest.mark.parametrize("tokenizer", TOKENIZERS)
async def test_runtime_failure_falls_back(tokenizer: bridge.RustTokenizer) -> None:
litellm.rust(True)
bridge.TOKEN_COUNTER.override(_RaisingFactory(RuntimeError("encode failed")))
@ -197,11 +212,17 @@ async def test_runtime_failure_falls_back(tokenizer: bridge.RustTokenizer) -> No
("my-router-alias", "cl100k_base"),
("azure/gpt-4o", "cl100k_base"),
("command-r-plus", "cl100k_base"),
("gpt-4o", None),
("gpt-4o-mini", None),
("gpt-4.1", None),
("gpt-5", None),
("o3", None),
("gpt-4o", "o200k_base"),
("gpt-4o-mini", "o200k_base"),
("gpt-4o-2024-08-06", "o200k_base"),
("chatgpt-4o-latest", "o200k_base"),
("gpt-4.1", "o200k_base"),
("gpt-5", "o200k_base"),
("gpt-5-mini", "o200k_base"),
("o1", "o200k_base"),
("o3", "o200k_base"),
("o3-mini", "o200k_base"),
("o4-mini", "o200k_base"),
("replicate/meta/llama-2-70b-chat", None),
("meta-llama/Llama-3-8b", None),
),
@ -210,6 +231,19 @@ def test_rust_tokenizer_mirrors_python_tokenizer_selection(model: str, expected:
assert bridge.rust_tokenizer(model) == expected
@pytest.mark.parametrize(
("model", "python_encoding"),
(("text-davinci-003", "p50k_base"), ("gpt-oss-120b", "o200k_harmony")),
)
def test_rust_tokenizer_declines_tiktoken_encodings_rust_does_not_have(
monkeypatch: pytest.MonkeyPatch, model: str, python_encoding: str
) -> None:
monkeypatch.setattr(litellm, "open_ai_chat_completion_models", litellm.open_ai_chat_completion_models | {model})
assert openai_tokenizer_encoding(model).name == python_encoding
assert bridge.rust_tokenizer(model) is None
def test_rust_tokenizer_declines_the_cohere_tokenizer_download(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "cohere_models", litellm.cohere_models | {"command-r-plus"})
@ -233,18 +267,25 @@ def test_rust_tokenizer_declines_legacy_message_accounting_python_prices_differe
assert bridge.rust_tokenizer(CL100K_MODEL) == "cl100k_base"
@pytest.mark.parametrize("model", (MODEL, "gpt-4", "gpt-4o"))
@pytest.mark.parametrize("model", (MODEL, CL100K_MODEL, O200K_MODEL, "gpt-5", "o3"))
def test_rust_tokenizer_names_the_encoding_python_actually_counts_with(model: str) -> None:
text: Final = "Hello, world! \u00e9\u00e8 12345 \u3053\u3093\u306b\u3061\u306f <|endoftext|>\r\n" * 10
text: Final = (
"Hello, world! camelCase ABCdef \u00e9\u00e8 12345 \u3053\u3093\u306b\u3061\u306f <|endoftext|>\r\n" * 9
)
python_count: Final = litellm.token_counter(model=model, text=text)
cl100k_count: Final = len(tiktoken.get_encoding("cl100k_base").encode(text, disallowed_special=()))
o200k_count: Final = len(tiktoken.get_encoding("o200k_base").encode(text, disallowed_special=()))
assert cl100k_count != o200k_count
match bridge.rust_tokenizer(model):
case "cl100k_base":
assert python_count == cl100k_count
case "o200k_base":
assert python_count == o200k_count
case "anthropic":
assert python_count == len(Tokenizer.from_str(claude_json_str).encode(text).ids) != cl100k_count
assert python_count == len(Tokenizer.from_str(claude_json_str).encode(text).ids)
assert python_count not in {cl100k_count, o200k_count}
case None:
assert python_count != cl100k_count
pytest.fail(f"{model} must have a Rust tokenizer")
def test_disabled_hf_download_routes_anthropic_models_to_cl100k_like_python(monkeypatch: pytest.MonkeyPatch) -> None:
@ -252,7 +293,7 @@ def test_disabled_hf_download_routes_anthropic_models_to_cl100k_like_python(monk
assert bridge.rust_tokenizer(MODEL) == "cl100k_base"
assert bridge.rust_tokenizer("meta-llama/Llama-3-8b") == "cl100k_base"
assert bridge.rust_tokenizer("gpt-4o") is None
assert bridge.rust_tokenizer(O200K_MODEL) == "o200k_base"
def test_disabled_token_counter_declines_every_model(monkeypatch: pytest.MonkeyPatch) -> None:
@ -260,6 +301,7 @@ def test_disabled_token_counter_declines_every_model(monkeypatch: pytest.MonkeyP
assert bridge.rust_tokenizer(MODEL) is None
assert bridge.rust_tokenizer(CL100K_MODEL) is None
assert bridge.rust_tokenizer(O200K_MODEL) is None
PARITY_REQUESTS: Final[tuple[dict[str, object], ...]] = (
@ -328,6 +370,8 @@ PARITY_REQUESTS: Final[tuple[dict[str, object], ...]] = (
PARITY_MODELS: Final[tuple[tuple[str, bridge.RustTokenizer], ...]] = (
(MODEL, "anthropic"),
(CL100K_MODEL, "cl100k_base"),
(O200K_MODEL, "o200k_base"),
("gpt-5", "o200k_base"),
)
@ -351,19 +395,22 @@ async def test_native_count_matches_python_budget_counter(
@pytest.mark.asyncio
async def test_cl100k_counts_long_text_exactly_where_python_chunks(monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize(("model", "tokenizer"), ((CL100K_MODEL, "cl100k_base"), (O200K_MODEL, "o200k_base")))
async def test_tiktoken_counts_long_text_exactly_where_python_chunks(
monkeypatch: pytest.MonkeyPatch, model: str, tokenizer: bridge.RustTokenizer
) -> None:
"""Python encodes tiktoken text in fixed-size chunks (drift of up to one token per chunk boundary); Rust does not."""
native: Final = pytest.importorskip("litellm.rust_bridge._native")
monkeypatch.setattr(bindings, "get_native_bridge", lambda: native)
litellm.rust(True)
text: Final = "x " * 20_000
body: Final = {"model": CL100K_MODEL, "messages": [{"role": "user", "content": text}]}
encoding: Final = tiktoken.get_encoding("cl100k_base")
body: Final = {"model": model, "messages": [{"role": "user", "content": text}]}
encoding: Final = tiktoken.get_encoding(tokenizer)
exact: Final = 3 + len(encoding.encode("user")) + len(encoding.encode(text)) + 3
chunks: Final = -(-len(text) // TIKTOKEN_ENCODE_CHUNK_SIZE_CHARS)
rust_count: Final = await bridge.count_input_tokens(json.dumps(body).encode(), "cl100k_base")
python_count: Final = _count_input_tokens(request_body=body, model=CL100K_MODEL)
rust_count: Final = await bridge.count_input_tokens(json.dumps(body).encode(), tokenizer)
python_count: Final = _count_input_tokens(request_body=body, model=model)
assert rust_count is not None
assert rust_count.input_tokens == exact
@ -385,7 +432,7 @@ DECLINED_REQUESTS: Final[tuple[dict[str, object], ...]] = (
@pytest.mark.asyncio
@pytest.mark.parametrize("tokenizer", ("anthropic", "cl100k_base"))
@pytest.mark.parametrize("tokenizer", TOKENIZERS)
@pytest.mark.parametrize("request_body", DECLINED_REQUESTS)
async def test_native_declines_shapes_python_prices_differently(
monkeypatch: pytest.MonkeyPatch, request_body: dict[str, object], tokenizer: bridge.RustTokenizer