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"),
]