fix: implement add_tokens instead of silently ignoring it - #63
Merged
AlonKejzman merged 2 commits intoAug 10, 2026
Merged
Conversation
`_TokenizerShim.add_tokens` and `add_special_tokens` returned 0 and discarded their argument, so the patched `transformers` backend could not grow its vocabulary. That makes fastokens unusable on any checkpoint whose embedding matrix is padded above the tokenizer's token count: loaders reconcile the two by appending placeholder tokens until `len(tokenizer)` reaches the embedding count, then assert it did, and the assertion fails with the vocabulary unchanged. The failure is fail-closed at load, so nothing is mis-tokenized — the backend is simply unavailable on those models. Tracing it surfaced a second stored-but-unused value: `encode_special_tokens`, which `transformers` sets for every `split_special_tokens=True` encode. The shim kept the flag and encoded as if it were unset, so control-token strings in untrusted text still produced control-token ids. Callers pair that flag with `add_special_tokens` to promote ordinary added tokens first, so both no-ops had to go for that path to work. Vocabulary extension is now native. `Tokenizer::add_tokens` rebuilds the added-token matcher and assigns ids the way `tokenizers` does — a content already in the vocabulary keeps its id and only its flags change (this is what promotes a token to special), a content the model already carries becomes an added token at that same id, and anything else is appended contiguously above the vocabulary, which lands padding placeholders exactly on the padded rows. The set is compiled before it is committed so a rejected batch cannot leave a half-extended tokenizer, and the opt-in prefix cache is dropped since its entries predate the new tokens. `AddedTokenPolicy` threads through `encode`, adding the `SkipSpecial` mode that leaves special tokens as ordinary text while the rest of the added vocabulary still matches. The shim now reads added tokens back from the backend rather than the constructor JSON, so extensions survive `to_str`, pickling, deepcopy, and `added_tokens_decoder`. `get_vocab` unions the id scan with the added entries, which also keeps `len(get_vocab()) == get_vocab_size()` when declared ids leave a gap. `get_vocab_size` still answers the full vocabulary for both values of `with_added_tokens`; that divergence is separate and over-reports rather than under-reports, so it is documented in place instead of changed here. Verified against `tokenizers` directly: after padding, both backends report the same size and assign the same ids, and `split_special_tokens` produces identical output. The end-to-end tests assert that equality through `AutoTokenizer` on both supported transformers lines. Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses five confirmed findings from the review of the add-tokens work: - encode_batch_flat ignored split_special_tokens: the bulk flat path hardcoded AddedTokenPolicy::All, so it could not suppress control-token IDs for untrusted input the way encode/encode_batch now can. Thread the parameter through and route via encode_with_policy. - add_special_tokens did not force special=True on AddedToken-like objects, diverging from HuggingFace. An AddedToken(content, special=False) passed to add_special_tokens was registered non-special, so encode_special_tokens would not skip it. Coerce object args to special=True (copying, not mutating, the caller's object). - get_vocab ignored with_added_tokens=False and always returned the full vocab, so transformers.get_added_vocab() (full minus base) saw an empty diff and dropped added tokens on save/reload. Exclude the added set when with_added_tokens=False. - get_added_tokens_decoder and to_str drifted the added-token `normalized` flag: reading it from the backend (serde default false) instead of the source JSON flipped absent-normalized from HF's True default to False, and to_str wrote that back, mutating tokenizer.json on a load/save round-trip. Preserve source entries verbatim, overlay only the live `special` flag (promotions), and append tokens added after construction. Adds Python regression tests for each and updates the .pyi stub. Rust type-checks (cargo check); Python tests require a maturin build to run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AlonKejzman
approved these changes
Aug 10, 2026
Collaborator
|
Thank you for your contribution! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
_TokenizerShim.add_tokensandadd_special_tokensreturned0and threw theirargument away, so the patched
transformersbackend could not grow itsvocabulary. That makes fastokens unusable on any checkpoint whose embedding
matrix is padded above the tokenizer's token count — a common shape. Loaders
reconcile the two by appending placeholder tokens until
len(tokenizer)reachesthe embedding count and then assert that it did:
The failure is fail-closed at load, so nothing is ever mis-tokenized; the
backend is simply unavailable on those models. Models whose tokenizer and
embedding counts already agree never enter the padding branch, which is why this
went unnoticed.
Tracing it surfaced a second stored-but-unused value:
encode_special_tokens,which
transformerssets for everysplit_special_tokens=Trueencode. The shimkept the flag and encoded as though it were unset, so control-token strings in
untrusted text still produced control-token ids. Callers pair that flag with
add_special_tokensto promote ordinary added tokens to special first, so bothno-ops had to go for that path to work at all.
What changed
Vocabulary extension is now native, and ids are assigned the way
tokenizersassigns them so the two backends stay interchangeable under the same loader:
change — this is what promotes a token to
special;leaving the vocabulary size untouched;
padding placeholders exactly on the padded embedding rows.
Tokenizer::add_tokenscompiles the new set before committing it, so a rejectedbatch cannot leave a half-extended tokenizer, and it drops the opt-in prefix
cache, whose entries predate the new tokens.
AddedTokenPolicythreads throughencode, adding theSkipSpecialmode that leaves special tokens as ordinarytext while the rest of the added vocabulary still matches — the exact rule
upstream applies under
encode_special_tokens.On the Python side the shim reads added tokens back from the backend instead of
the constructor JSON, so extensions survive
to_str, pickling, deepcopy, andadded_tokens_decoder.get_vocabunions the id scan with the added entries,which also keeps
len(get_vocab()) == get_vocab_size()when declared ids leave agap.
One divergence is deliberately left alone:
get_vocab_sizeanswers the fullvocabulary for both values of
with_added_tokens, where upstream returns thebase vocabulary for
False. The backend does not track the split, andover-reporting is the safer direction for callers that size embedding tables or
logit masks from it, so it is documented in place rather than changed here.
Test plan
cargo test— 264 pass, 16 new. Includes a parity test that runs the samebatches through
tokenizersand this crate and compares ids andvocabulary sizes across all four cases (new contents, a repeat, a string
the model already has, a promotion to special).
python/tests/test_add_tokens.pycovers the shim directly: padding upto an embedding count, idempotence, promotion, serialization round-trips,
and special-token splitting. Wired into CI alongside the existing
transformers job.
python/tests/test_patch_transformers.pyasserts end-to-end equality withthe unpatched backend for both padding and
split_special_tokens.cargo fmt --check,cargo clippy -- -D warnings.Made with Cursor