# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 """Resolve a chat model's assistant-turn-end stop tokens. Some checkpoints set eos_token_id to a bare document terminator (Qwen3.5 ships config eos ``<|endoftext|>`` though chat turns end with ``<|im_end|>``, and its small chat variants ship no generation_config), so generation runs past the turn and loops -- re-emitting tool calls or hallucinating ``<|im_start|>`` turns. Turn-end markers are derived from the tokenizer's ``chat_template`` (the tokens it actually uses to end a turn), not raw vocab membership: a base/coder model can carry ChatML control tokens in a shared vocab without using them, and a loader may have synced ``eos_token`` to the document terminator. Dependency-light (no torch / unsloth) so it is unit-testable without the full inference stack. """ from typing import Optional # Canonical assistant-turn-end markers per chat family. _CHAT_TURN_END_TOKENS = ( "<|im_end|>", # ChatML: Qwen, Yi "<|eot_id|>", # Llama 3.x "<|eom_id|>", # Llama 3.x tool turns "", # Gemma "", # Gemma-4 "<|end|>", # Phi "<|end_of_turn|>", # OpenChat / Starling (barred, distinct from Gemma's) ) # harmony/gpt-oss uses <|end|> as a channel delimiter, not the turn end, and has # its own streamer, so its eos is left untouched. _HARMONY_MARKERS = ("<|channel|>", "<|constrain|>") def _eos_id_set(eos_token_id) -> set: if isinstance(eos_token_id, (list, tuple)): return {int(t) for t in eos_token_id if t is not None} if eos_token_id is not None: return {int(eos_token_id)} return set() def _collect_template_text(chat_template) -> str: """Flatten a tokenizer ``chat_template`` into one scannable string. Usually the template is a single jinja string, but multi-variant models (e.g. Hermes-3: a ``default`` plus a ``tool_use`` template) expose it as a ``{name: template}`` dict -- or, as stored in tokenizer_config.json, a list of ``{"name": ..., "template": ...}`` dicts. Scanning only the ``str`` case would skip turn-end detection for those valid models, so gather every string leaf (variant names are harmless: they never contain the markers). """ if isinstance(chat_template, str): return chat_template if isinstance(chat_template, dict): values = chat_template.values() elif isinstance(chat_template, (list, tuple)): values = chat_template else: return "" parts = [_collect_template_text(v) for v in values] return "\n".join(p for p in parts if p) def resolve_chat_turn_end_eos_ids_using(template_tokenizer, id_tokenizer) -> list: """eos of ``id_tokenizer`` plus any canonical turn-end marker the ``template_tokenizer``'s chat_template uses, resolved to ids on ``id_tokenizer`` -- the tokenizer generation actually uses. Pass the same tokenizer for both at load time. After a mapped ``get_chat_template`` pass the MAPPED tokenizer as ``template_tokenizer`` (it carries the effective template) and the ORIGINAL generation tokenizer as ``id_tokenizer``: a mapped template registered ``map_eos_token=True`` can hand back a tokenizer whose vocab folds the turn-end token onto the doc-eos id, and generate_stream re-reads the original tokenizer, so resolving ids on the mapped tokenizer would store the wrong (doc-eos) id and let generation run past the real turn marker.""" ids = _eos_id_set(getattr(id_tokenizer, "eos_token_id", None)) template = _collect_template_text(getattr(template_tokenizer, "chat_template", None)) if not template or any(h in template for h in _HARMONY_MARKERS): return sorted(ids) unk = getattr(id_tokenizer, "unk_token_id", None) for marker in _CHAT_TURN_END_TOKENS: if marker in template: try: tid = id_tokenizer.convert_tokens_to_ids(marker) except Exception: tid = None if tid is not None and tid != unk and int(tid) >= 0: ids.add(int(tid)) return sorted(ids) def resolve_chat_turn_end_eos_ids(tokenizer) -> list: """tokenizer.eos plus any canonical turn-end marker the model's chat_template actually uses. Cheap (convert_tokens_to_ids per marker, no get_vocab); intended to be resolved once at load. Returns eos unchanged for harmony templates.""" return resolve_chat_turn_end_eos_ids_using(tokenizer, tokenizer) def chat_eos_repair(current_eos, turn_end_ids) -> Optional[list]: """Merged eos_token_id list, or None if ``current_eos`` already covers every resolved turn-end id. Used to repair a model's generation_config at load so every ``.generate()`` path (vision, tool loops) stops at the turn boundary.""" if not turn_end_ids: return None current_set = _eos_id_set(current_eos) if set(turn_end_ids) <= current_set: return None return sorted(current_set | set(turn_end_ids))