litellm/tests/test_litellm/rust_bridge/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

134 lines
5.6 KiB
Python

from collections.abc import Generator
from typing import Final
import pytest
import tiktoken
from tokenizers import Tokenizer
import litellm
from litellm.litellm_core_utils.tokenizer import HuggingFaceTokenizer, OpenAIEncoding
from litellm.rust_bridge import configuration, tokenizer
from litellm.utils import _select_tokenizer
from tests.test_litellm.litellm_core_utils.test_decode_special_tokens import TOKENIZER_JSON
@pytest.fixture(autouse=True)
def isolated_configuration(monkeypatch: pytest.MonkeyPatch) -> Generator[None]:
monkeypatch.delenv("LITELLM_RUST", raising=False)
configuration.reset_rust_configuration()
yield
tokenizer.TOKENIZER.reset()
configuration.reset_rust_configuration()
@pytest.mark.parametrize("environment", (None, "0", "1"))
@pytest.mark.parametrize("process", (None, False, True))
def test_tokenizer_factories_follow_rollout(
monkeypatch: pytest.MonkeyPatch, environment: str | None, process: bool | None
) -> None:
configuration.rust(process)
if environment is not None:
monkeypatch.setenv("LITELLM_RUST", environment)
enabled: Final = environment == "1" if environment is not None else process is True
encoding: Final = tokenizer.get_encoding("cl100k_base")
custom: Final = litellm.create_tokenizer(TOKENIZER_JSON)
reference: Final = Tokenizer.from_str(TOKENIZER_JSON)
assert isinstance(encoding, OpenAIEncoding if enabled else tiktoken.Encoding)
assert isinstance(custom["tokenizer"], HuggingFaceTokenizer if enabled else Tokenizer)
assert encoding.encode("café 漢字 🙂") == tiktoken.get_encoding(encoding.name).encode("café 漢字 🙂")
assert litellm.encode(text="Hello World", custom_tokenizer=custom) == reference.encode("Hello World").ids
assert litellm.token_counter(text="Hello World", custom_tokenizer=custom) == len(reference.encode("Hello World"))
def test_missing_native_binding_keeps_python_tokenizer_api() -> None:
configuration.rust(True)
tokenizer.TOKENIZER.override(None)
encoding: Final = tokenizer.get_encoding("cl100k_base")
custom: Final = litellm.create_tokenizer(TOKENIZER_JSON)["tokenizer"]
assert isinstance(encoding, tiktoken.Encoding)
assert isinstance(custom, Tokenizer)
custom.enable_padding(pad_id=0, pad_token="[UNK]")
assert [item.ids for item in custom.encode_batch(["Hello", "Hello World"])] == [[3, 1, 0], [3, 1, 2]]
def test_cached_selection_follows_backend_changes(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "disable_hf_tokenizer_download", True)
configuration.rust(True)
native: Final = _select_tokenizer("dispatch-fixture")["tokenizer"]
configuration.rust(False)
python: Final = _select_tokenizer("dispatch-fixture")["tokenizer"]
assert isinstance(native, OpenAIEncoding)
assert isinstance(python, tiktoken.Encoding)
assert native.encode("hello") == python.encode("hello")
def test_declined_native_factory_falls_back_before_tokenizing() -> None:
from litellm.rust_bridge._native import RustBridgeDeclined
class UnavailableTokenizer:
@staticmethod
def from_json(json: str) -> None:
raise RustBridgeDeclined("huggingface feature is disabled")
configuration.rust(True)
binding: Final = tokenizer._as_factory(UnavailableTokenizer)
tokenizer.TOKENIZER.override(binding)
custom: Final = litellm.create_tokenizer(TOKENIZER_JSON)
assert isinstance(custom["tokenizer"], Tokenizer)
assert (
litellm.decode(tokens=litellm.encode(text="Hello World", custom_tokenizer=custom), custom_tokenizer=custom)
== "Hello World"
)
@pytest.mark.parametrize(
("model", "text"),
(
("gpt-4o", "hello <|endoftext|> world"),
("gpt-3.5-turbo", "café 漢字 🙂"),
("text-davinci-003", " def f():\n return 1\n"),
("tokenizer-parity-fixture", "<SOS>hello<EOT> again"),
),
)
def test_public_token_api_is_identical_across_backends(monkeypatch: pytest.MonkeyPatch, model: str, text: str) -> None:
"""`litellm.token_counter`, `encode` and `decode` return the same values whichever backend
the catalog picks; only the object types differ."""
monkeypatch.setattr(litellm, "anthropic_models", {*litellm.anthropic_models, "tokenizer-parity-fixture"})
messages: Final = [{"role": "user", "content": text}, {"role": "assistant", "content": "ok"}]
def observe() -> tuple[int, int, list[int], str]:
ids: Final = litellm.encode(model=model, text=text)
return (
litellm.token_counter(model=model, text=text),
litellm.token_counter(model=model, messages=messages),
ids,
litellm.decode(model=model, tokens=ids),
)
configuration.rust(False)
python: Final = observe()
configuration.rust(True)
rust: Final = observe()
assert rust == python
def test_cached_huggingface_tokenizers_follow_backend_changes(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.litellm_core_utils.tokenizer import HuggingFaceTokenizer as RustHuggingFaceTokenizer
from litellm.utils import _load_huggingface_tokenizer
monkeypatch.setattr(litellm, "anthropic_models", {*litellm.anthropic_models, "tokenizer-cache-fixture"})
_load_huggingface_tokenizer.cache_clear()
configuration.rust(True)
native: Final = _select_tokenizer("tokenizer-cache-fixture")["tokenizer"]
configuration.rust(False)
python: Final = _select_tokenizer("tokenizer-cache-fixture")["tokenizer"]
configuration.rust(True)
assert isinstance(native, RustHuggingFaceTokenizer)
assert isinstance(python, Tokenizer)
assert _select_tokenizer("tokenizer-cache-fixture")["tokenizer"] is native