fix(llg): honor tokenizer special flag in toktrie detok - #2319
Open
subin9 wants to merge 1 commit into
Open
Conversation
`toktrie_hf_tokenizers::ByteTokenizer::from_tokenizer` marks every added-vocabulary token with SPECIAL_TOKEN_MARKER, ignoring the tokenizer's own per-token `special` flag. The completion detok (pipeline/sampling.rs -> tok_trie().decode_ext(tokens, include_special)) runs with include_special=false, so it drops those tokens from the output string. Any model whose tokenizer has `special=false` added tokens therefore loses them from completions, diverging from transformers decode(skip_special_tokens=True). Example: a tokenizer flags `<x>` as special=false; generating the sequence [<x>, A] yields "A" instead of "<x>A". Fix: after from_tokenizer, strip the marker from added tokens the tokenizer flags special=false so they decode as content. The existing loop that marks special=true tokens is untouched, so EOS/BOS/image/chat delimiters remain dropped exactly as before. Adds honors_tokenizer_special_flag: a before/after check on real tokenizers confirming special=false added tokens survive while special=true tokens (EOS, <|im_start|>, image) stay dropped.
Code Metrics Report━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Language Files Lines Code Comments Blanks ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ C Header 23 4454 3116 790 548 CSS 3 282 252 6 24 CUDA 119 23681 19217 1704 2760 Dockerfile 1 35 19 9 7 HTML 2 27 27 0 0 JavaScript 3 577 562 12 3 Jinja2 7 694 656 5 33 JSON 27 15864 15861 0 3 Makefile 1 18 16 0 2 MDX 36 5901 0 4327 1574 Metal Shading Lan| 37 14287 11284 1136 1867 PowerShell 1 656 570 31 55 Python 151 12284 10191 484 1609 Shell 3 1062 843 117 102 Plain Text 53 10687 0 9209 1478 TOML 28 1368 1189 39 140 TypeScript 11 1641 1404 66 171 YAML 3 25 23 2 0 ───────────────────────────────────────────────────────────────────────────────── Jupyter Notebooks 3 122 83 23 16 |- Markdown 1 60 30 22 8 |- Python 1 122 113 1 8 (Total) 304 226 46 32 ───────────────────────────────────────────────────────────────────────────────── Markdown 273 11726 0 8736 2990 |- BASH 23 295 217 46 32 |- Dockerfile 2 5 5 0 0 |- JSON 6 289 289 0 0 |- PowerShell 1 1 1 0 0 |- Python 135 7239 6021 310 908 |- Rust 62 3820 2838 388 594 |- TOML 7 77 65 0 12 (Total) 23452 9436 9480 4536 ───────────────────────────────────────────────────────────────────────────────── Rust 674 299702 267081 5872 26749 |- Markdown 413 9761 452 8126 1183 (Total) 309463 267533 13998 27932 ───────────────────────────────────────────────────────────────────────────────── Svelte 19 1969 1826 51 92 |- CSS 1 4 4 0 0 |- JavaScript 19 921 767 25 129 (Total) 2894 2597 76 221 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Total 1478 429656 345022 41537 43097 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
This was referenced Jul 8, 2026
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.
Problem
Any model whose tokenizer has added tokens flagged
special = falseloses those tokens from thedecoded completion string, diverging from HuggingFace
transformers. It's silent — no error, thetoken ids are generated correctly; only the decode drops them.
Surfaced with PaddleOCR-VL, whose tables are emitted as OTSL added tokens
<fcel>/<nl>(
special = false). They were dropped, collapsing a table into a run-on string of cell text with norows or columns:
This is general, not PaddleOCR-specific: any
special = false<...>-shaped added token (e.g.Qwen2.5-VL's
<tool_call>) is affected.Root cause (upstream heuristic)
toktrie_hf_tokenizers::ByteTokenizer::from_tokenizermarks a token special by a name-shapeheuristic that overrides the tokenizer's own flag (
src/lib.rs:121-122in the v1.4.0 this reporesolves;
:188on currentllguidancemain):So every
<...>-shaped added token gets theSPECIAL_TOKEN_MARKER, and the completion detok —tok_trie().decode_ext(tokens, include_special=false)— strips it. The heuristic is intentionalupstream (added in guidance-ai/llguidance#202 to mirror the
transformerspath for GGUF, where areliable flag is often absent), but it also overrides an explicit
special = false. Filedupstream: guidance-ai/llguidance#361. This PR is a correct local correction valid regardless of the
upstream outcome.
Precedent
Same class of bug was fixed narrowly in #1950 (merged), which enabled
include_specialduringdecode so
<think>/</think>delimiters appear — but only in think-tag mode. This PR fixes thegeneral case at the token-classification source instead of per-feature.
Fix
After
from_tokenizer, strip theSPECIAL_TOKEN_MARKERfrom thetoken_bytesof added tokens thetokenizer flags
special = false, so they decode as content — matchingtransformers.decode(skip_special_tokens=True). The existing loop that marksspecial = truetokens is untouched, so EOS/BOS/image/chat-delimiter tokens are dropped exactly as before.
Decode-only: it never changes which token id is generated.
Verification (
llg::tests::honors_tokenizer_special_flag, before/after on real tokenizers)<fcel>(PaddleOCR-VL OTSL)""(dropped)"<fcel>"(kept)</s>(PaddleOCR-VL EOS)""""<|IMAGE_END|>(PaddleOCR-VL)""""<|im_start|>(Qwen2.5-VL chat delimiter)""""(no leak)<tool_call>(Qwen2.5-VL)"""<tool_call>"(now content, per HF)special = truetokens stay dropped; only tokenizer-flaggedspecial = falsetokens survive.clippy -p mistralrs-core --tests -- -D warningsclean. Two tests cover this:honors_special_flag_inline_fixturebuilds a tiny byte-level tokenizer in-process (onespecial=false<x>, onespecial=true<s>) and runs in CI with no external files;honors_tokenizer_special_flagis the same before/after check against the real PaddleOCR-VL andQwen2.5-VL tokenizers (env-gated on local files, so it skips in CI when they're absent).
Tool-calling safety
The
<tool_call>row changes a shipped feature's detok, so it was checked explicitly. Qwen<tool_call>(special = false) surviving into content in a non-tool-calling generation doesnot break tool-calling:
seq.tool_call_stateisSomeonly when tools are requested, and everytool-parse entrypoint (
prefix_status,complete_if_tool_call,maybe_activate_continuation_grammar,finalize_for_response, the Gemma content matcher) is gatedon it — there is no ungated content-stream scan for tool markers. When
tool_call_state.is_some()detok already runs
include_special=true, so<tool_call>is in the stream regardless of this fix;tool sessions are byte-identical before/after. Non-tool sessions run no parser, so the token is
inert text (matching HF).