Skip to content

fix(tokenizer): resolve eos/bos ids from tokenizer_config.json (MODEL-QWEN35-EXL3) - #3363

Open
ghazni101 wants to merge 3 commits into
localai-org:mainfrom
ghazni101:row/MODEL-QWEN35-EXL3-eos
Open

ghazni101 wants to merge 3 commits into
localai-org:mainfrom
ghazni101:row/MODEL-QWEN35-EXL3-eos

Conversation

@ghazni101

@ghazni101 ghazni101 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Checkpoint tokenizer.json files that ship a bare ByteLevel post_processor (every Qwen-family checkpoint) leave eos_id_/bos_id_ unset: ExtractBosEos reads only the TemplateProcessing template. When config.json and generation_config.json also decline to name eos, requests carry no eos id at all — the model emits <|im_end|> and the engine ignores it, generating fake turns to max_tokens. Measured live on Qwen3.5-9B-EXL3-4.00bpw (ROCm gfx1101): <|im_end|> (id 248046) emitted at token 10, request ran to finish_reason=length. Issue: #3364 (local ISSUE-LOCAL-01M3RY20H90NMK37V74EH34Z77, mirrored and synced).

HF resolves tokenizer.eos_token_id from the eos_token/bos_token NAMES in tokenizer_config.json (string or AddedToken object), optionally supplied by special_tokens_map.json — AutoTokenizer on this checkpoint answers 248046 (measured, transformers 5.17.0). FromHfJson now reads both sibling files and fills any id the post_processor left at -1. Template ids keep precedence; add_bos_token/add_eos_token stay unhonoured (HF ignores them on load — pinned); tokenizer_config.json wins over special_tokens_map.json via contains()-gated overwrite, matching from_pretrained kwarg ordering.

Verify: test_bpe 30/30, 1020 assertions (run in the ROCm toolchain image; the host lacks cmake so the checker is not host-reproducible). Mutation check: reverting the precedence fix fails the new override subcase with EosId -1 vs 19. Live verification: serving container rebuilt on this change returns finish_reason=stop with clean reasoning/content split, where the unfixed build ran to length.

Out of scope: add_bos/add_eos encode-side application (HF does not apply them either); GGUF path untouched (own kv EOS).

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: OMP:devin/swe-2 [omp]

tokenizer.json's ByteLevel post_processor carries no TemplateProcessing
template, so ExtractBosEos left eos_id_/-bos_id_ unset on every Qwen-family
checkpoint. When the model's config.json and generation_config.json also
decline to name an eos_token_id, the request goes out with no eos at all
and the model's <|im_end|> is generated then ignored: a Qwen3.5-9B-EXL3
chat reply emitted id 248046 at token 10 and ran to finish_reason=length,
hallucinating further turns (measured live, ISSUE-LOCAL-01M3RY20H90NMK37V74EH34Z77).

HF resolves the same id from tokenizer_config.json's eos_token name
(string or AddedToken object; AutoTokenizer on the shipped config answers
248046, transformers 5.17.0). FromHfJson now reads the sibling
tokenizer_config.json and special_tokens_map.json and fills bos_id_/eos_id_
from the declared names -- only where the post_processor left them unset,
so a TemplateProcessing id keeps precedence (the pinned OPT case). The
add_bos_token/add_eos_token keys stay unhonoured: HF's loaded class ignores
them (measured) and EncodeWithSpecialTokens keeps reading only
template_bos_/template_eos_.

The DeepSeek-V2 pin's BosId()==-1 assertion was updated: HF resolves
bos_token_id from the config name even when add_bos_token resolves False
(measured, same transformers), so the pinned behaviour is the encode, not
the getter.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: OMP:devin/swe-2 [omp]
…p.json

Adversarial review of the eos/bos name fallback found the merge precedence
inverted: the first-nonempty guard let special_tokens_map.json lock in a
name before tokenizer_config.json was read, so the older file won. HF's
from_pretrained applies the config's init kwargs after consuming the map
file, so a declared key in tokenizer_config.json must overwrite the map's
value. Unconditional overwrite regressed differently -- a config that does
not declare the key would erase the map's name -- so contains() gates it:
value() cannot distinguish absent from an explicit null.

Verified empirically: the new 'tokenizer_config.json overrides
special_tokens_map.json' subcase fails on the committed code (EosId -1 vs
expected 19) and passes here; test_bpe is 30/30 green, 1020 assertions.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: OMP:devin/swe-2 [omp]
@ghazni101
ghazni101 force-pushed the row/MODEL-QWEN35-EXL3-eos branch from 86a6d22 to d5e6535 Compare September 30, 2026 16:49

@localai-org-maint-bot localai-org-maint-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new sibling-file lookup is skipped for a valid bare relative filename. In FromHfJson, std::filesystem::path("tokenizer.json").parent_path() is empty, so if (!dir.empty()) bypasses the entire EOS/BOS fix even when tokenizer_config.json is in the current directory. Loading the same bytes as "./tokenizer.json" takes the new path instead. For the ByteLevel fixture with eos_token: "<|end|>", the first spelling therefore leaves EosId() at -1 while the second resolves 19.

Please resolve an empty parent as . (or allow the empty directory to participate in the sibling join) and add a regression that loads both path spellings from the fixture directory. This is a public Tokenizer::FromHfJson path, and callers such as the tokenizer example pass the supplied filename directly.

I read the complete diff at d5e6535 against current main fce3673 and checked the callers. This is a static finding: the C++ regression, oracle comparison, and repository gate could not run here because Python/CMake/the C++ compiler are absent. I cannot push a verified repair under the repository's gate-before-push requirement from this environment. @mudler @richiejp

…arent (MODEL-QWEN35-EXL3)

FromHfJson("tokenizer.json") computed an empty parent_path and the
!dir.empty() guard skipped the entire tokenizer_config.json /
special_tokens_map.json lookup, so a bare relative filename left eos_id
at -1 while "./tokenizer.json" resolved it (PR localai-org#3363 review). The
guard is removed: joining a sibling name onto an empty path yields the
bare sibling filename, which std::filesystem resolves against the CWD --
the same directory the bare tokenizer.json opened from. A new subcase
loads both spellings from the fixture directory; reverting the fix fails
it with EosId -1 vs 19. Also records the GitHub localai-org#3364 mirror in the row
issue file, synced as part of this PR.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: OMP:devin/swe-2 [omp]
@ghazni101

Copy link
Copy Markdown
Contributor Author

Addressed in cacf4fc.

The !dir.empty() guard is removed. Joining a sibling filename onto the empty parent_path() yields the bare sibling filename, which std::filesystem::exists resolves against the CWD — the same directory the bare tokenizer.json was opened from — so both spellings now take the sibling lookup.

Regression added: subcase "a bare filename resolves siblings from the CWD too" loads both tokenizer.json and ./tokenizer.json from the fixture directory and asserts EosId() == 19 on each. Mutation check reproduced your static finding: restoring the guard fails the new subcase with EosId -1 vs 19 on the bare spelling.

Verify: test_bpe 30/30, 1022 assertions, build-cpu on this host.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants