# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 """Auto-override of the chat template for ``unsloth/gemma-4-*-GGUF``. Studio ships a bundled ``gemma-4.jinja`` (PR #118 based, ``preserve_thinking`` defaulted off) and applies it to gemma-4 GGUF loads via the existing ``chat_template_override`` -> ``--chat-template-file`` path, so users do not need to re-download quants. Pins the family matcher, the resolver precedence, the bundled asset's reasoning/tool capabilities (which drive the "Preserve thinking" UI toggle), the Jinja gate behaviour, and the reload-dedup interaction. """ from __future__ import annotations import importlib.util import sys import types as _types from pathlib import Path _BACKEND_DIR = str(Path(__file__).resolve().parent.parent) if _BACKEND_DIR not in sys.path: sys.path.insert(0, _BACKEND_DIR) import pytest # ── chat_templates is dependency-light: load it directly so the pure-logic # tests run without the studio venv / core.inference package side effects. ── _CT_PATH = Path(_BACKEND_DIR) / "core" / "inference" / "chat_templates.py" _ct_spec = importlib.util.spec_from_file_location("_gemma4_ct_test", _CT_PATH) chat_templates = importlib.util.module_from_spec(_ct_spec) _ct_spec.loader.exec_module(chat_templates) is_unsloth_gemma4_gguf = chat_templates.is_unsloth_gemma4_gguf resolve_effective_chat_template_override = chat_templates.resolve_effective_chat_template_override load_bundled_chat_template = chat_templates.load_bundled_chat_template is_unsloth_gemma4_edge_gguf = chat_templates.is_unsloth_gemma4_edge_gguf BUNDLED = load_bundled_chat_template("gemma-4.jinja") # 12b / 26B-A4B / 31B EDGE = load_bundled_chat_template("gemma-4-edge.jinja") # E2B / E4B # ── Stubs so core.inference.llama_cpp imports without the full studio venv ── def _stub_modules_ctx(): """patch.dict context that stubs the heavy deps llama_cpp pulls in at import, but only those NOT already importable (real httpx / structlog are kept when present, e.g. in CI), and removes the stubs on exit so other tests are not polluted.""" from unittest.mock import patch _loggers_stub = _types.ModuleType("loggers") _loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name) _structlog_stub = _types.ModuleType("structlog") _structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger("stub") _httpx_stub = _types.ModuleType("httpx") for _exc in ( "ConnectError", "TimeoutException", "ReadTimeout", "ReadError", "RemoteProtocolError", "CloseError", ): setattr(_httpx_stub, _exc, type(_exc, (Exception,), {})) _httpx_stub.Timeout = type("T", (), {"__init__": lambda s, *a, **k: None}) _httpx_stub.Client = type( "C", (), { "__init__": lambda s, **kw: None, "__enter__": lambda s: s, "__exit__": lambda s, *a: None, }, ) overrides = { name: stub for name, stub in ( ("loggers", _loggers_stub), ("structlog", _structlog_stub), ("httpx", _httpx_stub), ) if name not in sys.modules } return patch.dict(sys.modules, overrides) def _detect_reasoning_flags(): with _stub_modules_ctx(): from core.inference.llama_cpp import detect_reasoning_flags return detect_reasoning_flags # ── Family matcher ─────────────────────────────────────────────────── @pytest.mark.parametrize( "model_id,expected", [ ("unsloth/gemma-4-E2B-it-GGUF", True), ("unsloth/gemma-4-E4B-it-GGUF", True), ("unsloth/gemma-4-31B-it-GGUF", True), ("unsloth/gemma-4-26B-A4B-it-GGUF", True), ("UNSLOTH/GEMMA-4-E2B-IT-GGUF", True), # case-insensitive ("gemma-4-E2B-it-GGUF", True), # owner-less shorthand -> unsloth/ ("gemma-4-31B-it-GGUF", True), # owner-less shorthand -> unsloth/ ("unsloth/gemma-4-E2B-it", False), # bf16, not GGUF ("unsloth/gemma-3-4b-it-GGUF", False), # gemma 3 ("google/gemma-4-31B-it-GGUF", False), # not unsloth ("unsloth/Qwen3.5-9B-MTP-GGUF", False), ("/home/user/models/gemma-4-E2B.Q4_K_M.gguf", False), # local path ("", False), (None, False), ], ) def test_is_unsloth_gemma4_gguf(model_id, expected): assert is_unsloth_gemma4_gguf(model_id) is expected # ── Resolver precedence ────────────────────────────────────────────── @pytest.mark.parametrize( "model_id,expected_edge", [ ("unsloth/gemma-4-E2B-it-GGUF", True), ("unsloth/gemma-4-E4B-it-GGUF", True), ("UNSLOTH/GEMMA-4-E4B-IT-GGUF", True), ("unsloth/gemma-4-12b-it-GGUF", False), ("unsloth/gemma-4-26B-A4B-it-GGUF", False), ("unsloth/gemma-4-31B-it-GGUF", False), ("unsloth/gemma-3-4b-it-GGUF", False), ], ) def test_is_unsloth_gemma4_edge_gguf(model_id, expected_edge): assert is_unsloth_gemma4_edge_gguf(model_id) is expected_edge def test_resolver_returns_edge_template_for_e2b_e4b(): for mid in ("unsloth/gemma-4-E2B-it-GGUF", "unsloth/gemma-4-E4B-it-GGUF"): out = resolve_effective_chat_template_override(model_identifier = mid, user_override = None) assert out == EDGE assert out != BUNDLED def test_resolver_handles_owner_less_shorthand(): # ModelConfig.from_identifier prefixes unsloth/ for bare ids; the resolver # runs before that, so it must apply the same normalization. assert ( resolve_effective_chat_template_override( model_identifier = "gemma-4-E2B-it-GGUF", user_override = None ) == EDGE ) assert ( resolve_effective_chat_template_override( model_identifier = "gemma-4-31B-it-GGUF", user_override = None ) == BUNDLED ) def test_resolver_returns_standard_template_for_larger_models(): for mid in ( "unsloth/gemma-4-12b-it-GGUF", "unsloth/gemma-4-26B-A4B-it-GGUF", "unsloth/gemma-4-31B-it-GGUF", ): out = resolve_effective_chat_template_override(model_identifier = mid, user_override = None) assert out == BUNDLED def test_resolver_user_override_wins(): out = resolve_effective_chat_template_override( model_identifier = "unsloth/gemma-4-E2B-it-GGUF", user_override = "MY TEMPLATE" ) assert out == "MY TEMPLATE" def test_resolver_blank_override_falls_back_to_bundled(): out = resolve_effective_chat_template_override( model_identifier = "unsloth/gemma-4-31B-it-GGUF", user_override = " " ) assert out == BUNDLED def test_resolver_none_for_non_gemma(): assert ( resolve_effective_chat_template_override( model_identifier = "unsloth/Llama-3.2-1B-Instruct-GGUF", user_override = None ) is None ) # ── Bundled asset content + capability classification ──────────────── @pytest.mark.parametrize("tpl", [BUNDLED, EDGE]) def test_bundled_template_has_preserve_thinking_defaulted_off(tpl): assert "preserve_thinking" in tpl assert "preserve_thinking | default(false)" in tpl @pytest.mark.parametrize("name", ["gemma-4.jinja", "gemma-4-edge.jinja"]) def test_bundled_templates_are_ascii(name): # The temp file written for --chat-template-file must encode on any locale. # Keeping the bundled templates ASCII avoids UnicodeEncodeError on non-UTF-8 # Windows locales (cp932/cp1252) regardless of the writer's encoding. text = load_bundled_chat_template(name) non_ascii = sorted({c for c in text if ord(c) > 127}) assert not non_ascii, f"{name} has non-ASCII chars: {non_ascii}" @pytest.mark.parametrize("tpl", [BUNDLED, EDGE]) def test_detect_reasoning_flags_on_bundled_template(tpl): detect_reasoning_flags = _detect_reasoning_flags() flags = detect_reasoning_flags(tpl, "unsloth/gemma-4-E2B-it-GGUF") assert flags["supports_reasoning"] is True assert flags["reasoning_style"] == "enable_thinking" assert flags["reasoning_always_on"] is False # This is what makes the "Preserve thinking" toggle appear in the UI. assert flags["supports_preserve_thinking"] is True assert flags["supports_tools"] is True def test_edge_template_omits_empty_thought_block_on_thinking_off(): """E2B/E4B must NOT emit the empty <|channel>thought block when thinking is disabled; the larger-model template must. This is the only intended difference between the two bundled templates.""" EMPTY = "<|channel>thought\n" msgs = [{"role": "user", "content": "hi"}] edge_off = _render_with(EDGE, msgs, enable_thinking = False) std_off = _render_with(BUNDLED, msgs, enable_thinking = False) assert EMPTY not in edge_off, "edge (E2B/E4B) should not emit empty thought block" assert EMPTY in std_off, "standard (12b/26B/31B) should emit empty thought block" # With thinking ON neither appends the empty block at the prompt tail. assert EMPTY not in _render_with(EDGE, msgs, enable_thinking = True) # ── Jinja gate behaviour (off = omit prior reasoning, on = keep) ───── def _render_with(tpl, messages, **kw): pytest.importorskip("jinja2") # transitive via transformers; skip in minimal envs from jinja2 import Environment, BaseLoader def raise_exception(msg): raise RuntimeError(msg) env = Environment(loader = BaseLoader()) return env.from_string(tpl).render( messages = messages, bos_token = "", raise_exception = raise_exception, add_generation_prompt = True, **kw, ) def _render(messages, **kw): return _render_with(BUNDLED, messages, **kw) def _convo_with_prior_tool_reasoning(): # Assistant tool-call turn with reasoning, BEFORE the last user message. return [ {"role": "user", "content": "q1"}, { "role": "assistant", "reasoning_content": "SECRET_THOUGHT", "tool_calls": [{"id": "c1", "function": {"name": "f", "arguments": {"x": 1}}}], }, {"role": "tool", "tool_call_id": "c1", "content": "42"}, {"role": "user", "content": "q2"}, ] def test_preserve_thinking_off_omits_prior_reasoning(): # default(false): kwarg unset -> prior reasoning dropped before last user turn. assert "SECRET_THOUGHT" not in _render(_convo_with_prior_tool_reasoning()) def test_preserve_thinking_on_keeps_prior_reasoning(): assert "SECRET_THOUGHT" in _render(_convo_with_prior_tool_reasoning(), preserve_thinking = True) def test_enable_thinking_gates_think_token(): assert "<|think|>" in _render([{"role": "user", "content": "hi"}], enable_thinking = True) assert "<|think|>" not in _render([{"role": "user", "content": "hi"}], enable_thinking = False) # ── Reload dedup interaction (why the route resolves the effective override) ── def test_already_in_target_state_consistent_with_bundled_override(): """The backend dedup compares the incoming override against the live one. The route resolves the bundled template up front so a re-load that omits ``chat_template_override`` still matches (no spurious reload), while a raw ``None`` would not. """ LlamaCppBackend = _import_backend() class _FakeProcess: def terminate(self): ... def wait(self, timeout = None): return 0 def kill(self): ... def poll(self): return 0 backend = LlamaCppBackend() backend._process = _FakeProcess() backend._healthy = True backend._model_identifier = "unsloth/gemma-4-E2B-it-GGUF" backend._hf_variant = "Q4_K_M" backend._requested_n_ctx = 8192 backend._cache_type_kv = None backend._speculative_type = None backend._requested_spec_mode = "auto" backend._chat_template_override = BUNDLED # live server launched with the bundle backend._is_vision = False backend._extra_args = None backend._gguf_path = None common = dict( model_identifier = "unsloth/gemma-4-E2B-it-GGUF", hf_variant = "Q4_K_M", n_ctx = 8192, cache_type_kv = None, speculative_type = None, extra_args = None, is_vision = False, ) # Effective (resolved bundled) override -> already loaded, no reload. assert backend._already_in_target_state(chat_template_override = BUNDLED, **common) is True # Raw None (unresolved) -> false match, would force a needless reload. assert backend._already_in_target_state(chat_template_override = None, **common) is False def _import_backend(): with _stub_modules_ctx(): from core.inference.llama_cpp import LlamaCppBackend return LlamaCppBackend