diff --git a/renderers/__init__.py b/renderers/__init__.py index dde5e51..a71e6aa 100644 --- a/renderers/__init__.py +++ b/renderers/__init__.py @@ -68,6 +68,7 @@ PrimeQwen3RendererConfig, Qwen35RendererConfig, Qwen36RendererConfig, + Qwen38RendererConfig, Qwen3RendererConfig, Qwen3VLRendererConfig, RendererConfig, @@ -109,6 +110,7 @@ "PrimeQwen3Renderer": "renderers.prime_qwen3", "Qwen35Renderer": "renderers.qwen35", "Qwen36Renderer": "renderers.qwen36", + "Qwen38Renderer": "renderers.qwen38", "Qwen3Renderer": "renderers.qwen3", "Qwen3VLRenderer": "renderers.qwen3_vl", } @@ -192,6 +194,8 @@ def __dir__() -> list[str]: "Qwen35RendererConfig", "Qwen36Renderer", "Qwen36RendererConfig", + "Qwen38Renderer", + "Qwen38RendererConfig", "Qwen3Renderer", "Qwen3RendererConfig", "Qwen3VLRenderer", diff --git a/renderers/base.py b/renderers/base.py index 074f016..5e9792a 100644 --- a/renderers/base.py +++ b/renderers/base.py @@ -1031,6 +1031,11 @@ def bridge_to_next_turn(self, *args: Any, **kwargs: Any) -> "RenderedTokens | No "Qwen/Qwen3.5-397B-A17B": "qwen3.5", # Qwen3.6. "Qwen/Qwen3.6-35B-A3B": "qwen3.6", + # Qwen3.8. Dense 27B, built on the Qwen3.5 architecture. Its template + # additionally injects reasoning-effort instructions into the system + # message and preserves thinking by default (hard-coded per model in + # ``Qwen38Renderer`` / ``_ENABLE_THINKING_DEFAULTS``). + "Qwen/Qwen3.8-27B": "qwen3.8", # Qwen3-VL. "Qwen/Qwen3-VL-4B-Instruct": "qwen3-vl", "Qwen/Qwen3-VL-8B-Instruct": "qwen3-vl", @@ -1136,6 +1141,9 @@ def bridge_to_next_turn(self, *args: Any, **kwargs: Any) -> "RenderedTokens | No # Qwen3.6 extends Qwen3.5's chat template; same VL bits, only # tool-call argument serialization differs. "Qwen/Qwen3.6-35B-A3B": {"image"}, + # Qwen3.8 (dense 27B) is a Qwen3.5-architecture VLM with the same vision + # tokens / processor; its renderer shares Qwen35Renderer's image path. + "Qwen/Qwen3.8-27B": {"image"}, # Kimi K2.5 / K2.6 are unified VLMs (HF tag ``image-text-to-text``) # with custom processor (``KimiK25Processor`` + ``KimiK25VisionProcessor``). # Vision wrap is different from Qwen-VL: @@ -1367,6 +1375,7 @@ def _populate_registry(): from renderers.qwen3_vl import Qwen3VLRenderer from renderers.qwen35 import Qwen35Renderer from renderers.qwen36 import Qwen36Renderer + from renderers.qwen38 import Qwen38Renderer RENDERER_REGISTRY.update( { @@ -1377,6 +1386,7 @@ def _populate_registry(): "gemma4": Gemma4Renderer, "qwen3.5": Qwen35Renderer, "qwen3.6": Qwen36Renderer, + "qwen3.8": Qwen38Renderer, "glm-5": GLM5Renderer, "glm-5.1": GLM51Renderer, "glm-4.5": GLM45Renderer, diff --git a/renderers/configs.py b/renderers/configs.py index f792cdc..ff6d8f2 100644 --- a/renderers/configs.py +++ b/renderers/configs.py @@ -271,6 +271,50 @@ def _check_thinking_retention(self): return self +class Qwen38RendererConfig(BaseRendererConfig): + """Qwen3.8 renderer config. Extends Qwen3.5's template surface with + reasoning-effort instructions and thinking-preservation defaults.""" + + name: Literal["qwen3.8"] = "qwen3.8" + _template_fields = frozenset( + {"enable_thinking", "add_vision_id", "preserve_thinking", "reasoning_effort"} + ) + + enable_thinking: bool | None = None + """See :class:`Qwen35RendererConfig.enable_thinking`.""" + + add_vision_id: bool = False + """See :class:`Qwen35RendererConfig.add_vision_id`.""" + + preserve_thinking: bool = True + """Keep historical `` thinking`` blocks on every assistant turn. + Mirrors the Qwen3.8 chat template's ``preserve_thinking`` kwarg, which is + *undefined* in the template and therefore defaults to ``True``. Set to + ``False`` to restrict thinking blocks to turns after the last real user + query (Qwen3.5 behavior).""" + + reasoning_effort: Literal["xhigh", "medium", "low"] | None = None + """Reasoning-effort hint injected into the leading system message when + thinking is enabled. ``None`` mirrors the template default (``xhigh``); + ``medium`` suppresses the instruction string entirely. Mirrors the + Qwen3.8 chat template's ``reasoning_effort`` kwarg.""" + + image_cache_max: int = 256 + """See :class:`Qwen35RendererConfig.image_cache_max`.""" + + _internal_fields = frozenset({"image_cache_max"}) + + @model_validator(mode="after") + def _check_thinking_retention(self): + _reject_thinking_retention_conflict( + self, + "preserve_thinking", + true_implies="all", + false_implies="tool_cycle", + ) + return self + + class Qwen3VLRendererConfig(BaseRendererConfig): """Qwen3-VL renderer config.""" @@ -937,6 +981,7 @@ class DeepSeekR1RendererConfig(BaseRendererConfig): "prime-qwen3": PrimeQwen3RendererConfig, "qwen3.5": Qwen35RendererConfig, "qwen3.6": Qwen36RendererConfig, + "qwen3.8": Qwen38RendererConfig, "qwen3-vl": Qwen3VLRendererConfig, "gemma4": Gemma4RendererConfig, "glm-5": GLM5RendererConfig, @@ -1013,6 +1058,7 @@ def config_from_name(name: str) -> BaseRendererConfig | None: "PrimeQwen3RendererConfig", "Qwen35RendererConfig", "Qwen36RendererConfig", + "Qwen38RendererConfig", "Qwen3RendererConfig", "Qwen3VLRendererConfig", "RendererConfig", diff --git a/renderers/qwen35.py b/renderers/qwen35.py index 52de886..483cd63 100644 --- a/renderers/qwen35.py +++ b/renderers/qwen35.py @@ -102,6 +102,8 @@ "Qwen/Qwen3.5-397B-A17B": True, # Qwen3.6 extends the Qwen3.5 template; same big-size polarity. "Qwen/Qwen3.6-35B-A3B": True, + # Qwen3.8 (dense 27B) — thinking on by default. + "Qwen/Qwen3.8-27B": True, } @@ -305,6 +307,64 @@ def _last_query_index(messages: list[Message]) -> int: return i return len(messages) + # ------------------------------------------------------------------ + # System message + optional tools + # ------------------------------------------------------------------ + + def _emit_system_and_tools( + self, + messages: list[Message], + tools: list[ToolSpec] | None, + *, + emit_special, + emit_text, + emit_text_segments, + ) -> None: + """Emit the leading system message and (optionally) the tools block. + + Qwen3.5 emits a system message only when the caller supplied one. + Subclasses (Qwen3.8) override this to inject reasoning-effort + instructions and to emit a standalone system message when the + template's reasoning instructions are non-empty. + """ + first_is_system = messages[0].get("role") == "system" + + if tools: + # System message index for attribution + sys_idx = 0 if first_is_system else -1 + + emit_special(self._im_start, sys_idx, is_sampled=False, is_content=False) + # Body = system content (if any). Everything else in this + # block — role tag, tools header / footer / instructions, the + # JSON tool specs — is scaffold. The tools dict is + # recoverable from the ``tools`` argument; don't re-attribute + # its embedded JSON as message body. + segments: list[tuple[str, bool]] = [ + ("system\n", False), + (_TOOLS_HEADER, False), + ] + for tool in tools: + segments.append(("\n" + json.dumps(tool, ensure_ascii=False), False)) + segments.append((_TOOLS_FOOTER, False)) + segments.append((_TOOLS_INSTRUCTIONS, False)) + if first_is_system: + sys_content = self._render_content(messages[0].get("content")).strip() + if sys_content: + segments.append(("\n\n", False)) + segments.append((sys_content, True)) + emit_text_segments(segments, sys_idx, is_sampled=False) + emit_special(self._im_end, sys_idx, is_sampled=False, is_content=False) + emit_text("\n", sys_idx, is_sampled=False, is_content=False) + elif first_is_system: + sys_content = self._render_content(messages[0].get("content")).strip() + emit_special(self._im_start, 0, is_sampled=False, is_content=False) + sys_segments: list[tuple[str, bool]] = [("system\n", False)] + if sys_content: + sys_segments.append((sys_content, True)) + emit_text_segments(sys_segments, 0, is_sampled=False) + emit_special(self._im_end, 0, is_sampled=False, is_content=False) + emit_text("\n", 0, is_sampled=False, is_content=False) + # ------------------------------------------------------------------ # Core render method # ------------------------------------------------------------------ @@ -465,43 +525,13 @@ def flush_buf() -> None: emit_text("\n", msg_idx, is_sampled=False, is_content=False) # ── 1. System message + optional tools ────────────────────── - first_is_system = messages[0].get("role") == "system" - - if tools: - # System message index for attribution - sys_idx = 0 if first_is_system else -1 - - emit_special(self._im_start, sys_idx, is_sampled=False, is_content=False) - # Body = system content (if any). Everything else in this - # block — role tag, tools header / footer / instructions, the - # JSON tool specs — is scaffold. The tools dict is - # recoverable from the ``tools`` argument; don't re-attribute - # its embedded JSON as message body. - segments: list[tuple[str, bool]] = [ - ("system\n", False), - (_TOOLS_HEADER, False), - ] - for tool in tools: - segments.append(("\n" + json.dumps(tool, ensure_ascii=False), False)) - segments.append((_TOOLS_FOOTER, False)) - segments.append((_TOOLS_INSTRUCTIONS, False)) - if first_is_system: - sys_content = self._render_content(messages[0].get("content")).strip() - if sys_content: - segments.append(("\n\n", False)) - segments.append((sys_content, True)) - emit_text_segments(segments, sys_idx, is_sampled=False) - emit_special(self._im_end, sys_idx, is_sampled=False, is_content=False) - emit_text("\n", sys_idx, is_sampled=False, is_content=False) - elif first_is_system: - sys_content = self._render_content(messages[0].get("content")).strip() - emit_special(self._im_start, 0, is_sampled=False, is_content=False) - sys_segments: list[tuple[str, bool]] = [("system\n", False)] - if sys_content: - sys_segments.append((sys_content, True)) - emit_text_segments(sys_segments, 0, is_sampled=False) - emit_special(self._im_end, 0, is_sampled=False, is_content=False) - emit_text("\n", 0, is_sampled=False, is_content=False) + self._emit_system_and_tools( + messages, + tools, + emit_special=emit_special, + emit_text=emit_text, + emit_text_segments=emit_text_segments, + ) # ── 2. Compute last_query_index ───────────────────────────── last_qi = self._last_query_index(messages) @@ -920,6 +950,30 @@ def _render_arg_value(arg_value: Any) -> str: return json.dumps(arg_value, ensure_ascii=False) return str(arg_value) + def _extract_reasoning(self, msg: Message, content: str) -> tuple[str, str]: + """Return ``(reasoning_content, content)`` for an assistant message. + + Qwen3.5 falls back to splitting `` response`` out of ``content`` when + the message has no explicit ``reasoning_content`` field. Qwen3.8 + dropped that fallback (it relies solely on ``reasoning_content``), so + subclasses override this to skip the content split. + """ + reasoning_content = "" + if isinstance(msg.get("reasoning_content"), str): + reasoning_content = msg["reasoning_content"] + elif " response" in content: + # Split on response to separate reasoning from content + before_think_end, after_think_end = content.split(" response", 1) + # Extract text after thinking (if present) + if " thinking" in before_think_end: + reasoning_content = before_think_end.split(" thinking")[-1].lstrip("\n") + else: + reasoning_content = before_think_end.lstrip("\n") + reasoning_content = reasoning_content.rstrip("\n") + content = after_think_end.lstrip("\n") + + return reasoning_content.strip(), content + def _render_assistant( self, msg: Message, @@ -933,21 +987,7 @@ def _render_assistant( emit_text_segments, ) -> None: # Extract reasoning_content - reasoning_content = "" - if isinstance(msg.get("reasoning_content"), str): - reasoning_content = msg["reasoning_content"] - elif "" in content: - # Split on to separate reasoning from content - before_think_end, after_think_end = content.split("", 1) - # Extract text after (if present) - if "" in before_think_end: - reasoning_content = before_think_end.split("")[-1].lstrip("\n") - else: - reasoning_content = before_think_end.lstrip("\n") - reasoning_content = reasoning_content.rstrip("\n") - content = after_think_end.lstrip("\n") - - reasoning_content = reasoning_content.strip() + reasoning_content, content = self._extract_reasoning(msg, content) # ``<|im_start|>assistant\n`` is template-injected scaffolding — # at inference the chat template emits these as the generation diff --git a/renderers/qwen38.py b/renderers/qwen38.py new file mode 100644 index 0000000..b02f5ad --- /dev/null +++ b/renderers/qwen38.py @@ -0,0 +1,153 @@ +"""Qwen3.8 Renderer — mirrors the Qwen3.8 Jinja chat template. + +Qwen3.8 extends the Qwen3.5 template with three deltas (see the unified +diff against ``Qwen/Qwen3.5-9B``'s ``chat_template.jinja``): + +- **Reasoning-effort instructions.** When ``enable_thinking`` is truthy, the + template resolves ``reasoning_effort`` (default ``xhigh``) into a + ``reasoning_instructions`` string that is injected into the leading system + message — both the tools path (right after ``system\n``, before ``# Tools``) + and the no-tools path (prefixed to caller system content, or as a standalone + system message when the caller supplied none). ``medium`` produces no + instructions; ``xhigh`` / ``low`` produce the pinned strings below. +- **Thinking preservation defaults on.** ``preserve_thinking`` is undefined + in the template and treated as ``True``, so historical `` thinking`` blocks + are kept on every assistant turn (not just after the last real user query). + ``Qwen38RendererConfig.preserve_thinking`` therefore defaults to ``True``. +- **No `` response`` fallback.** The template no longer derives reasoning from + ``content`` when ``reasoning_content`` is absent; it relies solely on the + ``reasoning_content`` field. + +Tool-call argument serialization (``str`` verbatim, everything else compact +JSON) and the XML tool-call structure are identical to Qwen3.6, so +``_render_arg_value`` matches ``Qwen36Renderer``. +""" + +from __future__ import annotations + +import json +from typing import Any + +from renderers.configs import Qwen38RendererConfig +from renderers.qwen35 import ( + Qwen35Renderer, + _TOOLS_FOOTER, + _TOOLS_HEADER, + _TOOLS_INSTRUCTIONS, +) + +# Pinned reasoning-effort instruction strings (must match the Jinja template +# exactly). ``medium`` yields no instructions. +_REASONING_INSTRUCTIONS: dict[str, str] = { + "xhigh": ( + "Reasoning effort is set to xhigh. Please think carefully through the " + "task, validate key assumptions, consider plausible alternatives, and " + "prioritize correctness, consistency, and clarity in the final answer." + ), + "low": ( + "Reasoning effort is set to low. Keep your thinking brief and focused, " + "moving directly to the conclusion without unnecessary elaboration." + ), +} + + +class Qwen38Renderer(Qwen35Renderer): + """Deterministic message → token renderer for Qwen3.8 models.""" + + _config_cls = Qwen38RendererConfig + + def __init__(self, tokenizer, config=None, *, processor=None): + super().__init__(tokenizer, config, processor=processor) + # ``enable_thinking`` is resolved to a concrete bool by the parent + # ``__init__``; compute the template's reasoning-instruction string once. + self.reasoning_instructions = self._reasoning_instructions() + + def _reasoning_instructions(self) -> str: + """The template's ``reasoning_instructions`` for the resolved config. + + Empty when thinking is disabled or ``reasoning_effort`` is ``medium``. + ``None`` on the config means the template default (``xhigh``). + """ + if not self.config.enable_thinking: + return "" + effort = self.config.reasoning_effort or "xhigh" + return _REASONING_INSTRUCTIONS.get(effort, "") + + @staticmethod + def _render_arg_value(arg_value: Any) -> str: + # Qwen3.8: ``args_value | string if args_value is string else + # args_value | tojson | safe`` — same effective behavior as Qwen3.6. + if isinstance(arg_value, str): + return arg_value + return json.dumps(arg_value, ensure_ascii=False) + + def _extract_reasoning(self, msg, content): + # Qwen3.8 dropped the `` response``-in-content fallback; reasoning + # comes solely from the ``reasoning_content`` field. + reasoning_content = "" + if isinstance(msg.get("reasoning_content"), str): + reasoning_content = msg["reasoning_content"] + return reasoning_content.strip(), content + + def _emit_system_and_tools( + self, + messages, + tools, + *, + emit_special, + emit_text, + emit_text_segments, + ) -> None: + first_is_system = messages[0].get("role") == "system" + ri = self.reasoning_instructions + + if tools: + # System message index for attribution + sys_idx = 0 if first_is_system else -1 + + emit_special(self._im_start, sys_idx, is_sampled=False, is_content=False) + # Reasoning instructions + tools header / footer / instructions and + # the JSON tool specs are template-injected scaffold; only caller + # system content is body (``is_content=True``). + segments: list[tuple[str, bool]] = [("system\n", False)] + if ri: + segments.append((ri + "\n\n", False)) + segments.append((_TOOLS_HEADER, False)) + for tool in tools: + segments.append(("\n" + json.dumps(tool, ensure_ascii=False), False)) + segments.append((_TOOLS_FOOTER, False)) + segments.append((_TOOLS_INSTRUCTIONS, False)) + if first_is_system: + sys_content = self._render_content(messages[0].get("content")).strip() + if sys_content: + segments.append(("\n\n", False)) + segments.append((sys_content, True)) + emit_text_segments(segments, sys_idx, is_sampled=False) + emit_special(self._im_end, sys_idx, is_sampled=False, is_content=False) + emit_text("\n", sys_idx, is_sampled=False, is_content=False) + elif first_is_system: + sys_content = self._render_content(messages[0].get("content")).strip() + if sys_content or ri: + emit_special(self._im_start, 0, is_sampled=False, is_content=False) + segments = [("system\n", False)] + if ri: + segments.append((ri + "\n\n", False)) + if sys_content: + segments.append((sys_content, True)) + emit_text_segments(segments, 0, is_sampled=False) + emit_special(self._im_end, 0, is_sampled=False, is_content=False) + emit_text("\n", 0, is_sampled=False, is_content=False) + elif ri: + # Template emits a standalone system message carrying only the + # reasoning instructions when the caller supplied no system message. + emit_special(self._im_start, -1, is_sampled=False, is_content=False) + emit_text_segments( + [("system\n", False), (ri, False)], + -1, + is_sampled=False, + ) + emit_special(self._im_end, -1, is_sampled=False, is_content=False) + emit_text("\n", -1, is_sampled=False, is_content=False) + + +__all__ = ["Qwen38Renderer"] diff --git a/tests/conftest.py b/tests/conftest.py index b8c6b6a..23ae8f4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,7 @@ ("PrimeIntellect/Qwen3-0.6B", "auto"), ("Qwen/Qwen3.5-9B", "auto"), ("Qwen/Qwen3.6-35B-A3B", "auto"), + ("Qwen/Qwen3.8-27B", "auto"), ("Qwen/Qwen3-VL-4B-Instruct", "auto"), ("google/gemma-4-31B-it", "auto"), ("zai-org/GLM-5", "auto"), diff --git a/tests/test_qwen38.py b/tests/test_qwen38.py new file mode 100644 index 0000000..3ce3c84 --- /dev/null +++ b/tests/test_qwen38.py @@ -0,0 +1,144 @@ +"""Qwen3.8 renderer coverage. + +Locks in (a) the Qwen3.8 entries in ``MODEL_RENDERER_MAP`` and +``MULTIMODAL_MODELS``, (b) the thinking-polarity default, and (c) byte +parity of ``Qwen38Renderer`` against the model's own +``apply_chat_template`` across the template's new knobs — +``reasoning_effort`` (xhigh / medium / low) and ``preserve_thinking`` — +on top of the Qwen3.5 surface the shared barrage already exercises. +""" + +from __future__ import annotations + +import pytest + +from renderers import Qwen38Renderer, Qwen38RendererConfig, create_renderer +from renderers.base import MODEL_RENDERER_MAP, MULTIMODAL_MODELS, load_tokenizer + +QWEN38 = "Qwen/Qwen3.8-27B" + +HISTORY = [ + {"role": "user", "content": "q1"}, + {"role": "assistant", "reasoning_content": "r1", "content": "a1"}, + {"role": "user", "content": "q2"}, +] + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, + } +] + + +def test_map_routes_qwen38_to_qwen38_renderer(): + assert MODEL_RENDERER_MAP.get(QWEN38) == "qwen3.8" + + +def test_qwen38_registered_as_multimodal(): + assert QWEN38 in MULTIMODAL_MODELS + + +def test_qwen38_thinking_default_on(): + """Qwen3.8 ships thinking on by default (open `` thinking\n`` at the + gen-prompt boundary).""" + tok = load_tokenizer(QWEN38) + renderer = create_renderer(tok, Qwen38RendererConfig()) + assert isinstance(renderer, Qwen38Renderer) + assert renderer.config.enable_thinking is True + + +def test_config_defaults_match_template(): + cfg = Qwen38RendererConfig() + assert cfg.preserve_thinking is True + assert cfg.reasoning_effort is None # template default xhigh + + +# --------------------------------------------------------------------------- +# Byte parity against apply_chat_template +# --------------------------------------------------------------------------- + + +def _render_and_compare(tok, kwargs, messages, tools=None, add_generation_prompt=True): + config = Qwen38RendererConfig(**kwargs) + renderer = create_renderer(tok, config) + rendered = renderer.render_ids( + messages, tools=tools, add_generation_prompt=add_generation_prompt + ) + template_kwargs = {k: v for k, v in kwargs.items() if v is not None} + reference = tok.apply_chat_template( + messages, + tools=tools, + add_generation_prompt=add_generation_prompt, + tokenize=True, + **template_kwargs, + )["input_ids"] + assert rendered == reference + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"reasoning_effort": "low"}, + {"reasoning_effort": "medium"}, + {"reasoning_effort": "xhigh"}, + {"preserve_thinking": False}, + {"enable_thinking": False}, + {"enable_thinking": False, "reasoning_effort": "low"}, + {"enable_thinking": False, "preserve_thinking": False}, + ], +) +def test_render_parity_knobs(kwargs): + tok = load_tokenizer(QWEN38) + _render_and_compare(tok, kwargs, HISTORY) + + +def test_render_parity_tools_cycle(): + tok = load_tokenizer(QWEN38) + messages = [ + {"role": "user", "content": "weather?"}, + { + "role": "assistant", + "reasoning_content": "let me check", + "content": "", + "tool_calls": [ + { + "function": { + "name": "get_weather", + "arguments": {"city": "Paris"}, + } + } + ], + }, + {"role": "tool", "name": "get_weather", "content": "Sunny, 22C"}, + { + "role": "assistant", + "reasoning_content": "done", + "content": "Sunny in Paris.", + }, + ] + _render_and_compare(tok, {}, messages, tools=TOOLS) + + +def test_render_parity_system_message(): + tok = load_tokenizer(QWEN38) + messages = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "hi"}, + ] + for kwargs in ( + {}, + {"reasoning_effort": "low"}, + {"reasoning_effort": "medium"}, + {"reasoning_effort": "xhigh"}, + ): + _render_and_compare(tok, kwargs, messages) diff --git a/tests/test_renderer_config_parity.py b/tests/test_renderer_config_parity.py index 5568028..c0dfd93 100644 --- a/tests/test_renderer_config_parity.py +++ b/tests/test_renderer_config_parity.py @@ -47,6 +47,7 @@ ("Qwen/Qwen3-8B", "auto"), ("Qwen/Qwen3.5-9B", "auto"), ("Qwen/Qwen3.6-35B-A3B", "auto"), + ("Qwen/Qwen3.8-27B", "auto"), ("google/gemma-4-31B-it", "auto"), ("zai-org/GLM-5", "auto"), ("zai-org/GLM-5.1", "auto"), @@ -93,7 +94,7 @@ # gpt-oss accepts low/medium/high; Hy3 accepts no_think/low/high. The # union is listed here and the matrix builder drops values a given # renderer's typed config rejects (see ``_value_valid_for``). - "reasoning_effort": ["no_think", "low", "medium", "high"], + "reasoning_effort": ["no_think", "low", "medium", "high", "xhigh"], # Hy3 — keep {reasoning} on historical assistant turns # (True) vs collapse past-cycle reasoning to (False). "preserved_thinking": [True, False], @@ -442,7 +443,7 @@ def test_chat_template_kwarg_parity_hf( ("multi_turn", "tool_cycle") if resolved == "qwen3" else ("multi_turn",) ) qwen_deviation = ( - resolved in ("qwen3", "qwen3.5", "qwen3.6") + resolved in ("qwen3", "qwen3.5", "qwen3.6", "qwen3.8") and kwarg == "enable_thinking" and value is False and shape_id in qwen_deviating_shapes