Skip to content

Commit d545d5d

Browse files
Fix detokenizer for byte-level tokenizers with an SPM-style decoder
Mistral tekken v13 tokenizers (Devstral-Small-2, etc.) carry an SPM-style decoder ("Replace ▁->space, ByteFallback, Fuse, Strip") but a ByteLevel pre-tokenizer, so their vocabulary uses GPT-2 byte markers. load() selected the detokenizer from the decoder field alone, matching _is_spm_decoder and routing them to SPMStreamingDetokenizer, which only strips the "▁" marker and left the byte-level space marker "Ġ" (U+0120) in the output. Trust the pre-tokenizer: a ByteLevel vocabulary must use BPEStreamingDetokenizer regardless of the decoder shape. Genuine SPM tokenizers (no ByteLevel pre-tokenizer) are unaffected. Fixes #1041.
1 parent df1d3f3 commit d545d5d

2 files changed

Lines changed: 80 additions & 4 deletions

File tree

mlx_lm/tokenizer_utils.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,26 @@ def _is_bpe_decoder(decoder):
545545
return isinstance(decoder, dict) and decoder.get("type", None) == "ByteLevel"
546546

547547

548+
def _has_byte_level_pretokenizer(tokenizer_content):
549+
"""Whether the tokenizer encodes its vocabulary with byte-level markers.
550+
551+
A ``ByteLevel`` pre-tokenizer (possibly nested in a ``Sequence``) means the
552+
vocabulary uses GPT-2-style byte markers (e.g. ``Ġ`` for spaces), which only
553+
``BPEStreamingDetokenizer`` knows how to decode -- regardless of what the
554+
``decoder`` field looks like.
555+
"""
556+
557+
def _is_byte_level(node):
558+
return isinstance(node, dict) and node.get("type", None) == "ByteLevel"
559+
560+
pre_tokenizer = tokenizer_content.get("pre_tokenizer")
561+
if _is_byte_level(pre_tokenizer):
562+
return True
563+
if isinstance(pre_tokenizer, dict) and pre_tokenizer.get("type") == "Sequence":
564+
return any(_is_byte_level(p) for p in pre_tokenizer.get("pretokenizers", []))
565+
return False
566+
567+
548568
def _infer_tool_parser(chat_template):
549569
"""Attempt to auto-infer a tool parser from the chat template."""
550570
if not isinstance(chat_template, str):
@@ -598,12 +618,20 @@ def load(
598618
raise JSONDecodeError("Failed to parse tokenizer.json", e.doc, e.pos)
599619

600620
if "decoder" in tokenizer_content:
601-
if _is_spm_decoder(tokenizer_content["decoder"]):
621+
decoder = tokenizer_content["decoder"]
622+
if _is_bpe_decoder(decoder) or _has_byte_level_pretokenizer(
623+
tokenizer_content
624+
):
625+
# A byte-level vocabulary must use the BPE detokenizer even when
626+
# the tokenizer ships an SPM-style decoder (e.g. Mistral tekken
627+
# v13): the SPM detokenizer only strips the ``▁`` marker and so
628+
# leaves the byte-level space marker ``Ġ`` (U+0120) in the
629+
# output. See issue #1041.
630+
detokenizer_class = BPEStreamingDetokenizer
631+
elif _is_spm_decoder(decoder):
602632
detokenizer_class = SPMStreamingDetokenizer
603-
elif _is_spm_decoder_no_space(tokenizer_content["decoder"]):
633+
elif _is_spm_decoder_no_space(decoder):
604634
detokenizer_class = partial(SPMStreamingDetokenizer, trim_space=False)
605-
elif _is_bpe_decoder(tokenizer_content["decoder"]):
606-
detokenizer_class = BPEStreamingDetokenizer
607635

608636
if isinstance(eos_token_ids, int):
609637
eos_token_ids = [eos_token_ids]

tests/test_tokenizers.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
BPEStreamingDetokenizer,
1010
NaiveStreamingDetokenizer,
1111
SPMStreamingDetokenizer,
12+
_has_byte_level_pretokenizer,
1213
)
1314
from mlx_lm.utils import load_tokenizer
1415

@@ -109,6 +110,53 @@ def test_thinking(self):
109110
self.assertIsNone(tokenizer.think_start_id)
110111
self.assertIsNone(tokenizer.think_end_id)
111112

113+
def test_has_byte_level_pretokenizer(self):
114+
byte_level = {"type": "ByteLevel"}
115+
self.assertTrue(_has_byte_level_pretokenizer({"pre_tokenizer": byte_level}))
116+
self.assertTrue(
117+
_has_byte_level_pretokenizer(
118+
{
119+
"pre_tokenizer": {
120+
"type": "Sequence",
121+
"pretokenizers": [{"type": "Split"}, byte_level],
122+
}
123+
}
124+
)
125+
)
126+
self.assertFalse(
127+
_has_byte_level_pretokenizer(
128+
{"pre_tokenizer": {"type": "Metaspace", "replacement": "▁"}}
129+
)
130+
)
131+
self.assertFalse(_has_byte_level_pretokenizer({}))
132+
133+
def test_byte_level_vocab_with_spm_decoder(self):
134+
# Mistral tekken v13 tokenizers carry an SPM-style decoder but a
135+
# byte-level vocabulary (ByteLevel pre-tokenizer). They must use the BPE
136+
# detokenizer; the SPM detokenizer only strips the "▁" marker and so
137+
# leaves the byte-level space marker "Ġ" (U+0120) in the output.
138+
# Regression test for #1041. Download tokenizer files only -- the model
139+
# weights are irrelevant to detokenization and very large.
140+
repo = "mlx-community/Devstral-Small-2-24B-Instruct-2512-bf16"
141+
path = Path(
142+
snapshot_download(
143+
repo,
144+
allow_patterns=["*.json", "*.model", "*tokenizer*", "*.jinja"],
145+
)
146+
)
147+
tokenizer = load_tokenizer(path)
148+
self.assertIsInstance(tokenizer.detokenizer, BPEStreamingDetokenizer)
149+
150+
text = "Hello! How can I assist you today?"
151+
tokens = tokenizer.encode(text, add_special_tokens=False)
152+
detokenizer = tokenizer.detokenizer
153+
detokenizer.reset()
154+
for t in tokens:
155+
detokenizer.add_token(t)
156+
detokenizer.finalize()
157+
self.assertNotIn("Ġ", detokenizer.text)
158+
self.assertEqual(detokenizer.text, text)
159+
112160

113161
if __name__ == "__main__":
114162
unittest.main()

0 commit comments

Comments
 (0)