litellm/tests/test_litellm_rust/test_tokenizer.py
devin-ai-integration[bot] 0abd9267c1
feat(tokenizer): preserve Python defaults with opt-in Rust dispatch (#42174)
* ci: benchmark and gate an installed release wheel

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: simplify installed-wheel benchmark check

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(rust): add native tokenizer codec

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(tokenizer): route Python tokenization through the Rust extension

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(lint): format tokenizer call

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(packaging): restore runtime dependencies and native images

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(tokenizer): preserve Python SDK behavior with Rust tokenizers

* fix(tokenizer): restore compatibility paths

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(tokenizer): count custom tokenizers directly

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(tokenizer): preserve caller-supplied Python tokenizer counts

* fix(tokenizer): reuse packaged vocabularies in the native wheel

* refactor(rust_bridge): route token counting through the catalog as RUST_OPT_IN

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(spend_tracking): compare tokenizer groups by value

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore(deps): re-resolve filelock under the <4.0 pin

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(llms): align transformation override signatures with base configs

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* build(rust): use fat LTO to keep the native wheel under the 35 MB limit

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(tokenizer): preserve Python defaults with opt-in Rust dispatch

* test(proxy): tolerate missing litellm.utils.Tokenizer when patching it

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(proxy): patch the tokenizer dispatch function instead of the removed alias

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(tokenizer): give the Rust wrappers the tiktoken and tokenizers surface

Callers of litellm.encoding and litellm.create_tokenizer must see the same
read-only API whichever backend the catalog selects.

- OpenAIEncoding mirrors tiktoken.Encoding: n_vocab, max_token_value,
  token_byte_values, encode_single_token, encode_with_unstable,
  encode_to_numpy, decode_with_offsets, is_special_token, repr; the Rust
  tiktoken crate keeps a Vocabulary beside each CoreBPE and reports the
  requested encoding name (gpt2 stays gpt2).
- HuggingFaceTokenizer mirrors the read-only tokenizers.Tokenizer surface
  (token_to_id, id_to_token, get_vocab, get_vocab_size,
  get_added_tokens_decoder, num_special_tokens_to_add, padding, truncation,
  encode_special_tokens, from_buffer); HuggingFaceEncoding gains the
  char/word/token lookups, pad, truncate, set_sequence_id and merge.
  Mutators stay on the Python tokenizer.
- from_json/from_pretrained claim the fork gate only when the huggingface
  feature is compiled in; the surrogate fallback matches on the Codec.
- Tokenizer caching is keyed on the same catalog Context the dispatch runs
  on; rust_tokenizer reads the encoding name without loading an encoding;
  LITELLM_RUST parsing is cached.
- Drop the unused tiktoken_encoding_for_model export and Error::Download.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(tokenizer): close the exhaustive matches with assert_never

CodeQL reads a `match` over a Literal with no default arm as an implicit
`None` return. `assert_never` makes the exhaustiveness explicit for both the
HuggingFace tokenizer loader and the Rust token-counter factory.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(tokenizer): derive the fast counter from the shared tokenizer

The count-only counter (`fast` feature) and the codec each parsed the same
artifact: TokenCounter took the Anthropic JSON and the tiktoken rank files
from Python while Tokenizer loaded them again. One parse now serves both.

- FastTokenizer builds from a model another loader holds: `from_shared`
  takes the Arc<tokenizers::Tokenizer> the HF codec keeps, and
  `from_*_pairs` take the ranks the tiktoken vocabulary already parsed.
- `FastCounter::fast_counter` in the core crate derives it from either codec;
  encodings the fast scanner does not reproduce are refused.
- Native `Tokenizer.count(text, fast=False)` opts into that counter, built
  once per tokenizer on first use; `TokenCounter.from_tokenizer(tokenizer,
  fast=False)` replaces the JSON and rank-file constructors.
- The Python route counts over the native tokenizers the codec path shares
  (`native_encoding`, `native_anthropic`) and no longer reads rank files;
  the packaged Anthropic tokenizer has one loader, `tokenizer_dispatch.anthropic`.
- Public wrappers gain `count(text, fast=False)`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 04:41:11 +00:00

130 lines
5.5 KiB
Python

import json
from typing import Final
import pytest
import tiktoken
from tokenizers import Tokenizer as ReferenceTokenizer
from litellm.rust_bridge import _native
from litellm.utils import claude_json_str
from tests.test_litellm.litellm_core_utils.test_decode_special_tokens import TOKENIZER_JSON
pytestmark = pytest.mark.requires_rust_extension
def test_tiktoken_codec_round_trips_and_counts() -> None:
tokenizer: Final = _native.Tokenizer.from_tiktoken("cl100k_base")
encoded: Final = tokenizer.encode("hello world")
assert tokenizer.name == "cl100k_base"
assert tokenizer.count("hello world") == len(encoded)
assert tokenizer.decode(encoded) == "hello world"
def test_huggingface_codec_skips_special_tokens() -> None:
tokenizer: Final = _native.Tokenizer.from_json(claude_json_str)
encoded: Final = tokenizer.encode("<SOS>hello<EOT>")
assert "<SOS>" in tokenizer.decode(encoded, skip_special_tokens=False)
assert tokenizer.decode(encoded, skip_special_tokens=True) == "hello"
def test_tiktoken_codec_keeps_the_requested_encoding_name() -> None:
assert _native.Tokenizer.from_tiktoken("gpt2").name == "gpt2"
assert _native.Tokenizer.from_tiktoken("r50k_base").name == "r50k_base"
assert _native.Tokenizer.from_tiktoken("gpt2").encode("hi") == _native.Tokenizer.from_tiktoken("r50k_base").encode(
"hi"
)
def test_tiktoken_codec_exposes_its_vocabulary() -> None:
reference: Final = tiktoken.get_encoding("cl100k_base")
tokenizer: Final = _native.Tokenizer.from_tiktoken("cl100k_base")
assert tokenizer.special_tokens() == reference._special_tokens
assert tokenizer.max_token_value() == reference.max_token_value
assert tokenizer.token_byte_values() == reference.token_byte_values()
assert tokenizer.encode_single_token(b"hello") == reference.encode_single_token("hello")
assert tokenizer.is_special_token(reference.eot_token) and not tokenizer.is_special_token(0)
with pytest.raises(KeyError):
tokenizer.encode_single_token(b"<|not-a-token|>")
def test_huggingface_codec_rejects_tiktoken_only_calls() -> None:
tokenizer: Final = _native.Tokenizer.from_json(claude_json_str)
with pytest.raises(ValueError, match="requires a tiktoken encoding"):
tokenizer.token_byte_values()
with pytest.raises(ValueError, match="requires a Hugging Face tokenizer"):
_native.Tokenizer.from_tiktoken("cl100k_base").get_vocab()
def test_unknown_tiktoken_encoding_raises_value_error() -> None:
with pytest.raises(ValueError, match="unsupported tokenizer"):
_native.Tokenizer.from_tiktoken("unknown-encoding")
def test_tiktoken_codec_decodes_truncated_unicode_like_python() -> None:
reference: Final = tiktoken.get_encoding("cl100k_base")
tokenizer: Final = _native.Tokenizer.from_tiktoken(reference.name)
encoded: Final = reference.encode("🙂漢字")
assert tuple(tokenizer.decode(encoded[:end]) for end in range(1, len(encoded) + 1)) == tuple(
reference.decode(encoded[:end]) for end in range(1, len(encoded) + 1)
)
FAST_TEXTS: Final = (
"",
"hello world <|endoftext|>",
"café 漢字 ع 🙂 line\r\n indented 123456789",
"<SOS>x<EOT> a\u0301",
)
def test_fast_counting_is_an_opt_in_over_the_same_loaded_tokenizer() -> None:
for tokenizer in (
_native.Tokenizer.from_tiktoken("cl100k_base"),
_native.Tokenizer.from_tiktoken("o200k_base"),
_native.Tokenizer.from_json(claude_json_str),
):
assert [tokenizer.count(text, fast=True) for text in FAST_TEXTS] == [
tokenizer.count(text) for text in FAST_TEXTS
]
@pytest.mark.parametrize(
"name", ("cl100k_base", "o200k_base", "o200k_harmony", "p50k_base", "p50k_edit", "r50k_base", "gpt2")
)
@pytest.mark.asyncio
async def test_token_counter_counts_over_a_shared_tokenizer(name: str) -> None:
messages: Final = [{"role": "user", "content": "hello wide world"}, {"role": "assistant", "content": "ok"}]
body: Final = json.dumps({"model": "gpt-4", "messages": messages}).encode()
tokenizer: Final = _native.Tokenizer.from_tiktoken(name)
reference: Final = tiktoken.get_encoding(name)
for text in FAST_TEXTS:
assert tokenizer.count(text, fast=True) == tokenizer.count(text) == len(reference.encode_ordinary(text))
exact: Final = await _native.TokenCounter.from_tokenizer(tokenizer).acount_request(body)
fast: Final = await _native.TokenCounter.from_tokenizer(tokenizer, fast=True).acount_request(body)
assert exact == fast
assert exact["input_tokens"] == 3 + sum(
3 + len(reference.encode_ordinary(message["role"])) + len(reference.encode_ordinary(message["content"]))
for message in messages
)
@pytest.mark.parametrize("configured", (False, True))
@pytest.mark.asyncio
async def test_fast_count_preserves_huggingface_configuration(configured: bool) -> None:
reference: Final = ReferenceTokenizer.from_str(TOKENIZER_JSON)
if configured:
reference.enable_truncation(max_length=3)
reference.enable_padding(pad_id=0, pad_token="[UNK]", length=5)
tokenizer: Final = _native.Tokenizer.from_json(reference.to_str())
counter: Final = _native.TokenCounter.from_tokenizer(tokenizer, fast=True)
for text in ("", "Hello", "Hello World Hello World", "[BOS] Hello"):
expected: Final = len(reference.encode(text))
assert tokenizer.count(text, fast=True) == tokenizer.count(text) == expected
result: Final = await counter.acount_request(json.dumps({"prompt": text}).encode())
assert result["input_tokens"] == expected