* Studio: override chat template for unsloth/gemma-4-*-GGUF with bundled gemma-4.jinja The chat templates baked into the shipped unsloth/gemma-4-*-GGUF quants predate Google's gemma-4 chat-template PR #118 and lack the preserve_thinking flag, so Studio cannot surface the "Preserve thinking" toggle for Gemma 4. Bundle the updated template and override the embedded one at llama-server launch via --chat-template-file, scoped to the gemma-4 GGUF family, so users do not need to re-download any quant. - Add studio/backend/assets/chat_templates/gemma-4.jinja (PR #118 based; preserve_thinking defaults false, the one deliberate divergence from upstream). - Add core/inference/chat_templates.py: gemma-4 GGUF matcher plus an effective-override resolver (explicit user template still wins). - Wire the resolver into routes/inference.py ahead of the reload-dedup check and both load_model calls so the live backend and the incoming request compare against the same template text (no spurious reloads). - Default preserve_thinking off in the launch-time chat_template_kwargs so direct API callers match the UI default. - Ship the asset via package-data and add unit tests. * Studio: ship E2B/E4B edge variant of the bundled Gemma 4 template Google ships two distinct gemma-4 chat templates: E2B and E4B omit the empty "<|channel>thought<channel|>" block on enable_thinking=false, while the 12b/26B-A4B/31B family emits it (confirmed against google/gemma-4-E2B-it, -E4B-it, -12b-it, -26B-A4B-it, -31B-it; the two families differ only in that one block). The single PR #118 based template followed the larger-model behavior, which is wrong for the E2B/E4B GGUFs this feature most targets. - Add studio/backend/assets/chat_templates/gemma-4-edge.jinja: identical to gemma-4.jinja minus the empty-thought-block, matching E2B/E4B behavior. - Route unsloth/gemma-4-E2B-it-GGUF and -E4B-it-GGUF to the edge template; 12b/26B-A4B/31B keep gemma-4.jinja. - Extend tests for the edge matcher, per-family routing, and the empty-thought block difference (off for edge, on for standard). * Studio: address review feedback on the gemma-4 template override - Normalize owner-less shorthand model ids in the template matcher: a bare "gemma-4-E2B-it-GGUF" is canonicalized to "unsloth/" the same way ModelConfig.from_identifier does, so shorthand loads still get the override (and the preserve_thinking capability) instead of falling back to the embedded template. - Scope the test's module stubs with unittest.mock.patch.dict instead of sys.modules.setdefault, and only stub deps that are missing, so the global module registry is not polluted for tests that run afterwards. - Guard the Jinja render tests with pytest.importorskip("jinja2") so the suite stays runnable in minimal Studio environments where jinja2 is not present. - Add tests for shorthand resolution. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: address 10-reviewer P1 findings on the gemma-4 template override - /status no longer surfaces Studio's auto-applied bundled template as a user-authored chat_template_override. The frontend adopts that field as editable state and would otherwise re-send the gemma-4 template as an explicit override for a later, unrelated model. /status now reports None when the live override equals the model's auto-resolved bundled template. - When a bundled family template is in effect, strip an inherited --chat-template-file from llama_extra_args too (not only when the raw request set chat_template_override). Otherwise a stale inherited template, appended last, shadows the bundled one while Studio reports the bundled template's capabilities. - Write the temp chat-template file as UTF-8 explicitly, and keep the bundled templates ASCII (replaced em dashes), so non-UTF-8 Windows locales cannot raise UnicodeEncodeError or emit a mis-encoded template. Added an ASCII guard test. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
345 lines
13 KiB
Python
345 lines
13 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
|
|
|
|
"""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<channel|> 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<channel|>"
|
|
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 = "<bos>",
|
|
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
|