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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `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`, `llama-3`, `gpt-oss`, `hy3`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper.

## API

Expand Down
3 changes: 2 additions & 1 deletion docs/renderer-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | - |
Expand Down Expand Up @@ -137,7 +138,7 @@ the knobs its template actually exposes:
| Nemotron-3 | `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
Expand Down
4 changes: 4 additions & 0 deletions renderers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
KimiK25RendererConfig,
KimiK2RendererConfig,
LagunaM1RendererConfig,
LagunaS21RendererConfig,
LagunaXS2RendererConfig,
LagunaXS21RendererConfig,
Llama3RendererConfig,
Expand Down Expand Up @@ -92,6 +93,7 @@
"KimiK25Renderer": "renderers.kimi_k25",
"KimiK2Renderer": "renderers.kimi_k2",
"LagunaM1Renderer": "renderers.laguna_xs2",
"LagunaS21Renderer": "renderers.laguna_xs2",
"LagunaXS21Renderer": "renderers.laguna_xs2",
"LagunaXS2Renderer": "renderers.laguna_xs2",
"Llama3Renderer": "renderers.llama_3",
Expand Down Expand Up @@ -149,6 +151,8 @@ def __dir__() -> list[str]:
"KimiK2RendererConfig",
"LagunaM1Renderer",
"LagunaM1RendererConfig",
"LagunaS21Renderer",
"LagunaS21RendererConfig",
"LagunaXS2Renderer",
"LagunaXS2RendererConfig",
"LagunaXS21Renderer",
Expand Down
11 changes: 9 additions & 2 deletions renderers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1073,11 +1073,16 @@ 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. 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-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",
Expand Down Expand Up @@ -1327,6 +1332,7 @@ def _populate_registry():
from renderers.kimi_k25 import KimiK25Renderer
from renderers.laguna_xs2 import (
LagunaM1Renderer,
LagunaS21Renderer,
LagunaXS2Renderer,
LagunaXS21Renderer,
)
Expand Down Expand Up @@ -1359,6 +1365,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,
Expand Down
34 changes: 34 additions & 0 deletions renderers/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_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 @@ -704,6 +735,7 @@ class DeepSeekR1RendererConfig(BaseRendererConfig):
LagunaXS2RendererConfig,
LagunaM1RendererConfig,
LagunaXS21RendererConfig,
LagunaS21RendererConfig,
Llama3RendererConfig,
MiniMaxM2RendererConfig,
Nemotron3RendererConfig,
Expand Down Expand Up @@ -745,6 +777,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,
Expand Down Expand Up @@ -792,6 +825,7 @@ def config_from_name(name: str) -> BaseRendererConfig | None:
"KimiK25RendererConfig",
"KimiK2RendererConfig",
"LagunaM1RendererConfig",
"LagunaS21RendererConfig",
"LagunaXS2RendererConfig",
"LagunaXS21RendererConfig",
"Llama3RendererConfig",
Expand Down
53 changes: 51 additions & 2 deletions renderers/laguna_xs2.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@
: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 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 @@ -63,6 +70,7 @@
)
from renderers.configs import (
LagunaM1RendererConfig,
LagunaS21RendererConfig,
LagunaXS2RendererConfig,
LagunaXS21RendererConfig,
)
Expand Down Expand Up @@ -120,6 +128,7 @@ def __init__(
LagunaXS2RendererConfig
| LagunaM1RendererConfig
| LagunaXS21RendererConfig
| LagunaS21RendererConfig
| None
) = None,
):
Expand Down Expand Up @@ -688,10 +697,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 @@ -975,7 +991,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 @@ -1017,3 +1033,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 @@ -41,6 +41,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 @@ -66,6 +66,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"),
]
Expand Down
Loading