* Studio: stop chat generation on the assistant-turn-end token A small chat model (e.g. Qwen3.5-0.8B) looped on the safetensors path: it emitted a valid response or tool call, then ran past its turn and re-emitted the call, hallucinating <|im_start|>user turns. Root cause: the model's tokenizer.eos_token is synced to the config document terminator (<|endoftext|>, 248044) while chat turns actually end with <|im_end|> (248046), so generate_stream's single eos_token_id never stopped at the turn boundary. Stop on every assistant-turn-end marker the vocab defines (tokenizer.eos plus <|im_end|>, <|eot_id|>, <end_of_turn>, ...). Verified on the real weights: the single-eos control loops (400 tokens) while the fixed set yields a clean 38-token tool call and a clean answer from the tool result. No-op when eos is already the turn-ender (the id just dedups). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: repair chat generation_config.eos_token_id at load time Qwen3.5 / Qwen3.6 small chat checkpoints declare the chat turn-end as tokenizer.eos_token (<|im_end|>) but ship config.eos_token_id = <|endoftext|> and no generation_config.json (upstream shipped generation_config only on the large chat models). So every .generate() path that reads generation_config -- the vision path and tool loops, not just generate_stream -- never stops at the turn boundary and loops. At load time, when the tokenizer's own eos is a chat turn-end marker but generation_config.eos_token_id omits it, add it. This fixes the config once for all generation paths and complements the generate_stream turn-end stop. No-op for base models (eos is a plain document terminator) and already-correct configs. Verified on unsloth/Qwen3.5-0.8B: 248044 -> [248044, 248046]. * Studio: derive chat turn-end eos from the template, resolve once at load Address PR review of the turn-end stop handling: - Do not call tokenizer.get_vocab() per generation request (serializes the whole 100k+ vocab). Resolve the turn-end tokens once at load and cache them on model_info; generate_stream reads the cache. - Derive turn-end markers from the chat_template the model actually uses, not raw vocab membership, so a base/coder model that merely carries ChatML control tokens in a shared vocab is not stopped early, and a loader that synced tokenizer.eos to the document terminator is still covered. - Skip harmony/gpt-oss templates: <|end|> there is an intra-message channel delimiter, not the turn end (dropped <|return|> from the marker list too). - Move the logic to a dependency-light module (core.inference.chat_eos) so the unit test does not import the full unsloth/torch inference stack. Verified on unsloth/Qwen3.5-0.8B (gen_config 248044 -> [248044, 248046], clean 38-token tool call with generation_config-only stopping), Phi-3.5 (adds <|end|>), Llama-3 / Qwen3 (unchanged), and a harmony template (left untouched). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: refresh turn-end eos after the mapper installs its template For a MODEL_TO_TEMPLATE_MAPPER model whose own tokenizer ships no chat_template, the effective template is applied at generate time via get_chat_template, but the turn-end eos ids were resolved once at load when the template was still empty, so only the document eos was cached. Qwen2.5 / Yi base checkpoints (eos <|endoftext|>, ChatML turns end with <|im_end|>) then run past the assistant boundary in generate_stream and loop. Re-resolve the turn-end eos from the now-templated tokenizer and refresh the cached ids right after applying the mapper template, so generate_stream stops at the ChatML turn end. Add a regression test. * Studio: union turn-end eos refresh into load-time cache instead of overwriting get_chat_template can return a different tokenizer whose vocab was remapped (Gemma folds <end_of_turn> onto the eos id), while generate_stream re-reads the original model_info tokenizer. Overwriting the cache with the refreshed set dropped a valid load-time id (e.g. <end_of_turn>=107) and let generation run past the real turn marker. Union the refresh into the existing cache so it can only add ids, never drop a valid one. Add a regression test covering the destructive-swap case the prior test missed. * Studio: resolve refreshed turn-end ids on the generation tokenizer, add Gemma-4 marker Two residual gaps in the turn-end eos refresh: - For map_eos_token=True mapped templates (e.g. chatml on a Yi-6B base), get_chat_template returns a tokenizer whose vocab folds the turn-end token onto the document eos id, while generate_stream re-reads the original tokenizer. The refresh resolved ids on the returned tokenizer, so it stored the doc eos and missed the real turn-end id, and generation ran past the boundary. Read the turn-end marker strings from the mapped template but resolve their ids on the original generation tokenizer (new resolve_chat_turn_end_eos_ids_using). - Add Gemma-4's <turn|> turn terminator to the marker allowlist; those templates keep a document eos so resolve otherwise missed the real turn marker. Add regression tests for both. * Fix turn-end detection for Starling, multi-variant and vision templates; keep tests collectable The turn-end marker set missed OpenChat/Starling's barred <|end_of_turn|> (distinct from Gemma's unbarred form), so Starling generations ran past the assistant boundary. A dict/list chat_template (Hermes-3 style default+tool_use variants) hit an early non-string return and skipped detection; flatten and scan every variant. Vision models carry the chat_template on the ProcessorMixin, not the unwrapped inner tokenizer, so read markers from the template-carrying container while resolving ids on the generation tokenizer. The refresh test constructs the real backend, so it is guarded with a module-level skip when unsloth/unsloth_zoo is absent (the lightweight pytest matrix), and core.inference package init is made lazy so the dependency-light chat_eos tests collect without the heavy stack. * Studio: tighten chat turn-end eos comments * Studio: condense chat turn-end eos comments --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
194 lines
8.6 KiB
Python
194 lines
8.6 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Mapper models whose own tokenizer ships no chat_template have their turn-end
|
|
eos resolved at LOAD from an empty template (document eos only). The effective
|
|
template is installed later, at generate time, via get_chat_template, so the
|
|
turn-end-eos cache must be refreshed then; otherwise generate_stream runs past
|
|
the ChatML <|im_end|> boundary and loops (the exact bug this PR fixes).
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_BACKEND = Path(__file__).resolve().parent.parent
|
|
if str(_BACKEND) not in sys.path:
|
|
sys.path.insert(0, str(_BACKEND))
|
|
|
|
# These tests construct InferenceBackend, pulling the full stack. CI may lack
|
|
# unsloth/unsloth_zoo (ImportError) or have a broken CUDA/bitsandbytes setup
|
|
# (RuntimeError); skip at module level so collection is not aborted (exit 2).
|
|
try:
|
|
from core.inference import inference as inf_mod # noqa: E402
|
|
from core.inference.inference import InferenceBackend # noqa: E402
|
|
except (ImportError, RuntimeError) as exc: # pragma: no cover - env-dependent
|
|
pytest.skip(
|
|
f"full inference backend unavailable ({type(exc).__name__}: {exc})",
|
|
allow_module_level = True,
|
|
)
|
|
|
|
_CHATML = "{% for m in messages %}<|im_start|>{{m.role}}\n{{m.content}}<|im_end|>{% endfor %}"
|
|
_GEMMA = "{% for m in messages %}<start_of_turn>{{m.role}}\n{{m.content}}<end_of_turn>{% endfor %}"
|
|
|
|
|
|
class _FakeTokenizer:
|
|
def __init__(
|
|
self,
|
|
eos_id,
|
|
chat_template = "",
|
|
token_ids = None,
|
|
):
|
|
self.eos_token_id = eos_id
|
|
self.chat_template = chat_template
|
|
self.pad_token_id = eos_id
|
|
self.unk_token_id = None
|
|
self._ids = dict(token_ids or {})
|
|
|
|
def convert_tokens_to_ids(self, tok):
|
|
return self._ids.get(tok)
|
|
|
|
|
|
def test_turn_end_eos_refreshed_after_generate_time_template(monkeypatch):
|
|
import utils.datasets as ds
|
|
|
|
backend = InferenceBackend.__new__(InferenceBackend)
|
|
backend.active_model_name = "unsloth/qwen2.5-0.5b"
|
|
|
|
# No chat_template at load, so the cache stored only the document eos, though
|
|
# <|im_end|> is atomic in the vocab (unused until the mapper installs a template).
|
|
bare_tok = _FakeTokenizer(151643, chat_template = "", token_ids = {"<|im_end|>": 151645})
|
|
model_info = {
|
|
"tokenizer": bare_tok,
|
|
"is_vision": False,
|
|
"chat_turn_end_eos_ids": [151643],
|
|
}
|
|
backend.models = {backend.active_model_name: model_info}
|
|
|
|
# The mapper installs a ChatML template (turns end with <|im_end|>) at generate time.
|
|
templated_tok = _FakeTokenizer(151643, chat_template = _CHATML, token_ids = {"<|im_end|>": 151645})
|
|
monkeypatch.setattr(inf_mod, "get_chat_template", lambda tok, chat_template = None: templated_tok)
|
|
monkeypatch.setattr(
|
|
ds, "MODEL_TO_TEMPLATE_MAPPER", {backend.active_model_name: "qwen-2.5"}, raising = False
|
|
)
|
|
|
|
# Stub the tail so the generator runs through the refresh without a real model.
|
|
monkeypatch.setattr(backend, "_normalize_top_k", lambda k: k, raising = False)
|
|
monkeypatch.setattr(
|
|
backend, "_apply_chat_template_for_generation", lambda *a, **k: "PROMPT", raising = False
|
|
)
|
|
monkeypatch.setattr(backend, "generate_stream", lambda *a, **k: iter(()), raising = False)
|
|
|
|
list(backend._generate_chat_response_inner(messages = [{"role": "user", "content": "hi"}]))
|
|
|
|
# After the template is applied the cache must include the ChatML turn-end id.
|
|
assert model_info["chat_turn_end_eos_ids"] == [151643, 151645]
|
|
|
|
|
|
def test_turn_end_eos_refresh_preserves_load_time_ids_on_destructive_swap(monkeypatch):
|
|
# Regression: get_chat_template can return a remapped tokenizer (Gemma: <end_of_turn>
|
|
# folded onto the eos id) while generate_stream re-reads the original. Resolving on
|
|
# the swap yields a narrower set, so the refresh must UNION, never overwrite.
|
|
import utils.datasets as ds
|
|
|
|
backend = InferenceBackend.__new__(InferenceBackend)
|
|
backend.active_model_name = "unsloth/gemma-2b-it"
|
|
|
|
# Original tokenizer (used by generate_stream): <end_of_turn>=107 distinct from
|
|
# eos=1, so the load-time cache resolved to [1, 107].
|
|
orig_tok = _FakeTokenizer(1, chat_template = _GEMMA, token_ids = {"<end_of_turn>": 107})
|
|
model_info = {
|
|
"tokenizer": orig_tok,
|
|
"is_vision": False,
|
|
"chat_turn_end_eos_ids": [1, 107],
|
|
}
|
|
backend.models = {backend.active_model_name: model_info}
|
|
|
|
# Destructively-swapped tokenizer: <end_of_turn> now maps onto eos id 1, so
|
|
# resolving on it yields only [1] (drops 107).
|
|
swapped_tok = _FakeTokenizer(1, chat_template = _GEMMA, token_ids = {"<end_of_turn>": 1})
|
|
monkeypatch.setattr(inf_mod, "get_chat_template", lambda tok, chat_template = None: swapped_tok)
|
|
monkeypatch.setattr(
|
|
ds, "MODEL_TO_TEMPLATE_MAPPER", {backend.active_model_name: "gemma-3"}, raising = False
|
|
)
|
|
|
|
monkeypatch.setattr(backend, "_normalize_top_k", lambda k: k, raising = False)
|
|
monkeypatch.setattr(
|
|
backend, "_apply_chat_template_for_generation", lambda *a, **k: "PROMPT", raising = False
|
|
)
|
|
monkeypatch.setattr(backend, "generate_stream", lambda *a, **k: iter(()), raising = False)
|
|
|
|
list(backend._generate_chat_response_inner(messages = [{"role": "user", "content": "hi"}]))
|
|
|
|
# The load-time <end_of_turn>=107 must survive: overwriting with the swapped
|
|
# [1] would regress and loop past the turn.
|
|
assert model_info["chat_turn_end_eos_ids"] == [1, 107]
|
|
|
|
|
|
def test_turn_end_eos_refresh_resolves_marker_id_on_original_not_remapped(monkeypatch):
|
|
# Yi-style map_eos_token=True: the original carries <|im_end|> at its own id, but
|
|
# get_chat_template folds it onto the doc-eos id. generate_stream uses the original,
|
|
# so read marker strings from the mapped template but ids from the original.
|
|
import utils.datasets as ds
|
|
|
|
backend = InferenceBackend.__new__(InferenceBackend)
|
|
backend.active_model_name = "01-ai/yi-6b"
|
|
|
|
# Original: no template of its own, doc eos = 2, <|im_end|> atomic = 7.
|
|
orig_tok = _FakeTokenizer(2, chat_template = "", token_ids = {"<|im_end|>": 7})
|
|
model_info = {
|
|
"tokenizer": orig_tok,
|
|
"is_vision": False,
|
|
"chat_turn_end_eos_ids": [2],
|
|
}
|
|
backend.models = {backend.active_model_name: model_info}
|
|
|
|
# Remapped tokenizer: ChatML template, but <|im_end|> folded onto doc-eos id 2.
|
|
remapped_tok = _FakeTokenizer(2, chat_template = _CHATML, token_ids = {"<|im_end|>": 2})
|
|
monkeypatch.setattr(inf_mod, "get_chat_template", lambda tok, chat_template = None: remapped_tok)
|
|
monkeypatch.setattr(
|
|
ds, "MODEL_TO_TEMPLATE_MAPPER", {backend.active_model_name: "chatml"}, raising = False
|
|
)
|
|
|
|
monkeypatch.setattr(backend, "_normalize_top_k", lambda k: k, raising = False)
|
|
monkeypatch.setattr(
|
|
backend, "_apply_chat_template_for_generation", lambda *a, **k: "PROMPT", raising = False
|
|
)
|
|
monkeypatch.setattr(backend, "generate_stream", lambda *a, **k: iter(()), raising = False)
|
|
|
|
list(backend._generate_chat_response_inner(messages = [{"role": "user", "content": "hi"}]))
|
|
|
|
# The real <|im_end|>=7 (original vocab) must be recovered, not the remapped 2.
|
|
assert model_info["chat_turn_end_eos_ids"] == [2, 7]
|
|
|
|
|
|
class _FakeProcessor:
|
|
"""A ProcessorMixin-like container: carries the chat_template itself and
|
|
wraps the real text tokenizer as ``.tokenizer`` (the vision layout)."""
|
|
|
|
def __init__(self, chat_template, tokenizer):
|
|
self.chat_template = chat_template
|
|
self.tokenizer = tokenizer
|
|
|
|
|
|
def test_resolve_chat_eos_reads_vision_processor_template():
|
|
# Vision model: the chat_template lives on the processor while the inner tokenizer
|
|
# ships none. _resolve_chat_eos must read the marker from the processor but resolve
|
|
# its id on the inner tokenizer, and repair generation_config.
|
|
from types import SimpleNamespace
|
|
|
|
inner_tok = _FakeTokenizer(1, chat_template = "", token_ids = {"<end_of_turn>": 107})
|
|
processor = _FakeProcessor(_GEMMA, inner_tok)
|
|
model = SimpleNamespace(generation_config = SimpleNamespace(eos_token_id = 1))
|
|
|
|
backend = InferenceBackend.__new__(InferenceBackend)
|
|
backend.active_model_name = "unsloth/gemma-3-4b-it"
|
|
model_info = {"model": model, "tokenizer": processor, "processor": processor, "is_vision": True}
|
|
backend.models = {backend.active_model_name: model_info}
|
|
|
|
backend._resolve_chat_eos(backend.active_model_name)
|
|
|
|
assert model_info["chat_turn_end_eos_ids"] == [1, 107]
|
|
# generation_config repaired so the vision .generate() path stops at the turn.
|
|
assert model.generation_config.eos_token_id == [1, 107]
|