diff --git a/README.md b/README.md index b445579..2f03ed2 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ next_prompt_ids = r.bridge_to_next_turn( ) ``` -Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `laguna-xs.2`, `laguna-xs-2.1`, `laguna-m.1`, `nemotron-3`, `nemotron-3-ultra`, `nemotron-3.5`, `llama-3`, `gpt-oss`, `hy3`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper. +Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `laguna-xs.2`, `laguna-xs-2.1`, `laguna-s-2.1`, `laguna-m.1`, `nemotron-3`, `nemotron-3-ultra`, `nemotron-3.5`, `llama-3`, `gpt-oss`, `hy3`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper. ## API diff --git a/docs/renderer-config.md b/docs/renderer-config.md index fa1ff47..e1a6156 100644 --- a/docs/renderer-config.md +++ b/docs/renderer-config.md @@ -39,6 +39,7 @@ chat-template kwargs. Those fields are covered by parity tests against | Laguna XS.2 | `LagunaXS2RendererConfig` | `enable_thinking`, `render_assistant_messages_raw` | - | | Laguna M.1 | `LagunaM1RendererConfig` | `enable_thinking`, `render_assistant_messages_raw` | - | | Laguna XS-2.1 | `LagunaXS21RendererConfig` | `enable_thinking` | - | +| Laguna S-2.1 | `LagunaS21RendererConfig` | `enable_thinking`, `preserve_thinking` | - | | Llama 3 | `Llama3RendererConfig` | `date_string`, `tools_in_user_message` | - | | MiniMax M2 | `MiniMaxM2RendererConfig` | `model_identity` | - | | Nemotron-3 Nano / Super | `Nemotron3RendererConfig` | `enable_thinking`, `truncate_history_thinking`, `low_effort` | - | @@ -138,7 +139,7 @@ the knobs its template actually exposes: | Nemotron-3 / 3.5 | `truncate_history_thinking=False -> all`; else `enable_thinking=False -> all`; else `tool_cycle` | | DeepSeek R1 | `template` | | MiniMax M2 | `tool_cycle` | -| DeepSeek V3, Qwen3-VL, Kimi K2, Laguna XS.2 / M.1 / XS-2.1, Llama 3 | `all` | +| DeepSeek V3, Qwen3-VL, Kimi K2, Laguna XS.2 / M.1 / XS-2.1 / S-2.1, Llama 3 | `all` | | PrimeIntellect Qwen3 | `all` | Config construction raises when an explicit template knob directly contradicts diff --git a/renderers/__init__.py b/renderers/__init__.py index e43eb59..4a01702 100644 --- a/renderers/__init__.py +++ b/renderers/__init__.py @@ -55,6 +55,7 @@ KimiK25RendererConfig, KimiK2RendererConfig, LagunaM1RendererConfig, + LagunaS21RendererConfig, LagunaXS2RendererConfig, LagunaXS21RendererConfig, Llama3RendererConfig, @@ -93,6 +94,7 @@ "KimiK25Renderer": "renderers.kimi_k25", "KimiK2Renderer": "renderers.kimi_k2", "LagunaM1Renderer": "renderers.laguna_xs2", + "LagunaS21Renderer": "renderers.laguna_s21", "LagunaXS21Renderer": "renderers.laguna_xs2", "LagunaXS2Renderer": "renderers.laguna_xs2", "Llama3Renderer": "renderers.llama_3", @@ -151,6 +153,8 @@ def __dir__() -> list[str]: "KimiK2RendererConfig", "LagunaM1Renderer", "LagunaM1RendererConfig", + "LagunaS21Renderer", + "LagunaS21RendererConfig", "LagunaXS2Renderer", "LagunaXS2RendererConfig", "LagunaXS21Renderer", diff --git a/renderers/base.py b/renderers/base.py index 9fc6651..7083e5f 100644 --- a/renderers/base.py +++ b/renderers/base.py @@ -1076,11 +1076,12 @@ def bridge_to_next_turn(self, *args: Any, **kwargs: Any) -> "RenderedTokens | No # construction to pin a different date. "meta-llama/Llama-3.2-1B-Instruct": "llama-3", "meta-llama/Llama-3.2-3B-Instruct": "llama-3", - # Poolside Laguna. These checkpoints ship distinct chat templates, - # each mirrored by its own renderer class/config discriminator. + # Poolside Laguna. These checkpoints ship distinct chat templates, each + # mirrored by its own renderer class/config discriminator. "poolside/Laguna-XS.2": "laguna-xs.2", "poolside/Laguna-M.1": "laguna-m.1", "poolside/Laguna-XS-2.1": "laguna-xs-2.1", + "poolside/Laguna-S-2.1": "laguna-s-2.1", # GPT-OSS. "openai/gpt-oss-20b": "gpt-oss", "openai/gpt-oss-120b": "gpt-oss", @@ -1328,6 +1329,7 @@ def _populate_registry(): from renderers.hy3 import Hy3Renderer from renderers.kimi_k2 import KimiK2Renderer from renderers.kimi_k25 import KimiK25Renderer + from renderers.laguna_s21 import LagunaS21Renderer from renderers.laguna_xs2 import ( LagunaM1Renderer, LagunaXS2Renderer, @@ -1366,6 +1368,7 @@ def _populate_registry(): "laguna-xs.2": LagunaXS2Renderer, "laguna-m.1": LagunaM1Renderer, "laguna-xs-2.1": LagunaXS21Renderer, + "laguna-s-2.1": LagunaS21Renderer, "llama-3": Llama3Renderer, "nemotron-3": Nemotron3Renderer, "nemotron-3-ultra": Nemotron3UltraRenderer, diff --git a/renderers/configs.py b/renderers/configs.py index 969e05f..b57f01f 100644 --- a/renderers/configs.py +++ b/renderers/configs.py @@ -546,6 +546,37 @@ class LagunaXS21RendererConfig(BaseRendererConfig): upstream default.""" +class LagunaS21RendererConfig(BaseRendererConfig): + """Laguna S-2.1 renderer config. + + S-2.1 is a larger sibling of XS-2.1 sharing its tokenizer (same vocab, + special tokens, and merges), but its chat template is *not* byte-identical: + ``enable_thinking`` defaults to ``True`` (XS-2.1 defaults ``False``), and a + new ``preserve_thinking`` kwarg widens the reasoning-display gate to + ``enable_thinking or preserve_thinking``. The token format is otherwise + identical, so this is served by + :class:`renderers.laguna_s21.LagunaS21Renderer`, a thin subclass of + ``LagunaXS21Renderer`` that only overrides that gate. + """ + + name: Literal["laguna-s-2.1"] = "laguna-s-2.1" + + enable_thinking: bool = True + """When ``True``, the generation prompt ends with ```` and every + assistant turn renders ``{reasoning}``; when ``False``, + turns open with a bare ````. Mirrors the template's + ``enable_thinking`` kwarg — note S-2.1's upstream default is ``True``, + unlike XS-2.1's ``False``.""" + + preserve_thinking: bool = False + """When ``True``, assistant turns keep their ``{reasoning}`` + block even while ``enable_thinking`` is ``False`` — the template gates + reasoning display on ``enable_thinking or preserve_thinking``. With the + default ``False`` the gate collapses to ``enable_thinking`` and the + renderer matches XS-2.1 turn-for-turn. Mirrors the template's + ``preserve_thinking`` kwarg and its upstream default.""" + + class Llama3RendererConfig(BaseRendererConfig): """Llama-3.x Instruct renderer config. @@ -735,6 +766,7 @@ class DeepSeekR1RendererConfig(BaseRendererConfig): LagunaXS2RendererConfig, LagunaM1RendererConfig, LagunaXS21RendererConfig, + LagunaS21RendererConfig, Llama3RendererConfig, MiniMaxM2RendererConfig, Nemotron3RendererConfig, @@ -777,6 +809,7 @@ class DeepSeekR1RendererConfig(BaseRendererConfig): "laguna-xs.2": LagunaXS2RendererConfig, "laguna-m.1": LagunaM1RendererConfig, "laguna-xs-2.1": LagunaXS21RendererConfig, + "laguna-s-2.1": LagunaS21RendererConfig, "llama-3": Llama3RendererConfig, "minimax-m2": MiniMaxM2RendererConfig, "nemotron-3": Nemotron3RendererConfig, @@ -825,6 +858,7 @@ def config_from_name(name: str) -> BaseRendererConfig | None: "KimiK25RendererConfig", "KimiK2RendererConfig", "LagunaM1RendererConfig", + "LagunaS21RendererConfig", "LagunaXS2RendererConfig", "LagunaXS21RendererConfig", "Llama3RendererConfig", diff --git a/renderers/laguna_s21.py b/renderers/laguna_s21.py new file mode 100644 index 0000000..d39e11a --- /dev/null +++ b/renderers/laguna_s21.py @@ -0,0 +1,33 @@ +"""Laguna S-2.1 Renderer — a larger sibling of Laguna XS-2.1. + +S-2.1 shares XS-2.1's tokenizer and token format, so this is a thin subclass of +:class:`renderers.laguna_xs2.LagunaXS21Renderer`; see that module for the shared +format. The template delta is two thinking kwargs: ``enable_thinking`` defaults +to ``True`` (XS-2.1 defaults ``False``), and ``preserve_thinking`` widens the +reasoning-display gate to ``enable_thinking or preserve_thinking``. +""" + +from __future__ import annotations + +from transformers.tokenization_utils import PreTrainedTokenizer + +from renderers.configs import LagunaS21RendererConfig +from renderers.laguna_xs2 import LagunaXS21Renderer + + +class LagunaS21Renderer(LagunaXS21Renderer): + """Mirrors the ``poolside/Laguna-S-2.1`` chat template.""" + + # Narrows the inherited attribute so the S-2.1-only ``preserve_thinking`` + # resolves; ``__init__`` always stores an S-2.1 config. + config: LagunaS21RendererConfig + + def __init__( + self, + tokenizer: PreTrainedTokenizer, + config: LagunaS21RendererConfig | None = None, + ): + super().__init__(tokenizer, config or LagunaS21RendererConfig()) + + def _render_history_reasoning(self) -> bool: + return self.config.enable_thinking or self.config.preserve_thinking diff --git a/renderers/laguna_xs2.py b/renderers/laguna_xs2.py index 8ef3469..303ada3 100644 --- a/renderers/laguna_xs2.py +++ b/renderers/laguna_xs2.py @@ -41,6 +41,9 @@ :class:`LagunaM1Renderer`. It shares XS.2's byte layout, tool syntax, and generation prompt, but has no fallback system prompt and reads assistant reasoning from ``reasoning`` before falling back to ``reasoning_content``. + +S-2.1 subclasses :class:`LagunaXS21Renderer` from :mod:`renderers.laguna_s21`, +overriding only the ``_render_history_reasoning`` seam. """ from __future__ import annotations @@ -63,6 +66,7 @@ ) from renderers.configs import ( LagunaM1RendererConfig, + LagunaS21RendererConfig, LagunaXS2RendererConfig, LagunaXS21RendererConfig, ) @@ -120,6 +124,7 @@ def __init__( LagunaXS2RendererConfig | LagunaM1RendererConfig | LagunaXS21RendererConfig + | LagunaS21RendererConfig | None ) = None, ): @@ -688,10 +693,17 @@ class LagunaXS21Renderer(LagunaXS2Renderer): def __init__( self, tokenizer: PreTrainedTokenizer, - config: LagunaXS21RendererConfig | None = None, + config: LagunaXS21RendererConfig | LagunaS21RendererConfig | None = None, ): super().__init__(tokenizer, config or LagunaXS21RendererConfig()) + def _render_history_reasoning(self) -> bool: + """Whether an assistant turn renders its ``{reasoning}`` + block (vs opening with a bare ````). Mirrors the template's + reasoning-display gate; XS-2.1 gates this on ``enable_thinking`` alone. + """ + return self.config.enable_thinking + def render( self, messages: list[Message], @@ -975,7 +987,7 @@ def _render_assistant( # scaffolding the model never samples. emit_special(self._assistant, msg_idx, is_sampled=False, is_content=False) - if self.config.enable_thinking: + if self._render_history_reasoning(): # ``{reasoning}`` renders verbatim, even when # the reasoning is empty; the opener is the gen-prompt # prefill, the rest the model sampled. diff --git a/tests/conftest.py b/tests/conftest.py index fb75241..587917b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,10 @@ # XS-2.1 resolves to `LagunaXS21Renderer` via the model name — its # chat template differs from XS.2's (see renderers/laguna_xs2.py). ("poolside/Laguna-XS-2.1", "auto"), + # S-2.1 is a larger sibling of XS-2.1 sharing its tokenizer but NOT its + # chat template (S-2.1 defaults enable_thinking=True and adds a + # preserve_thinking gate), so it resolves to LagunaS21Renderer. + ("poolside/Laguna-S-2.1", "auto"), # DeepSeek-V3/R1 are intentionally NOT in this shared barrage: their # chat templates can't render the barrage's tool-call fixtures (the # templates require ``tool['type']`` and a string-serialized diff --git a/tests/test_laguna_s21.py b/tests/test_laguna_s21.py new file mode 100644 index 0000000..8cfecbe --- /dev/null +++ b/tests/test_laguna_s21.py @@ -0,0 +1,128 @@ +"""Laguna-S-2.1 focused tests. + +S-2.1 shares XS-2.1's tokenizer and token format, so the shared matrices +(conftest / config-parity) already assert byte parity on the common shapes +via :class:`LagunaS21Renderer`. This file pins the two behaviours that make +S-2.1 *differ* from XS-2.1 — and that the shared barrage can't reach because +it never sets the relevant kwargs: + +- ``enable_thinking`` defaults to ``True`` (XS-2.1 defaults ``False``), so the + auto-resolved renderer renders reasoning by default, and +- the new ``preserve_thinking`` kwarg keeps historical ```` blocks even + while ``enable_thinking`` is ``False`` — the template gates reasoning display + on ``enable_thinking or preserve_thinking``. +""" + +from __future__ import annotations + +from functools import lru_cache +from itertools import product + +from renderers import create_renderer +from renderers.base import load_tokenizer +from renderers.configs import LagunaS21RendererConfig +from renderers.laguna_s21 import LagunaS21Renderer + +_MODEL = "poolside/Laguna-S-2.1" + + +@lru_cache(maxsize=None) +def _tok(): + return load_tokenizer(_MODEL) + + +def _renderer(**config_kwargs) -> LagunaS21Renderer: + renderer = create_renderer(_tok(), LagunaS21RendererConfig(**config_kwargs)) + assert isinstance(renderer, LagunaS21Renderer) + return renderer + + +def _expected(msgs, *, add_generation_prompt=False, **template_kwargs): + return list( + _tok().apply_chat_template( + msgs, + add_generation_prompt=add_generation_prompt, + tokenize=True, + return_dict=False, + **template_kwargs, + ) + ) + + +def test_auto_resolves_to_s21_renderer_with_thinking_on_by_default(): + """``poolside/Laguna-S-2.1`` resolves to :class:`LagunaS21Renderer`, and + its config defaults ``enable_thinking=True`` / ``preserve_thinking=False`` + — matching S-2.1's template defaults (unlike XS-2.1's thinking-off).""" + r = create_renderer(_tok()) # auto-resolve via MODEL_RENDERER_MAP + assert isinstance(r, LagunaS21Renderer) + assert r.config.name == "laguna-s-2.1" + assert r.config.enable_thinking is True + assert r.config.preserve_thinking is False + + +def test_default_renders_empty_think_wrapper(): + """With thinking on by default, a reasoning-free assistant turn still + opens with the empty ```` wrapper (this is exactly the + render_ids divergence from the XS-2.1 renderer).""" + msgs = [ + {"role": "user", "content": "What is 2+2?"}, + {"role": "assistant", "content": "4"}, + ] + r = _renderer() # defaults: enable_thinking=True + ours = r.render_ids(msgs) + assert ours == _expected(msgs) + assert "4" in _tok().decode(ours) + + +def test_preserve_thinking_keeps_history_when_thinking_off(): + """``enable_thinking=False`` alone drops reasoning, but with + ``preserve_thinking=True`` the historical ``{reasoning}`` + survives — matching the template's ``enable_thinking or preserve_thinking`` + gate.""" + msgs = [ + {"role": "user", "content": "What is 2+2?"}, + {"role": "assistant", "reasoning_content": "Simple arithmetic", "content": "4"}, + ] + r = _renderer(enable_thinking=False, preserve_thinking=True) + ours = r.render_ids(msgs) + assert ours == _expected(msgs, enable_thinking=False, preserve_thinking=True) + assert "Simple arithmetic" in _tok().decode(ours) + + +def test_no_preserve_thinking_drops_history_when_thinking_off(): + """With both flags off the gate collapses to ``enable_thinking`` and the + turn opens with a bare ````, dropping the reasoning — identical to + the XS-2.1 renderer's default.""" + msgs = [ + {"role": "user", "content": "What is 2+2?"}, + {"role": "assistant", "reasoning_content": "Simple arithmetic", "content": "4"}, + ] + r = _renderer(enable_thinking=False, preserve_thinking=False) + ours = r.render_ids(msgs) + assert ours == _expected(msgs, enable_thinking=False, preserve_thinking=False) + assert "Simple arithmetic" not in _tok().decode(ours) + + +def test_all_thinking_flag_combos_match_template(): + """Byte parity against ``apply_chat_template`` for every + ``enable_thinking`` × ``preserve_thinking`` combination, over a multi-turn + conversation carrying historical reasoning.""" + msgs = [ + {"role": "user", "content": "Q1"}, + {"role": "assistant", "reasoning_content": "deduce", "content": "A1"}, + {"role": "user", "content": "Q2"}, + {"role": "assistant", "reasoning_content": "more", "content": "A2"}, + ] + for enable_thinking, preserve_thinking in product((False, True), repeat=2): + r = _renderer( + enable_thinking=enable_thinking, preserve_thinking=preserve_thinking + ) + for add_generation_prompt in (False, True): + assert r.render_ids( + msgs, add_generation_prompt=add_generation_prompt + ) == _expected( + msgs, + add_generation_prompt=add_generation_prompt, + enable_thinking=enable_thinking, + preserve_thinking=preserve_thinking, + ), (enable_thinking, preserve_thinking, add_generation_prompt) diff --git a/tests/test_renderer_config_parity.py b/tests/test_renderer_config_parity.py index 49c3c70..55a780d 100644 --- a/tests/test_renderer_config_parity.py +++ b/tests/test_renderer_config_parity.py @@ -69,6 +69,7 @@ ("poolside/Laguna-XS.2", "auto"), ("poolside/Laguna-M.1", "auto"), ("poolside/Laguna-XS-2.1", "auto"), + ("poolside/Laguna-S-2.1", "auto"), ("tencent/Hy3", "auto"), ("openai/gpt-oss-20b", "gpt-oss"), ]