"""Negative-path validation tests for unsloth.chat_templates.construct_chat_template. Regression coverage for the no-match guards added in the PR #5763 follow-up: missing placeholders or unrecoverable two-example structures must raise RuntimeError with a clear message (not IndexError/AttributeError) and must not silently drop the last char via s[:-1]. A minimal fake tokenizer keeps the cases CPU-only (no HF_TOKEN, no gated download). """ from types import SimpleNamespace import pytest from unsloth.chat_templates import construct_chat_template class _FakeTokenizer: """Minimal surface construct_chat_template touches before the guards fire.""" name_or_path = "fake/tokenizer" eos_token = "" def get_vocab(self): return {"": 0} @pytest.mark.parametrize( "template, expected_in_message", [ ("only {INPUT} here, no output marker", "{OUTPUT}"), ("only {OUTPUT} here, no input marker", "{INPUT}"), ("neither sentinel here, just literal text", "{INPUT}"), ("neither sentinel here, just literal text", "{OUTPUT}"), ], ) def test_missing_placeholder_in_chat_template_raises(template, expected_in_message): with pytest.raises(RuntimeError) as exc_info: construct_chat_template( tokenizer = _FakeTokenizer(), chat_template = template, extra_eos_tokens = [""], ) assert expected_in_message in str(exc_info.value) def test_single_pair_template_raises_clear_error_not_attribute_error(): """A single {INPUT}/{OUTPUT} pair must raise RuntimeError, not the old AttributeError on `found.group(1)` when the loop broke without setting `found`.""" template = "user: {INPUT}\nassistant: {OUTPUT}\n" with pytest.raises(RuntimeError): construct_chat_template( tokenizer = _FakeTokenizer(), chat_template = template, extra_eos_tokens = [""], ) def test_error_message_excerpt_is_bounded(): """Error messages must include a bounded excerpt of the offending template, not dump arbitrarily large content into the traceback.""" huge = ("garbage " * 5000) + "{INPUT}" # ~40 KB, missing {OUTPUT} with pytest.raises(RuntimeError) as exc_info: construct_chat_template( tokenizer = _FakeTokenizer(), chat_template = huge, extra_eos_tokens = [""], ) msg = str(exc_info.value) # Excerpt is capped well under the template length. assert len(msg) < 1000 assert "{OUTPUT}" in msg class _SuccessFakeTokenizer(_FakeTokenizer): """Adds the surface construct_chat_template touches on the success path.""" bos_token = "" bos_token_id = 1 added_tokens_decoder: dict = {} def __call__(self, text): # input_ids[0] must differ from bos_token_id so the BOS-handling branch is skipped. return SimpleNamespace(input_ids = [5]) @pytest.mark.parametrize( "chat_template", [ # User turn begins with {INPUT} (no prefix before the sentinel). "{INPUT} [/INST] {OUTPUT}{INPUT} [/INST] {OUTPUT}", # Assistant turn begins with {OUTPUT} (no prefix before the sentinel). "User: {INPUT}\n{OUTPUT}User: {INPUT}\n{OUTPUT}", ], ) def test_chat_template_does_not_leak_sentinel_when_section_starts_with_it(chat_template): """When an input/output section begins with the {INPUT}/{OUTPUT} sentinel, the generated Jinja template must not keep the literal sentinel text. The `startswith` branch in the internal `process()` helper used to slice from `find()` (which is 0 here) instead of past the sentinel, re-including the literal `{INPUT}`/`{OUTPUT}`.""" _, jinja_template, _, _ = construct_chat_template( tokenizer = _SuccessFakeTokenizer(), chat_template = chat_template, extra_eos_tokens = [""], ) assert "{INPUT}" not in jinja_template assert "{OUTPUT}" not in jinja_template