This guide provides comprehensive documentation for using Splintr's Python and Rust APIs. For a quick start, see the main README.
The Tokenizer class is the main entry point for tokenization in Python.
Load a pretrained model:
from splintr import Tokenizer
# Load pretrained model (includes vocabulary and special tokens)
tokenizer = Tokenizer.from_pretrained("cl100k_base") # OpenAI GPT-4/3.5
tokenizer = Tokenizer.from_pretrained("o200k_base") # OpenAI GPT-4o
tokenizer = Tokenizer.from_pretrained("llama3") # Meta Llama 3 family
tokenizer = Tokenizer.from_pretrained("deepseek_v3") # DeepSeek V3/R1
tokenizer = Tokenizer.from_pretrained("qwen3") # Qwen 2/3, Baichuan-M2
tokenizer = Tokenizer.from_pretrained("glm4") # GLM-4/4.5
tokenizer = Tokenizer.from_pretrained("gpt-oss") # OpenAI gpt-oss
tokenizer = Tokenizer.from_pretrained("kimi") # Kimi K2/K2.5/K2.6/K2.7
tokenizer = Tokenizer.from_pretrained("kimi_k3") # Kimi K3
tokenizer = Tokenizer.from_pretrained("mistral_v1") # Mistral 7B v0.1/v0.2, Mixtral 8x7B
tokenizer = Tokenizer.from_pretrained("mistral_v2") # Mistral 7B v0.3, Codestral, Mixtral 8x22B
tokenizer = Tokenizer.from_pretrained("mistral_v3") # Mistral NeMo, Large 2, Pixtral
tokenizer = Tokenizer.from_pretrained("whisper_v3") # OpenAI Whisper multilingual (v1/v2/v3; bare "whisper" → v2)Whisper English-only checkpoints (
*.en) use a different base BPE and are not bundled — load those withfrom_json.
from_pretrained returns an AnyTokenizer for every bundled vocabulary — the same universal handle from_json returns, and the same one splintr::pretrained::from_pretrained returns in Rust. It delegates to that one loader, so a vocabulary name means the same thing, and produces the same ids, on both sides of the binding. Query .family for the backend it dispatched to ("BPE" for the byte-level vocabularies, "Spm" for Mistral V1/V2).
Load a raw .tiktoken file:
from splintr import Tokenizer, CL100K_BASE_PATTERN
tokenizer = Tokenizer(
vocab_path="path/to/vocab.tiktoken",
pattern=CL100K_BASE_PATTERN,
special_tokens={"<|endoftext|>": 100257} # optional
)A .tiktoken file is base64(token bytes) rank per line and states nothing else — no pre-tokenizer pattern, no special tokens, no decoder chain — so pattern is required and special_tokens is yours to supply. This is the one loader that returns the concrete Tokenizer rather than an AnyTokenizer: with no model.type to read there is no backend to dispatch on, and it is always byte-level BPE.
Ids are the same as any other route to the same vocabulary — loading crates/vocab-cl100k/vocabs/cl100k_base.tiktoken this way encodes identically to from_pretrained("cl100k_base"). In Rust: Tokenizer::from_file(vocab_path, pattern, special_tokens), or Tokenizer::from_bytes(&data, pattern, special_tokens) for a vocabulary already in memory.
Every Python tokenizer class — Tokenizer, AnyTokenizer, SpmTokenizer, SentencePieceTokenizer, WordPieceTokenizer — exposes the same six encoding methods, and each means the same thing on all of them:
| Method | Meaning | HuggingFace / tiktoken equivalent |
|---|---|---|
encode(text) |
Model-ready: boundary template applied | tokenizer.encode(text) (its default add_special_tokens=True) |
encode_raw(text) |
Content tokens only, no template | tokenizer.encode(text, add_special_tokens=False) |
encode_ordinary(text) |
Never match a special token spelled out in text |
tiktoken allowed_special=set() |
encode_with_special(text) |
Match every special token spelled out in text |
tiktoken allowed_special="all" |
encode_allowed_special(text, allowed) |
Match only the listed ones; ValueError on any other |
tiktoken allowed_special={...} |
encode_batch(texts) |
Batch form of encode, parallel across texts |
tokenizer.encode_batch(texts) |
Two independent questions are at work here, and mixing them up is the one way to get this wrong:
- Boundary tokens — the
[CLS]…[SEP]/<s>…</s>wrapper the model was trained to receive. It comes from the tokenizer'spost_processortemplate, never from the text.encodeadds it;encode_rawdoes not. Every other method on the list also adds it, because refusing to match a special token inside untrusted content says nothing about whether the model wants a BOS. - Special tokens spelled out in the input — a user typing
<|endoftext|>.encode_ordinary/encode_with_special/encode_allowed_specialdecide whether those become real control-token ids.encodeuses the tokenizer's own default (see below).
Encode text to token IDs, model-ready. Sequential processing, optimal for texts under ~1MB.
tokens = tokenizer.encode("Hello, world!")
print(tokens) # [9906, 11, 1917, 0]Vocabularies loaded through Tokenizer.from_pretrained declare no boundary template, so for them encode and encode_raw return the same ids. A tokenizer loaded from a tokenizer.json usually does declare one:
from splintr import from_json
tok = from_json("/path/to/llama-3.2-1b/tokenizer.json")
tok.encode("Hello, world!") # [128000, 9906, 11, 1917, 0] — 128000 is BOS
tok.encode_raw("Hello, world!") # [9906, 11, 1917, 0]Encode text to token IDs, content only — no boundary template. Use it when you assemble the sequence yourself (a chat template, a reranker pair) and place the boundary tokens by hand. Whatever encode adds and this does not is the template.
tok = from_json("/path/to/bge-m3-tokenizer/tokenizer.json")
tok.encode("Hello, world!") # [0, 35378, 4, 8999, 38, 2] — [CLS] … [SEP]
tok.encode_raw("Hello, world!") # [35378, 4, 8999, 38]Encode text, matching every configured special token spelled out in it: the special token becomes its single control-token id rather than being split into ordinary pieces.
tokenizer = Tokenizer.from_pretrained("cl100k_base")
text = "Start <|endoftext|> End"
tokenizer.encode(text) # [3563, 220, 100257, 4060] — 100257 is <|endoftext|>
tokenizer.encode_with_special(text) # [3563, 220, 100257, 4060] — the same
tokenizer.encode_ordinary(text) # [3563, 83739, 8862, 728, 428, 91, 29, 4060]Every loader — from_pretrained, from_json, the GGUF loader — turns added-token matching on, so encode and encode_with_special agree on everything they return; the method exists so the name means the same thing on every tokenizer class. encode_ordinary is how you decline the match. Name the mode explicitly whenever the text is untrusted — see below.
Encode multiple texts in parallel using Rayon — the batch form of encode, with the boundary template applied to each result. This is where Splintr really shines: roughly 10-12x tiktoken's throughput on the batch workloads benchmarks/benchmark_batch.py runs.
texts = ["Hello, world!", "How are you?"]
batch_tokens = tokenizer.encode_batch(texts)
# [[9906, 11, 1917, 0], [4438, 527, 499, 30]]Same result as encode, but Rayon parallelizes within the single text. This is only beneficial for very large texts (>1MB); for typical use cases encode() is faster. A backend with no intra-text parallel path simply runs encode, so the ids never depend on which one you hold.
# Only useful for very large texts
large_text = "..." * 1000000 # >1MB of text
tokens = tokenizer.encode_rayon(large_text)The batch form of encode_with_special, parallel across texts.
The flat counterpart to encode_batch: one contiguous buffer of ids plus the offsets that cut it into rows, as two bytes objects, rather than list[list[int]].
import numpy as np
ids, offsets = tok.encode_batch_flat(texts)
ids = np.frombuffer(ids, dtype=np.uint32) # every token, concatenated
offsets = np.frombuffer(offsets, dtype=np.uint64) # len(texts) + 1 entries
assert ids[offsets[i]:offsets[i + 1]].tolist() == tok.encode_batch(texts)[i]ids is little-endian uint32; offsets is little-endian uint64 and always starts at 0 and ends at the total token count, so offsets[i]:offsets[i + 1] is row i and an empty text is an empty (not missing) row. Both buffers are one memcpy each.
It exists because the object construction dominates: a Python list of ints costs ~18 ns per token, measured at 89% of encode_batch's wall time on a 220k-token batch, which makes the flat form ~4.7x faster end to end. Available on Tokenizer and AnyTokenizer, so from_pretrained, from_json and the raw .tiktoken loader all have it. See Best Practices.
Batch calls also release the GIL for the duration of the encode, so a threaded data loader is no longer serialized against the tokenizer.
A tokenizer that matches special tokens will promote text that spells a control token to that token's real id. <|im_start|> typed by a user becomes the same id the server emits when it opens a turn, and downstream nothing can tell the two apart — that is how a user message forges a system turn. Denylisting the spelling beforehand does not close it: the spelling is not the only thing that maps to the id.
So the three matching modes are explicit methods. In Rust they are the SpecialMode enum passed to Tokenize::encode_with.
from splintr import Tokenizer
tokenizer = Tokenizer.from_pretrained("cl100k_base")
untrusted = "Start <|endoftext|> End"
# Match none: the literal spelling stays ordinary content.
tokenizer.encode_ordinary(untrusted)
# [3563, 83739, 8862, 728, 428, 91, 29, 4060]
# Match a named subset.
tokenizer.encode_allowed_special(untrusted, ["<|endoftext|>"])
# [3563, 220, 100257, 4060]
# Anything outside the allow-list is rejected, naming the token and its offset.
tokenizer.encode_allowed_special(untrusted, [])
# ValueError: special token "<|endoftext|>" at byte offset 6 is not in
# the caller's allow-listThe model's own boundary tokens are unaffected by the mode — they come from the post_processor template, not from matching text against the vocabulary, so locking down matching does not silently strip the BOS the model was trained with. Use encode_raw when you want no boundary tokens at all.
Decode token IDs back to text. Raises an error if the decoded bytes are not valid UTF-8.
tokens = [9906, 11, 1917, 0]
text = tokenizer.decode(tokens)
print(text) # "Hello, world!"Control tokens render as nothing — HuggingFace's default skip_special_tokens=True, which every loader implements, so the same vocabulary decodes the same way whether it came from from_pretrained, from a tokenizer.json or from a GGUF file:
tok = Tokenizer.from_pretrained("mistral_v2")
tok.decode([3]) # "" — [INST]
tok.decode(tok.encode_with_special("[INST]Hi[/INST]")) # "Hi"What counts as a control token is the vocabulary's own declaration, not a guess: DeepSeek marks <|User|> and <|Assistant|> as ordinary added tokens, so those still render, and Whisper's timestamp tokens (<|0.00|>…<|30.00|>) are transcript content and render too. The OpenAI vocabularies (cl100k_base, o200k_base) follow tiktoken, which renders <|endoftext|> and its siblings. Use special_token_id(name) when you want a marker's spelling or id rather than its decoded text.
Decode token IDs to raw bytes without UTF-8 validation. Needs the byte-level BPE backend (family == "BPE") and a source that declares no decoder pipeline — reading token bytes directly is exactly what would bypass such a pipeline — and raises ValueError otherwise. Every bundled byte-level vocabulary qualifies; use decode for the rest.
tokens = [9906, 11, 1917, 0]
raw_bytes = tokenizer.decode_bytes(tokens)
print(raw_bytes) # b'Hello, world!'Decode token IDs to text, replacing any invalid UTF-8 sequences with the replacement character (�). Same backend requirement as decode_bytes.
tokens = [9906, 11, 1917, 0]
text = tokenizer.decode_lossy(tokens)
# Invalid UTF-8 sequences become �A single id's own contribution to the decoded stream — ByteLevel alphabet unmapped and <0xNN> byte fallback resolved — for callers that need to attribute output to one token rather than render a sequence: logprob display, token-level highlighting, debugging a vocabulary. Available on every tokenizer class (Tokenizer, SentencePieceTokenizer, SpmTokenizer, WordPieceTokenizer, AnyTokenizer).
No sequence-level post-processing runs — no leading-space strip, no first-token rule, no word separator — so concatenating this over a sequence of ids is not the same as calling decode on that sequence. Use streaming_decoder() to render a whole stream.
An id in the vocabulary that carries no surface (a control token decode drops, say) returns empty bytes, not an error; ValueError is reserved for an id outside the vocabulary altogether.
tok = Tokenizer.from_pretrained("cl100k_base")
ids = tok.encode("Hello, world!")
tok.decode_token_bytes(ids[0]) # b'Hello'
# Concatenating per-token bytes is not `decode`'s output — see above.
b"".join(tok.decode_token_bytes(i) for i in ids) == tok.decode(ids).encode()
# not guaranteed, and false on vocabularies with a separator or byte fallbackdecode_token_bytes as text. Raises far more often than decode does: a single <0xNN> byte-fallback id, or a token holding one byte of a multi-byte character, is not valid UTF-8 standing alone — that is the expected signal to stop decoding id-at-a-time and use streaming_decoder(), which buffers exactly those partial sequences across tokens.
tok = Tokenizer.from_pretrained("cl100k_base")
ids = tok.encode("Hello, world!")
tok.decode_token(ids[0]) # "Hello"The total vocabulary size including special tokens and splintr's 54 agent tokens.
print(tokenizer.vocab_size) # 100331 for cl100k_base with agent tokensFor the size the upstream reference reports — what you need to size an embedding or logit layer — use base_vocab_size.
The number of entries currently in the LRU cache. The byte-level BPE backend is the only one that caches encoded chunks; on any other family this raises ValueError, as does clear_cache().
print(tokenizer.cache_len) # Number of cached text chunksClear the LRU encoding cache. Useful if memory pressure is a concern.
tokenizer.clear_cache()base_vocab_size(name) is a module-level function reporting a vocabulary's size as its upstream reference defines it — without splintr's 54 agent tokens. That is the number to size a model's embedding or logit layer with, or to identify which vocabulary a checkpoint uses from the shape of its token-embedding tensor: both must match the checkpoint's vocabulary, not splintr's extended one. Agent tokens are appended strictly above every id the reference uses, so this is also exactly the id at which splintr's additions begin.
from splintr import Tokenizer, base_vocab_size
tokenizer = Tokenizer.from_pretrained("cl100k_base")
print(tokenizer.vocab_size) # 100331 — extended (base + 54 agent)
print(base_vocab_size("cl100k_base")) # 100277 — what tiktoken reports
print(base_vocab_size("llama3")) # 128256
print(base_vocab_size("mistral_v3")) # 131072It accepts the same names as Tokenizer.from_pretrained and raises ValueError for anything else. It is not vocab_size - 54: several reference vocabularies leave gaps below their nominal size, so the difference varies per vocabulary. (Rust: splintr::pretrained::base_vocab_size(vocab) taking a PretrainedVocab, or base_vocab_size_by_name(name) returning Result<u32, TokenizerError>.)
The SentencePieceTokenizer class provides unigram tokenization for models using SentencePiece (e.g., loaded from GGUF files).
from splintr import SentencePieceTokenizer
# Create from raw vocabulary data
tokenizer = SentencePieceTokenizer(
tokens=["<unk>", "<s>", "</s>", "▁Hello", "▁world"],
scores=[0.0, 0.0, 0.0, -1.2, -1.5],
eos_token_id=2,
bos_token_id=1, # optional
)Encode text using Viterbi maximum-score segmentation (true SentencePiece Unigram, not greedy), with byte fallback for unknown characters. Prepends BOS if configured — BOS is a boundary token, so encode_raw omits it.
ids = tokenizer.encode("Hello world")
# [1, 3, 4] (BOS + ▁Hello + ▁world)The other five encoding methods (encode_raw, encode_ordinary, encode_with_special, encode_allowed_special, encode_batch) are present here too and mean exactly what they mean elsewhere.
Decode token IDs to text. Skips BOS/EOS tokens, converts ▁ back to spaces.
text = tokenizer.decode([1, 3, 4])
# "Hello world"Decode token IDs, silently skipping any invalid (out-of-range) IDs.
text = tokenizer.decode_lossy([1, 3, 999, 4])
# "Hello world" (999 is skipped)vocab_size: int— Total vocabulary sizeeos_token_id: int— End-of-sequence token IDbos_token_id: int | None— Beginning-of-sequence token ID (if configured)
is_eos(token_id: int) -> bool— Check if a token is the EOS token
For models not bundled with from_pretrained, load a HuggingFace tokenizer.json with from_json. It reads everything from the file — split regex, byte-level flag, BPE merge order (independent of token ids, so RoBERTa-style vocabs work), the full ordered normalizer (including SentencePiece's Precompiled charsmap), the post_processor template, the declared decoder pipeline, and special tokens.
It always returns an AnyTokenizer — the universal loaded-tokenizer handle — which dispatches internally to the backend matching the file's model.type. Query .family for which one:
from splintr import from_json, from_json_bytes
tok = from_json("/path/to/bge-m3-tokenizer/tokenizer.json") # from a path
# tok = from_json_bytes(open("tokenizer.json","rb").read()) # from bytes
tok.family # "Unigram"
tok.encode("Hello, world!") # [0, 35378, 4, 8999, 38, 2] — [CLS] … [SEP]
tok.encode_raw("Hello, world!") # [35378, 4, 8999, 38] — content only
tok.decode(tok.encode("Hello, world!")) # "Hello, world!"model.type |
tok.family |
Internal backend | Example models |
|---|---|---|---|
BPE |
"BPE" |
Tokenizer |
GPT-2, RoBERTa, Qwen, Whisper.en |
Unigram |
"Unigram" |
SentencePieceTokenizer |
T5, Gemma, Albert, XLNet |
WordPiece |
"WordPiece" |
WordPieceTokenizer |
BERT, DistilBERT, Electra |
A fourth backend, SpmTokenizer (family == "Spm"), is not reachable from tokenizer.json: it is what the bundled Mistral V1/V2 vocabularies use and what the Rust from_gguf_vocab loader produces from a GGUF file's embedded vocabulary.
Other members: the six encoding methods, decode(ids), vocab_size, family, eos_token_id, is_eos(id), and special_token_id(name) (the id of an added token by its content, e.g. "[CLS]", or None).
decode runs the file's declared decoder chain after dropping special=true ids — HuggingFace's default skip_special_tokens=True — so files whose decoding is that chain (Mistral, Llama, Gemma) come back as text rather than raw pieces.
Strict by design. Rather than silently approximate an unsupported config (which would produce wrong tokens with no signal), from_json raises:
UnsupportedModelType—model.typeis not BPE/Unigram/WordPieceUnsupportedNormalizer— an unrecognized normalizer step (dropping it would mis-normalize)InvalidNormalizerRegex— aReplaceregex that fails to compileUnsupportedPreTokenizer— a declared pre-tokenizer with no recognized split (refusing to guess the pattern)
Output is verified id-for-id against HuggingFace tokenizers across all three families. (Rust: splintr::from_json_path / from_json_bytes.)
What follows is a list of properties splintr's own test suite pins, not an aspirational list — each line names the test that fails if the property stops holding. A claim that seemed true but had no test enforcing it is left out entirely, or marked as such, rather than stated as fact.
- Encode pipeline order. Content runs through the declared normalizer, then the pre-tokenizer split (
add_prefix_spaceapplied at that stage, matching where HuggingFace'sByteLevel/Metaspacenodes own the flag — seeTokenizer::prefixedinsrc/core/tokenizer/encode.rs), then the BPE/Unigram/WordPiece model; special tokens are extracted before any of that runs. This is exercised end to end, not as an isolated ordering check:tests/reference_parity.rs::pretrained_vocabularies_match_reference_tokenizersassertsencode/encode_rawids against each reference tokenizer's own ids for every bundled vocabulary, which cannot pass if a stage runs out of order oradd_prefix_spaceis applied on the wrong side of the split. - Streaming decode agrees with whole-sequence decode, for any chunking. Concatenating every
add_token/add_tokensemission plus the finalflush()equalsdecode/decode_lossyon the same ids, regardless of how the ids are grouped into chunks. Pinned for every bundled vocabulary and backend bytests/decode_agreement.rs::streaming_decode_agrees_with_whole_sequence_decode, and as a property test per backend:src/core/streaming/decoder.rs::prop_chunking_matches_decode_cl100k_base/prop_chunking_matches_decode_deepseek_v3/prop_arbitrary_ids_match_decode_lossy,src/core/spm.rs::prop_chunking_matches_decode_mistral/prop_chunking_matches_decode_mistral_v2,src/core/sentencepiece.rs::prop_chunking_matches_decode, andsrc/core/wordpiece.rs::prop_chunking_matches_decode/prop_chunking_matches_decode_without_prefix.reset()producing a decoder byte-identical to a fresh one is covered by the correspondingprop_reset_matches_a_fresh_decoderin each of those modules. - The UTF-8 reassembly buffer matches
String::from_utf8_lossy. Byte-at-a-time or arbitrarily chunked, the buffer's lossy output is exactly whatString::from_utf8_lossywould produce on the same bytes fed whole. Pinned bysrc/core/streaming/utf8.rs::prop_byte_at_a_time_matches_lossyandprop_arbitrary_chunking_matches_lossy. - The pre-tokenizer split matches the reference tool's split — for bundled vocabularies that have a pre-tokenizer stage.
AnyTokenizer::pre_tokenizeis asserted piece-for-piece against each reference's own split bytests/reference_parity.rs::pretrained_vocabularies_match_reference_tokenizers. The reference and its authority differ by vocabulary: forcl100k_base/o200k_baseit isregex.finditerover the installedtiktokenencoding's own_pat_str, which proves splintr's pattern constant has not drifted from it and that splintr's regex engine executes that pattern identically to Python'sregexmodule — it does not independently verify OpenAI's intent behind the pattern. Forllama3/deepseek_v3/whisperit is HuggingFacetokenizers'pre_tokenizer.pre_tokenize_strover the normalizer's own output.mistralandmistral_v2have no split pinned here — SentencePiece has no pre-tokenizer stage, so those fixtures carry nopiecesfield at all. - Ids and decoded text match the reference tokenizers, for the bundled vocabularies, over the fixture corpus. Also
tests/reference_parity.rs::pretrained_vocabularies_match_reference_tokenizers, againsttests/fixtures/pretrained/*.jsoncaptured byscripts/extract_reference_cases.pyfromtiktoken(OpenAI vocabularies),tokenizers(HF-published vocabularies) orsentencepiece(Mistral). This is a fixture-corpus guarantee, not a universal one: it holds over the cases committed to that corpus, not over all possible input. A broader, non-CI sweep against real model directories isscripts/verify_external_models.py;scripts/fuzz_reference.pylooks for the corpus's blind spots (see Correctness against the reference implementations). - Concatenating
decode_token/decode_token_bytesover a sequence is notdecode— this is a deliberate non-guarantee, not an oversight. Per-id decoding never includes the inter-token separator a surface may carry (WordPiece's##continuation spacing, a leading-space rule that only fires past the first token), because that separator is a fact about where in the sequence an id sits, not about the id itself. See Per-Token Decoding for the exact contract ofdecode_token/decode_token_bytes. encode_ordinarynever promotes a special token's literal spelling in untrusted text. UnderSpecialMode::Ordinary, text that spells out<|endoftext|>(or any other configured special token) is tokenized as ordinary content and round-trips throughdecodeunchanged — it can never become that token's control id. Pinned bysrc/core/tokenizer/tests.rs::encode_with_all_vs_ordinary_diverge_on_a_special_token. The model's own boundary tokens (BOS/EOS/[CLS]/[SEP]) are a separate concern: they come from thepost_processortemplate under every mode,Ordinaryincluded, and are unaffected by this guarantee — see Special tokens in untrusted text.
Not currently pinned by a dedicated test: the streaming-decode identity above covers only the bundled vocabularies and backends reachable through from_pretrained; agreement for a from_json-loaded tokenizer's declared decoder pipeline is checked out of band by scripts/verify_external_models.py and scripts/fuzz_reference.py, not by an in-repo cargo test.
A streaming decoder is essential for real-time LLM applications where tokens arrive one at a time. It handles the critical problem of BPE tokens not aligning with UTF-8 character boundaries.
There is exactly one decoder class, StreamingDecoder, and two ways to get it — streaming_decoder() and streaming_decoder_with_special(), on the tokenizer whose stream you are decoding. They differ only in the mode decode and decode_with_special differ in: the first drops the control tokens, the second renders them, so a generation loop that needs to see <|eot_id|> go past has a stream that shows it. Every tokenizer class has it — Tokenizer, SentencePieceTokenizer, SpmTokenizer, WordPieceTokenizer and AnyTokenizer — and the decoder it hands back carries that tokenizer's own decode rules: the ByteLevel alphabet (DeepSeek V3, GPT-2), <0xNN> byte fallback, the ▁ metaspace substitution, and the special=true ids decode drops.
That is what makes the guarantee below hold on every vocabulary, and it is why there is nothing else to choose:
"".join(chunks) + decoder.flush() == tokenizer.decode(ids)
# ...and, for the sibling factory, against the sibling decode:
"".join(chunks) + decoder.flush() == tokenizer.decode_with_special(ids)BPE tokens don't align with UTF-8 character boundaries. A multi-byte Unicode character like "世" (3 bytes: 0xE4 0xB8 0x96) might split across tokens. The streaming decoder:
- Buffers incomplete byte sequences across token boundaries
- Only outputs text when complete UTF-8 characters are available
- Prevents display corruption in streaming LLM output
- Handles edge cases automatically
# Create a streaming decoder
decoder = tokenizer.streaming_decoder()
# Process tokens one at a time (typical LLM streaming scenario)
for token_id in token_stream:
# Returns text only when complete UTF-8 characters are available
if text := decoder.add_token(token_id):
print(text, end="", flush=True)
# Flush any remaining buffered bytes at the end
print(decoder.flush())import openai
from splintr import Tokenizer
tokenizer = Tokenizer.from_pretrained("cl100k_base")
decoder = tokenizer.streaming_decoder()
# Stream tokens from OpenAI API
response = openai.ChatCompletion.create(
model="gpt-4",
messages=[{"role": "user", "content": "Tell me a story"}],
stream=True
)
for chunk in response:
if chunk.choices[0].delta.content:
# Process each token as it arrives
token_ids = get_token_ids(chunk) # pseudo-code
for token_id in token_ids:
if text := decoder.add_token(token_id):
print(text, end="", flush=True)
# Don't forget to flush at the end
print(decoder.flush())A ByteLevel BPE vocabulary (DeepSeek V3, GPT-2) encodes raw bytes (0-255) as printable Unicode characters — space 0x20 becomes Ġ — so its tokens need one extra unmapping step before UTF-8 assembly. That step is part of the tokenizer's configuration, so the same call serves it:
from splintr import Tokenizer
# DeepSeek V3 uses ByteLevel BPE encoding — same call, no second class
tokenizer = Tokenizer.from_pretrained("deepseek_v3")
decoder = tokenizer.streaming_decoder()
for token_id in token_stream:
if text := decoder.add_token(token_id):
print(text, end="", flush=True)
print(decoder.flush())See bytelevel_bpe.md for details on ByteLevel encoding.
Core operations:
add_token(token_id: int) -> str | None: Add a token, return complete characters or None if buffering. An id in no table at all is skipped, matchingdecode_lossy— one stray id must not abort a generationadd_tokens(token_ids: list[int]) -> str | None: Add multiple tokens at once. Grouping changes only when text is emitted, never whatflush() -> str: Flush buffered bytes (incomplete sequences become �)reset(): Clear the buffer and start fresh
Properties:
has_pending: bool: Whether there are buffered bytes waitingpending_bytes: int: Number of bytes currently buffered
AnyTokenizer.streaming_decoder() is the one form that can raise. A tokenizer.json may declare a decoder pipeline holding a step that cannot be evaluated one chunk at a time (a BPEDecoder, a trailing Strip, a Replace over the fused text). Answering with the backend's own decode would render the raw pieces (▁hello▁world) the pipeline exists to turn into text, so it raises ValueError naming the step instead:
tokenizer = from_json("path/to/tokenizer.json")
try:
decoder = tokenizer.streaming_decoder()
except ValueError as e:
# ... its `Strip` step is not incrementally computable — decode the whole sequence instead
text = tokenizer.decode(ids)Whole-sequence decode handles those files. Every other tokenizer class always returns a decoder.
The Rust API provides similar functionality with strongly-typed interfaces. For complete documentation, see docs.rs/splintr.
Add Splintr to your Cargo.toml:
[dependencies]
splintr = "*" # or pin to a specific versionpretrained::from_pretrained(name) is the Rust entry point, and it returns an AnyTokenizer for every bundled vocabulary — so the same code works whether the vocabulary needs the byte-level BPE backend or the SPM-BPE one (Mistral V1/V2):
use splintr::{pretrained::from_pretrained, Tokenize};
// fn from_pretrained(name: &str) -> Result<AnyTokenizer, TokenizerError>
let tokenizer = from_pretrained("cl100k_base")?;
let tokens = tokenizer.encode("Hello, world!");
let batch_tokens = tokenizer.encode_batch(&["Hello, world!", "How are you?"]);
let text = tokenizer.decode(&tokens)?; // `decode` comes from the `Tokenize` traitpretrained::from_vocab(vocab) takes a PretrainedVocab enum instead of a name, with the same return type. To build a tokenizer from your own vocabulary rather than a bundled one, use Tokenizer::new — see BPE Tokenizer.
All tokenizer backends implement the Tokenize trait, enabling generic code:
use splintr::Tokenize;
fn count_tokens(tokenizer: &dyn Tokenize, text: &str) -> usize {
tokenizer.encode(text).len()
}Methods:
encode(&self, text: &str) -> Vec<u32>: Encode text to token IDsencode_with(&self, text: &str, mode: &SpecialMode<'_>) -> Result<Vec<u32>, PolicyError>: Encode under an explicitSpecialModedecode(&self, ids: &[u32]) -> Result<String, TokenizeError>: Decode token IDs to textvocab_size(&self) -> usize: Vocabulary size
Implemented by Tokenizer (BPE), SentencePieceTokenizer (unigram), SpmTokenizer (SentencePiece BPE), WordPieceTokenizer (WordPiece), and by AnyTokenizer itself.
The universal loaded-tokenizer handle: a backend plus the SpecialPolicy parsed from the same source, plus the declared decoder pipeline. It is what from_pretrained, from_json_path/from_json_bytes and from_gguf_vocab all return.
use splintr::{from_json_path, Tokenize};
let tok = from_json_path("tokenizer.json")?;
let ids = tok.encode("Hello, world!"); // boundary template applied
let raw = tok.encode_raw("Hello, world!"); // content tokens only
let pair = tok.encode_pair("query", "document")?; // [CLS] q [SEP] d [SEP]
let text = tok.decode(&ids)?; // via `Tokenize`Methods:
encode(&self, text: &str) -> Vec<u32>: Content tokens with the single-sequence template applied (HFadd_special_tokens=True)encode_raw(&self, text: &str) -> Vec<u32>: Content tokens alone (HFadd_special_tokens=False)encode_with(&self, text: &str, mode: &SpecialMode<'_>) -> Result<Vec<u32>, PolicyError>:encodeunder an explicit matching modeencode_batch(&self, texts: &[&str]) -> Vec<Vec<u32>>: Batch form ofencode, parallel across textsencode_batch_with(&self, texts: &[&str], mode: &SpecialMode<'_>) -> Result<Vec<Vec<u32>>, PolicyError>: Batch form ofencode_with; fails as a whole rather than dropping an offending textencode_rayon(&self, text: &str) -> Vec<u32>:encodeparallelized within one text, where the backend supports it (same ids either way)encode_pair(&self, a: &str, b: &str) -> Result<Vec<u32>, PolicyError>: The pair template; errors withPolicyError::NoPairTemplaterather than concatenating without the model's separatordecode_batch(&self, token_lists: &[Vec<u32>]) -> Result<Vec<String>, TokenizeError>: Batch form ofdecode, running the same declared pipelineset_pcre2(&mut self, use_pcre2: bool) -> Result<(), TokenizerError>/set_jit(&mut self, use_jit: bool): Reconfigure the BPE backend's regex engine in place, keeping the policy, decoder pipeline and special-id set attached;TokenizerError::NotBpeBackendon any other familydeclares_decoder(&self) -> bool: Whether the source declared adecoderpipeline — consult before reaching past this handle for a backend's raw byte-level decodefamily(&self) -> &'static str:"BPE"|"Unigram"|"WordPiece"|"Spm"backend(&self) -> &Backend/into_backend(self) -> Backend: Reach a backend-specific APIpolicy(&self) -> &SpecialPolicy,eos_token_id(&self) -> Option<u32>,is_eos(&self, id: u32) -> bool,special_token_id(&self, name: &str) -> Option<u32>
The boundary template applies under every SpecialMode, including Ordinary: boundary tokens come from the template, not from matching text against the vocabulary, so the two concerns stay independent.
To fit a sequence into a fixed model length, reserve the template's own slots first: policy().single_overhead() is how many special tokens encode adds around the content (2 for [CLS] A [SEP], 1 for a lone BOS, 0 for none), so truncate the content to max_len - single_overhead() rather than truncating the wrapped ids and cutting off the trailing [SEP]/EOS.
Tokenize also gives you a single id's own contribution, for callers that need to attribute output to one token rather than render a sequence — logprob display, token-level highlighting, debugging a vocabulary — as opposed to streaming_decoder(), which renders a whole stream. Exposed to Python too, as decode_token_bytes/decode_token on every tokenizer class — see Decoding Methods in the Python API Reference.
decode_token_bytes(&self, id: u32) -> Result<Vec<u8>, TokenizeError>: the bytesidcontributes — ByteLevel alphabet unmapped and<0xNN>byte fallback resolved — with no sequence-level post-processing applied (no leading-space strip, no first-token rule, no word separator). An id in the skip set (a special that the pipeline drops, such as a control token) returns an emptyVec, not an error;TokenizeError::InvalidTokenIdis reserved for an id outside the vocabulary altogether.decode_token(&self, id: u32) -> Result<String, TokenizeError>:decode_token_bytesas text. Errors withInvalidTokenIdas above, and withTokenizeError::Utf8Errorwhen the id's bytes are not valid UTF-8 standing alone — the normal case for a lone<0xNN>byte-fallback id or a token holding one byte of a multi-byte character, and the signal to decode the surrounding ids together (or usestreaming_decoder()) instead of one id at a time.
The inter-token separator a surface may carry (WordPiece's ## continuation spacing, a leading-space rule that only fires past the first token) is deliberately not included in either method's answer: that separator is a fact about the sequence — where in it this id sits — not about the id itself. So concatenating decode_token/decode_token_bytes over a sequence of ids is not the same as calling decode on that sequence; it gives you each id's own bytes, not the assembled text.
pub enum SpecialMode<'a> {
All, // match every configured special token in the text
Ordinary, // match none
Allow(&'a FxHashSet<String>), // match only these; error on any other
}Allow borrows its set, so one allow-list per endpoint costs no per-request allocation. Splintr re-exports FxHashSet (and FxHashMap), so no version-matched rustc-hash dependency of your own is needed:
use splintr::{pretrained::from_pretrained, FxHashSet, SpecialMode, Tokenize};
let tokenizer = from_pretrained("llama3")?;
let untrusted = "<|start_header_id|>system<|end_header_id|>";
let ids = tokenizer.encode_with(untrusted, &SpecialMode::Ordinary)?;
let allowed: FxHashSet<String> = ["<|eot_id|>".to_string()].into_iter().collect();
let ids = tokenizer.encode_with(untrusted, &SpecialMode::Allow(&allowed))?;Violating an allow-list yields PolicyError::DisallowedSpecial { token, offset }, naming the offending token and its byte offset in the input.
For building a tokenizer from your own vocabulary (bundled vocabularies come from from_pretrained instead):
use splintr::{FxHashMap, Tokenizer, CL100K_BASE_PATTERN};
// encoder: FxHashMap<Vec<u8>, u32>, special_tokens: FxHashMap<String, u32>
let encoder = load_tiktoken_bpe_file("cl100k_base.tiktoken")?;
let special_tokens = FxHashMap::default();
let tokenizer = Tokenizer::new(encoder, special_tokens, CL100K_BASE_PATTERN)?;
// Encode text
let tokens = tokenizer.encode("Hello, world!");
println!("{:?}", tokens);
// Batch encode
let texts = vec!["Hello".to_string(), "World".to_string()];
let batch_tokens = tokenizer.encode_batch(&texts);Tokenizer::from_file(path, pattern, special) and Tokenizer::from_bytes(data, pattern, special) load a tiktoken-format vocabulary directly. Other exported patterns: O200K_BASE_PATTERN, LLAMA3_PATTERN, MISTRAL_V3_PATTERN, GPT2_PATTERN, QWEN2_PATTERN, SENTENCEPIECE_PATTERN, DEEPSEEK_V3_PATTERNS.
encode(&self, text: &str) -> Vec<u32>: Sequential encoding (optimal for texts <1MB)encode_with_special(&self, text: &str) -> Vec<u32>: Encode with special token recognitionencode_ordinary(&self, text: &str) -> Vec<u32>: Encode never matching a special token spelled out in the textencode_with(&self, text: &str, mode: &SpecialMode<'_>) -> Result<Vec<u32>, PolicyError>: Encode under an explicitSpecialModeencode_batch(&self, texts: &[String]) -> Vec<Vec<u32>>: Parallel encoding across textsencode_batch_with_special(&self, texts: &[String]) -> Vec<Vec<u32>>: Batch form ofencode_with_specialencode_rayon(&self, text: &str) -> Vec<u32>: Parallel encoding within text (for texts >1MB)
This backend has no notion of boundary tokens — no encode_raw, because there is no template to leave off. Those live on AnyTokenizer, which pairs a backend with the SpecialPolicy that owns them.
decode(&self, tokens: &[u32]) -> Result<String, TokenizerError>: Decode to UTF-8 stringdecode_bytes(&self, tokens: &[u32]) -> Vec<u8>: Decode to raw bytesdecode_lossy(&self, tokens: &[u32]) -> String: Decode with replacement for invalid UTF-8
For models using SentencePiece Unigram tokenization (e.g., T5, Gemma, Albert, XLNet, loaded via from_json). Mistral V1/V2 are SentencePiece BPE (merge-by-score, not Unigram) and load through the Rust-only SpmTokenizer via from_pretrained instead — see src/core/spm.rs.
use splintr::SentencePieceTokenizer;
// Create from raw vocabulary data
let tokenizer = SentencePieceTokenizer::new(
tokens, // Vec<String> — token strings indexed by ID
scores, // Vec<f64> — per-token Unigram scores maximized by Viterbi (empty for uniform)
Some(1), // Optional BOS token ID
2, // EOS token ID
)?;
// Encode (prepends BOS if configured, uses ▁ word boundaries)
let ids = tokenizer.encode("Hello world");
// Decode (skips BOS/EOS, converts ▁ back to spaces)
let text = tokenizer.decode(&ids)?;
// Lossy decode (skips invalid token IDs instead of erroring)
let text = tokenizer.decode_lossy(&ids);encode(&self, text: &str) -> Vec<u32>: Viterbi maximum-score (true SentencePiece Unigram) segmentation with byte fallbackencode_ordinary(&self, text: &str) -> Vec<u32>: Encode never matching an added token spelled out in the textencode_with(&self, text: &str, mode: &SpecialMode<'_>) -> Result<Vec<u32>, PolicyError>: Encode under an explicitSpecialModedecode(&self, ids: &[u32]) -> Result<String, SentencePieceError>: Decode to UTF-8 stringdecode_lossy(&self, ids: &[u32]) -> String: Decode, skipping invalid token IDsvocab_size(&self) -> usize: Vocabulary sizeis_eos(&self, token_id: u32) -> bool: Check if token is EOSeos_token_id(&self) -> u32: Get EOS token IDbos_token_id(&self) -> Option<u32>: Get BOS token ID
For BERT-family models using WordPiece subword tokenization:
use splintr::{WordPieceTokenizer, Tokenize};
// Create from a flat vocabulary (index = token ID)
let vocab = vec![
"[PAD]", "[UNK]", "[CLS]", "[SEP]",
"hello", "world", "##ing", "##s",
].into_iter().map(String::from).collect();
let tokenizer = WordPieceTokenizer::new(
vocab, // Vec<String> — token strings indexed by ID
1, // UNK token ID
200, // Max word length before mapping to UNK
true, // Lowercase and strip accents (for uncased models)
);
// Encode (BasicTokenizer + WordPiece greedy longest-match)
let ids = tokenizer.encode("Hello world");
// Decode (reconstructs text, skips [CLS]/[SEP]/[PAD] special tokens)
let text = tokenizer.decode(&ids)?;encode(&self, text: &str) -> Vec<u32>: BasicTokenizer + WordPiece subword tokenization (via theTokenizetrait)encode_ordinary(&self, text: &str) -> Vec<u32>: Encode never matching an added token spelled out in the textencode_with(&self, text: &str, mode: &SpecialMode<'_>) -> Result<Vec<u32>, PolicyError>: Encode under an explicitSpecialModedecode(&self, ids: &[u32]) -> Result<String, TokenizeError>: Decode, joining subwords and removing##prefixesvocab_size(&self) -> usize: Vocabulary sizecls_token_id(&self) -> Option<u32>:[CLS]token IDsep_token_id(&self) -> Option<u32>:[SEP]token IDpad_token_id(&self) -> Option<u32>:[PAD]token IDunk_token_id(&self) -> u32:[UNK]token ID
The Rust API uses Result types for operations that can fail:
match tokenizer.decode(&tokens) {
Ok(text) => println!("Decoded: {}", text),
Err(e) => eprintln!("Decoding error: {}", e),
}from splintr import Tokenizer
# Load tokenizer
tokenizer = Tokenizer.from_pretrained("cl100k_base")
# Simple encoding
text = "The quick brown fox jumps over the lazy dog."
tokens = tokenizer.encode(text)
print(f"Text: {text}")
print(f"Tokens: {tokens}")
print(f"Token count: {len(tokens)}")
# Simple decoding
decoded = tokenizer.decode(tokens)
print(f"Decoded: {decoded}")
assert decoded == text
# Handle different languages
chinese = "你好世界"
tokens_cn = tokenizer.encode(chinese)
print(f"Chinese tokens: {tokens_cn}")
decoded_cn = tokenizer.decode(tokens_cn)
print(f"Decoded Chinese: {decoded_cn}")from splintr import Tokenizer
import time
tokenizer = Tokenizer.from_pretrained("cl100k_base")
# Prepare a batch of texts
texts = [
"First text to encode",
"Second text to encode",
"Third text with different content",
"Fourth text for batch processing",
] * 100 # 400 texts
# Measure batch encoding performance
start = time.time()
batch_tokens = tokenizer.encode_batch(texts)
elapsed = time.time() - start
print(f"Encoded {len(texts)} texts in {elapsed:.3f}s")
print(f"Throughput: {len(texts)/elapsed:.1f} texts/second")
# Process results
for i, tokens in enumerate(batch_tokens[:5]):
print(f"Text {i}: {len(tokens)} tokens")from splintr import Tokenizer
tokenizer = Tokenizer.from_pretrained("cl100k_base")
# Encode without special token recognition
# The special token gets split into multiple tokens
text = "Start <|endoftext|> End"
tokens_no_special = tokenizer.encode(text)
print(f"Without special tokens: {len(tokens_no_special)} tokens")
# Encode with special token recognition
# The special token becomes a single token
tokens_with_special = tokenizer.encode_with_special(text)
print(f"With special tokens: {len(tokens_with_special)} tokens")
# Verify the difference
decoded = tokenizer.decode(tokens_with_special)
print(f"Decoded: {decoded}")from splintr import Tokenizer, CL100K_AGENT_TOKENS, LLAMA3_AGENT_TOKENS, DEEPSEEK_V3_AGENT_TOKENS
# OpenAI models with agent tokens
tokenizer_openai = Tokenizer.from_pretrained("cl100k_base")
# Chain-of-Thought reasoning
cot_text = "<|think|>Let me break this down step by step...<|/think|>The answer is 42."
tokens = tokenizer_openai.encode_with_special(cot_text)
print(f"Thinking token ID: {CL100K_AGENT_TOKENS.THINK}")
print(f"Thinking end token ID: {CL100K_AGENT_TOKENS.THINK_END}")
# ReAct agent pattern
react_text = """<|plan|>I need to search for information
<|act|>search("climate change")
<|observe|>Found 10 results...
<|think|>Based on these results..."""
tokens = tokenizer_openai.encode_with_special(react_text)
print(f"Encoded {len(tokens)} tokens")
# Function calling
function_text = """<|function|>calculate_sum
<|result|>42
<|/result|>"""
tokens = tokenizer_openai.encode_with_special(function_text)
print(f"Function token ID: {CL100K_AGENT_TOKENS.FUNCTION}")
print(f"Result token ID: {CL100K_AGENT_TOKENS.RESULT}")
# RAG with citations
rag_text = """<|context|>This is source material...
<|cite|>According to the documentation...
<|source|>docs.example.com"""
tokens = tokenizer_openai.encode_with_special(rag_text)
print(f"Context token ID: {CL100K_AGENT_TOKENS.CONTEXT}")
print(f"Cite token ID: {CL100K_AGENT_TOKENS.CITE}")
# Llama 3 models
tokenizer_llama = Tokenizer.from_pretrained("llama3")
# Use Llama 3 native tokens
llama_text = "<|begin_of_text|><|start_header_id|>user<|end_header_id|>\nHello<|eot_id|>"
tokens = tokenizer_llama.encode_with_special(llama_text)
print(f"Llama begin_of_text: {LLAMA3_AGENT_TOKENS.BEGIN_OF_TEXT}")
print(f"Llama start_header_id: {LLAMA3_AGENT_TOKENS.START_HEADER_ID}")
# DeepSeek V3 models with native thinking tokens
tokenizer_deepseek = Tokenizer.from_pretrained("deepseek_v3")
# Use DeepSeek's native thinking tokens for R1-style reasoning
deepseek_text = "<think>Let me reason through this problem step by step...</think>The solution is X."
tokens = tokenizer_deepseek.encode_with_special(deepseek_text)
print(f"DeepSeek think token (native): {DEEPSEEK_V3_AGENT_TOKENS.THINK_NATIVE}")
print(f"DeepSeek think_end token (native): {DEEPSEEK_V3_AGENT_TOKENS.THINK_END_NATIVE}")
# DeepSeek V3 also has tool calling tokens
tool_text = """<|tool▁calls▁begin|>
<|tool▁call▁begin|>
function_name
<|tool▁call▁end|>
<|tool▁calls▁end|>"""
tokens = tokenizer_deepseek.encode_with_special(tool_text)
print(f"Encoded tool calling pattern with {len(tokens)} tokens")from splintr import Tokenizer
import time
tokenizer = Tokenizer.from_pretrained("cl100k_base")
# Simulate streaming token generation
text = "Hello, 世界! This is a test of streaming decoding with Unicode characters: 你好"
tokens = tokenizer.encode(text)
# Create streaming decoder
decoder = tokenizer.streaming_decoder()
print("Streaming output:")
for token in tokens:
# Simulate network delay
time.sleep(0.05)
# Add token and print if we get complete characters
if chunk := decoder.add_token(token):
print(chunk, end="", flush=True)
# Flush any remaining bytes
if remaining := decoder.flush():
print(remaining, end="", flush=True)
print("\n\nStreaming complete!")from splintr import Tokenizer
import time
tokenizer = Tokenizer.from_pretrained("deepseek_v3")
# Test text with Unicode
text = "DeepSeek V3 supports ByteLevel BPE! 中文测试"
tokens = tokenizer.encode(text)
# Same call as above: the ByteLevel unmapping comes from the tokenizer
decoder = tokenizer.streaming_decoder()
print("ByteLevel streaming output:")
for token in tokens:
time.sleep(0.05)
if chunk := decoder.add_token(token):
print(chunk, end="", flush=True)
# Flush remaining
if remaining := decoder.flush():
print(remaining, end="", flush=True)
print("\n\nByteLevel streaming complete!")
# Check pending state
print(f"Has pending bytes: {decoder.has_pending}")
print(f"Pending byte count: {decoder.pending_bytes}")from splintr import Tokenizer
tokenizer = Tokenizer.from_pretrained("cl100k_base")
decoder = tokenizer.streaming_decoder()
def stream_tokens(token_generator):
"""Stream tokens with proper error handling."""
try:
for token_id in token_generator:
try:
if text := decoder.add_token(token_id):
yield text
except Exception as e:
print(f"\nError processing token {token_id}: {e}")
# Reset decoder and continue
decoder.reset()
continue
# Always flush at the end
if remaining := decoder.flush():
yield remaining
except Exception as e:
print(f"\nFatal streaming error: {e}")
# Final flush attempt
try:
if remaining := decoder.flush():
yield remaining
except:
pass
# Use the streaming function
text = "Test streaming with proper error handling"
tokens = tokenizer.encode(text)
for chunk in stream_tokens(iter(tokens)):
print(chunk, end="", flush=True)
print("\nDone!")- Main README - Quick start and overview
- Best Practices - Which vocabulary, which encode mode, how to size a model
- Vocabularies - What is bundled and what each one contains
- Special Tokens Documentation - Complete agent tokens reference
- ByteLevel BPE Documentation - ByteLevel encoding details
- API Documentation (Rust) - Complete Rust API reference
- GitHub Repository - Source code and examples