Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions renderers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
Hy3RendererConfig,
KimiK25RendererConfig,
KimiK2RendererConfig,
LagunaS21RendererConfig,
LagunaXS2RendererConfig,
LagunaXS21RendererConfig,
Llama3RendererConfig,
Expand Down Expand Up @@ -90,6 +91,7 @@
"Hy3Renderer": "renderers.hy3",
"KimiK25Renderer": "renderers.kimi_k25",
"KimiK2Renderer": "renderers.kimi_k2",
"LagunaS21Renderer": "renderers.laguna_xs2",
"LagunaXS21Renderer": "renderers.laguna_xs2",
"LagunaXS2Renderer": "renderers.laguna_xs2",
"Llama3Renderer": "renderers.llama_3",
Expand Down Expand Up @@ -145,6 +147,8 @@ def __dir__() -> list[str]:
"KimiK25RendererConfig",
"KimiK2Renderer",
"KimiK2RendererConfig",
"LagunaS21Renderer",
"LagunaS21RendererConfig",
"LagunaXS2Renderer",
"LagunaXS2RendererConfig",
"LagunaXS21Renderer",
Expand Down
16 changes: 13 additions & 3 deletions renderers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1064,10 +1064,15 @@ 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. The two checkpoints ship different chat templates,
# each mirrored by its own renderer class.
# Poolside Laguna. XS.2 and XS-2.1 ship different chat templates, each
# mirrored by its own renderer class. S-2.1 is a larger sibling of XS-2.1
# that shares its tokenizer but NOT its chat template: S-2.1 defaults
# ``enable_thinking`` to True and adds a ``preserve_thinking`` gate, so it
# gets its own renderer/config (``LagunaS21Renderer``), a thin subclass of
# the XS-2.1 renderer.
"poolside/Laguna-XS.2": "laguna-xs.2",
"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",
Expand Down Expand Up @@ -1315,7 +1320,11 @@ def _populate_registry():
from renderers.hy3 import Hy3Renderer
from renderers.kimi_k2 import KimiK2Renderer
from renderers.kimi_k25 import KimiK25Renderer
from renderers.laguna_xs2 import LagunaXS2Renderer, LagunaXS21Renderer
from renderers.laguna_xs2 import (
LagunaS21Renderer,
LagunaXS2Renderer,
LagunaXS21Renderer,
)
from renderers.llama_3 import Llama3Renderer
from renderers.minimax_m2 import MiniMaxM2Renderer
from renderers.nemotron3 import Nemotron3Renderer, Nemotron3UltraRenderer
Expand Down Expand Up @@ -1344,6 +1353,7 @@ def _populate_registry():
"kimi-k2.5": KimiK25Renderer,
"laguna-xs.2": LagunaXS2Renderer,
"laguna-xs-2.1": LagunaXS21Renderer,
"laguna-s-2.1": LagunaS21Renderer,
"llama-3": Llama3Renderer,
"nemotron-3": Nemotron3Renderer,
"nemotron-3-ultra": Nemotron3UltraRenderer,
Expand Down
34 changes: 34 additions & 0 deletions renderers/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,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_xs2.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 ``<think>`` and every
assistant turn renders ``<think>{reasoning}</think>``; when ``False``,
turns open with a bare ``</think>``. 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 ``<think>{reasoning}</think>``
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.

Expand Down Expand Up @@ -681,6 +712,7 @@ class DeepSeekR1RendererConfig(BaseRendererConfig):
KimiK25RendererConfig,
LagunaXS2RendererConfig,
LagunaXS21RendererConfig,
LagunaS21RendererConfig,
Llama3RendererConfig,
MiniMaxM2RendererConfig,
Nemotron3RendererConfig,
Expand Down Expand Up @@ -721,6 +753,7 @@ class DeepSeekR1RendererConfig(BaseRendererConfig):
"kimi-k2.5": KimiK25RendererConfig,
"laguna-xs.2": LagunaXS2RendererConfig,
"laguna-xs-2.1": LagunaXS21RendererConfig,
"laguna-s-2.1": LagunaS21RendererConfig,
"llama-3": Llama3RendererConfig,
"minimax-m2": MiniMaxM2RendererConfig,
"nemotron-3": Nemotron3RendererConfig,
Expand Down Expand Up @@ -767,6 +800,7 @@ def config_from_name(name: str) -> BaseRendererConfig | None:
"Hy3RendererConfig",
"KimiK25RendererConfig",
"KimiK2RendererConfig",
"LagunaS21RendererConfig",
"LagunaXS2RendererConfig",
"LagunaXS21RendererConfig",
"Llama3RendererConfig",
Expand Down
62 changes: 58 additions & 4 deletions renderers/laguna_xs2.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@
section ends at ``</available_tools>``.
- The ``<system>`` block is emitted whenever there is system content,
tools, or ``enable_thinking`` — even if that leaves it empty.

S-2.1 is a larger sibling of XS-2.1 served by :class:`LagunaS21Renderer`. It
shares XS-2.1's tokenizer and token format, so the two differ only in the chat
template's thinking knobs: ``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
subclass overrides only that gate; everything else is inherited.
"""

from __future__ import annotations
Expand All @@ -56,7 +63,11 @@
resolve_thinking_retention,
should_rerender_for_thinking_retention,
)
from renderers.configs import LagunaXS2RendererConfig, LagunaXS21RendererConfig
from renderers.configs import (
LagunaS21RendererConfig,
LagunaXS2RendererConfig,
LagunaXS21RendererConfig,
)
from renderers.parsing import parse_laguna_xs2

_DEFAULT_SYSTEM_MESSAGE = (
Expand Down Expand Up @@ -107,7 +118,10 @@ class LagunaXS2Renderer:
def __init__(
self,
tokenizer: PreTrainedTokenizer,
config: LagunaXS2RendererConfig | LagunaXS21RendererConfig | None = None,
config: LagunaXS2RendererConfig
| LagunaXS21RendererConfig
| LagunaS21RendererConfig
| None = None,
):
self._tokenizer = tokenizer
self.config = config or LagunaXS2RendererConfig()
Expand Down Expand Up @@ -633,10 +647,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 ``<think>{reasoning}</think>``
block (vs opening with a bare ``</think>``). 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],
Expand Down Expand Up @@ -920,7 +941,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():
# ``<think>{reasoning}</think>`` renders verbatim, even when
# the reasoning is empty; the opener is the gen-prompt
# prefill, the rest the model sampled.
Expand Down Expand Up @@ -962,3 +983,36 @@ def _render_assistant(

emit_special(self._assistant_end, msg_idx, is_sampled=True, is_content=True)
emit_text("\n", msg_idx, is_sampled=False, is_content=False)


class LagunaS21Renderer(LagunaXS21Renderer):
"""Laguna-S-2.1 — a larger sibling of XS-2.1 that shares its tokenizer
and token format but ships a slightly different chat template.

The only template differences are the ``enable_thinking`` default (``True``
here, ``False`` for XS-2.1) and a new ``preserve_thinking`` kwarg that
widens the reasoning-display gate to ``enable_thinking or
preserve_thinking``. Everything else — role tags, tool-call packing,
system-block gate, generation prompt, and the whole parse skeleton — is
inherited unchanged from :class:`LagunaXS21Renderer`.
"""

# Narrow the inherited ``config`` attribute so ``preserve_thinking`` (an
# S-2.1-only field) resolves; ``__init__`` always stores an S-2.1 config.
config: LagunaS21RendererConfig

def __init__(
self,
tokenizer: PreTrainedTokenizer,
config: LagunaS21RendererConfig | None = None,
):
# ``LagunaXS21Renderer.__init__`` forwards any config straight through;
# pass an S-2.1 config so ``enable_thinking`` defaults to ``True`` and
# ``preserve_thinking`` is available.
super().__init__(tokenizer, config or LagunaS21RendererConfig())

def _render_history_reasoning(self) -> bool:
# S-2.1's template gates reasoning display on
# ``enable_thinking or preserve_thinking`` — the latter keeps
# historical ``<think>`` blocks even while thinking is off.
return self.config.enable_thinking or self.config.preserve_thinking
4 changes: 4 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,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
Expand Down
128 changes: 128 additions & 0 deletions tests/test_laguna_s21.py
Original file line number Diff line number Diff line change
@@ -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 ``<think>`` 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_xs2 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 ``<think></think>`` 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 "<assistant><think></think>4</assistant>" 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 ``<think>{reasoning}</think>``
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 "<think>Simple arithmetic</think>" 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 ``</think>``, 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)
1 change: 1 addition & 0 deletions tests/test_renderer_config_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
("nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16", "auto"),
("poolside/Laguna-XS.2", "auto"),
("poolside/Laguna-XS-2.1", "auto"),
("poolside/Laguna-S-2.1", "auto"),
("tencent/Hy3", "auto"),
("openai/gpt-oss-20b", "gpt-oss"),
]
Expand Down
Loading