mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-27 01:22:18 +00:00
* 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>
134 lines
5.6 KiB
Python
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
|