renderers 0.1.8.dev53__tar.gz → 0.1.8.dev55__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/PKG-INFO +1 -1
  2. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/docs/renderer-config.md +2 -1
  3. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/__init__.py +4 -0
  4. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/_version.py +2 -2
  5. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/base.py +30 -3
  6. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/configs.py +22 -0
  7. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/laguna_xs2.py +398 -18
  8. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/parsing.py +27 -16
  9. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/conftest.py +3 -0
  10. renderers-0.1.8.dev55/tests/test_laguna_xs21.py +376 -0
  11. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_renderer_config_parity.py +1 -0
  12. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_roundtrip.py +4 -0
  13. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/.github/workflows/publish-dev.yml +0 -0
  14. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/.github/workflows/publish.yml +0 -0
  15. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/.github/workflows/style.yml +0 -0
  16. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/.github/workflows/test.yml +0 -0
  17. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/.gitignore +0 -0
  18. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/.pre-commit-config.yaml +0 -0
  19. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/LICENSE +0 -0
  20. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/README.md +0 -0
  21. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/examples/README.md +0 -0
  22. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/examples/sglang/multiturn_generate_sglang.py +0 -0
  23. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/examples/sglang/online_multiturn_sglang.py +0 -0
  24. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/examples/tinker/multiturn_generate_tinker.py +0 -0
  25. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/examples/transformers/multiturn_generate_transformers.py +0 -0
  26. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/examples/vllm/multiturn_generate_vllm.py +0 -0
  27. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/pyproject.toml +0 -0
  28. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/client.py +0 -0
  29. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/deepseek_r1.py +0 -0
  30. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/deepseek_v3.py +0 -0
  31. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/default.py +0 -0
  32. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/glm45.py +0 -0
  33. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/glm5.py +0 -0
  34. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/gpt_oss.py +0 -0
  35. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/kimi_k2.py +0 -0
  36. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/kimi_k25.py +0 -0
  37. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/llama_3.py +0 -0
  38. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/minimax_m2.py +0 -0
  39. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/nemotron3.py +0 -0
  40. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/parsers.py +0 -0
  41. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/qwen3.py +0 -0
  42. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/qwen35.py +0 -0
  43. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/qwen36.py +0 -0
  44. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/renderers/qwen3_vl.py +0 -0
  45. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_bridge.py +0 -0
  46. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_build_helpers.py +0 -0
  47. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_client.py +0 -0
  48. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_deepseek_r1.py +0 -0
  49. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_gpt_oss_harmony_parity.py +0 -0
  50. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_incremental.py +0 -0
  51. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_is_content.py +0 -0
  52. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_kimi_k25_tool_schema.py +0 -0
  53. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_llama_3.py +0 -0
  54. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_load_tokenizer.py +0 -0
  55. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_message_indices.py +0 -0
  56. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_message_tool_names.py +0 -0
  57. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_multimodal.py +0 -0
  58. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_nemotron3_parity.py +0 -0
  59. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_nemotron3_ultra.py +0 -0
  60. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_parse_response.py +0 -0
  61. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_parse_response_robustness.py +0 -0
  62. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_parsers.py +0 -0
  63. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_preserve_thinking.py +0 -0
  64. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_qwen35_size_coverage.py +0 -0
  65. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_render_ids.py +0 -0
  66. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_renderer_config.py +0 -0
  67. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_sampled_mask.py +0 -0
  68. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_tokens_per_message.py +0 -0
  69. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/tests/test_tool_arg_type_preservation.py +0 -0
  70. {renderers-0.1.8.dev53 → renderers-0.1.8.dev55}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: renderers
3
- Version: 0.1.8.dev53
3
+ Version: 0.1.8.dev55
4
4
  Summary: Chat template renderers — deterministic message-to-token conversion for LLM training
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -35,6 +35,7 @@ chat-template kwargs. Those fields are covered by parity tests against
35
35
  | Kimi K2 | `KimiK2RendererConfig` | - | `enable_thinking` |
36
36
  | Kimi K2.5 / 2.6 | `KimiK25RendererConfig` | `thinking` | `image_cache_max` |
37
37
  | Laguna XS.2 | `LagunaXS2RendererConfig` | `enable_thinking`, `render_assistant_messages_raw` | - |
38
+ | Laguna XS-2.1 | `LagunaXS21RendererConfig` | `enable_thinking` | - |
38
39
  | Llama 3 | `Llama3RendererConfig` | `date_string`, `tools_in_user_message` | - |
39
40
  | MiniMax M2 | `MiniMaxM2RendererConfig` | `model_identity` | - |
40
41
  | Nemotron-3 Nano / Super | `Nemotron3RendererConfig` | `enable_thinking`, `truncate_history_thinking`, `low_effort` | - |
@@ -132,7 +133,7 @@ the knobs its template actually exposes:
132
133
  | Nemotron-3 | `truncate_history_thinking=False -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
133
134
  | DeepSeek R1 | `template` |
134
135
  | MiniMax M2 | `tool_cycle` |
135
- | DeepSeek V3, Qwen3-VL, Kimi K2, Laguna XS.2, Llama 3 | `all` |
136
+ | DeepSeek V3, Qwen3-VL, Kimi K2, Laguna XS.2 / XS-2.1, Llama 3 | `all` |
136
137
 
137
138
  Config construction raises when an explicit template knob directly contradicts
138
139
  an explicit generic bridge policy. For example:
@@ -54,6 +54,7 @@ from renderers.configs import (
54
54
  KimiK25RendererConfig,
55
55
  KimiK2RendererConfig,
56
56
  LagunaXS2RendererConfig,
57
+ LagunaXS21RendererConfig,
57
58
  Llama3RendererConfig,
58
59
  MiniMaxM2RendererConfig,
59
60
  Nemotron3RendererConfig,
@@ -86,6 +87,7 @@ _LAZY_RENDERERS: dict[str, str] = {
86
87
  "GptOssRenderer": "renderers.gpt_oss",
87
88
  "KimiK25Renderer": "renderers.kimi_k25",
88
89
  "KimiK2Renderer": "renderers.kimi_k2",
90
+ "LagunaXS21Renderer": "renderers.laguna_xs2",
89
91
  "LagunaXS2Renderer": "renderers.laguna_xs2",
90
92
  "Llama3Renderer": "renderers.llama_3",
91
93
  "MiniMaxM2Renderer": "renderers.minimax_m2",
@@ -139,6 +141,8 @@ __all__ = [
139
141
  "KimiK2RendererConfig",
140
142
  "LagunaXS2Renderer",
141
143
  "LagunaXS2RendererConfig",
144
+ "LagunaXS21Renderer",
145
+ "LagunaXS21RendererConfig",
142
146
  "Llama3Renderer",
143
147
  "Llama3RendererConfig",
144
148
  "MULTIMODAL_MODELS",
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.8.dev53'
22
- __version_tuple__ = version_tuple = (0, 1, 8, 'dev53')
21
+ __version__ = version = '0.1.8.dev55'
22
+ __version_tuple__ = version_tuple = (0, 1, 8, 'dev55')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -1060,8 +1060,10 @@ MODEL_RENDERER_MAP: dict[str, str] = {
1060
1060
  # construction to pin a different date.
1061
1061
  "meta-llama/Llama-3.2-1B-Instruct": "llama-3",
1062
1062
  "meta-llama/Llama-3.2-3B-Instruct": "llama-3",
1063
- # Poolside Laguna.
1063
+ # Poolside Laguna. The two checkpoints ship different chat templates,
1064
+ # each mirrored by its own renderer class.
1064
1065
  "poolside/Laguna-XS.2": "laguna-xs.2",
1066
+ "poolside/Laguna-XS-2.1": "laguna-xs-2.1",
1065
1067
  # GPT-OSS.
1066
1068
  "openai/gpt-oss-20b": "gpt-oss",
1067
1069
  "openai/gpt-oss-120b": "gpt-oss",
@@ -1302,7 +1304,7 @@ def _populate_registry():
1302
1304
  from renderers.gpt_oss import GptOssRenderer
1303
1305
  from renderers.kimi_k2 import KimiK2Renderer
1304
1306
  from renderers.kimi_k25 import KimiK25Renderer
1305
- from renderers.laguna_xs2 import LagunaXS2Renderer
1307
+ from renderers.laguna_xs2 import LagunaXS2Renderer, LagunaXS21Renderer
1306
1308
  from renderers.llama_3 import Llama3Renderer
1307
1309
  from renderers.minimax_m2 import MiniMaxM2Renderer
1308
1310
  from renderers.nemotron3 import Nemotron3Renderer, Nemotron3UltraRenderer
@@ -1327,6 +1329,7 @@ def _populate_registry():
1327
1329
  "kimi-k2": KimiK2Renderer,
1328
1330
  "kimi-k2.5": KimiK25Renderer,
1329
1331
  "laguna-xs.2": LagunaXS2Renderer,
1332
+ "laguna-xs-2.1": LagunaXS21Renderer,
1330
1333
  "llama-3": Llama3Renderer,
1331
1334
  "nemotron-3": Nemotron3Renderer,
1332
1335
  "nemotron-3-ultra": Nemotron3UltraRenderer,
@@ -1761,6 +1764,8 @@ def _get_offset_tokenizer(tokenizer):
1761
1764
  def attribute_text_segments(
1762
1765
  tokenizer,
1763
1766
  segments: "list[tuple[str, bool]]",
1767
+ *,
1768
+ overlap_is_content: bool = False,
1764
1769
  ) -> "list[tuple[int, bool]]":
1765
1770
  """Tokenize concatenated segments as a single BPE pass and return
1766
1771
  ``(token_id, is_content)`` pairs.
@@ -1779,6 +1784,16 @@ def attribute_text_segments(
1779
1784
  tokens (rare; usually pre-tokenizer artefacts) are attributed to
1780
1785
  the most recently entered segment.
1781
1786
 
1787
+ ``overlap_is_content=True`` widens the content bit: a token counts
1788
+ as content when *any* of its source characters fall in a content
1789
+ segment, not just its first. Templates whose wrap glues directly
1790
+ onto the body with no whitespace (e.g. ``<user>{content}</user>``)
1791
+ can merge wrap and body bytes into one token; under the first-char
1792
+ policy such a token would land on the wrap side and the body would
1793
+ no longer be recoverable from the content run. Over-inclusion keeps
1794
+ every body byte inside the ``is_content=True`` run at the cost of a
1795
+ few adjacent wrap bytes.
1796
+
1782
1797
  Requires a HuggingFace fast tokenizer with offset tracking. Every
1783
1798
  model in ``MODEL_RENDERER_MAP`` ships one, so the offset lookup
1784
1799
  always succeeds for tokenizers obtained via :func:`load_tokenizer`.
@@ -1815,13 +1830,25 @@ def attribute_text_segments(
1815
1830
 
1816
1831
  out: list[tuple[int, bool]] = []
1817
1832
  last_is_content = spans[-1][2] if spans else False
1818
- for tok_id, (start, _end) in zip(token_ids, offsets):
1833
+ for tok_id, (start, end) in zip(token_ids, offsets):
1819
1834
  if start >= total_len:
1820
1835
  # Token's character offset is past every segment (shouldn't
1821
1836
  # normally happen for add_special_tokens=False, but defensive
1822
1837
  # against tokenizer-specific edge cases).
1823
1838
  out.append((tok_id, last_is_content))
1824
1839
  continue
1840
+ if overlap_is_content and end > start:
1841
+ out.append(
1842
+ (
1843
+ tok_id,
1844
+ any(
1845
+ seg_is_content
1846
+ for seg_start, seg_end, seg_is_content in spans
1847
+ if seg_start < end and start < seg_end
1848
+ ),
1849
+ )
1850
+ )
1851
+ continue
1825
1852
  # Find the segment that contains `start`. Segments are
1826
1853
  # contiguous and ordered, so a linear scan is fine — the inner
1827
1854
  # loop runs at most len(segments) times per token and segments
@@ -422,6 +422,25 @@ class LagunaXS2RendererConfig(BaseRendererConfig):
422
422
  chat template's ``render_assistant_messages_raw`` gate."""
423
423
 
424
424
 
425
+ class LagunaXS21RendererConfig(BaseRendererConfig):
426
+ """Laguna XS-2.1 renderer config.
427
+
428
+ XS-2.1's chat template reads a single kwarg, ``enable_thinking``,
429
+ which gates both the generation prompt (``<think>`` vs ``</think>``)
430
+ and whether assistant reasoning is rendered into the history at all.
431
+ Served by :class:`renderers.laguna_xs2.LagunaXS21Renderer`.
432
+ """
433
+
434
+ name: Literal["laguna-xs-2.1"] = "laguna-xs-2.1"
435
+
436
+ enable_thinking: bool = False
437
+ """When ``True``, the generation prompt ends with ``<think>`` and
438
+ every assistant turn renders ``<think>{reasoning}</think>``; when
439
+ ``False``, turns open with a bare ``</think>`` and reasoning is not
440
+ rendered. Mirrors the template's ``enable_thinking`` kwarg and its
441
+ upstream default."""
442
+
443
+
425
444
  class Llama3RendererConfig(BaseRendererConfig):
426
445
  """Llama-3.x Instruct renderer config.
427
446
 
@@ -576,6 +595,7 @@ RendererConfig = Annotated[
576
595
  KimiK2RendererConfig,
577
596
  KimiK25RendererConfig,
578
597
  LagunaXS2RendererConfig,
598
+ LagunaXS21RendererConfig,
579
599
  Llama3RendererConfig,
580
600
  MiniMaxM2RendererConfig,
581
601
  Nemotron3RendererConfig,
@@ -613,6 +633,7 @@ _CONFIG_BY_NAME: dict[str, type[BaseRendererConfig]] = {
613
633
  "kimi-k2": KimiK2RendererConfig,
614
634
  "kimi-k2.5": KimiK25RendererConfig,
615
635
  "laguna-xs.2": LagunaXS2RendererConfig,
636
+ "laguna-xs-2.1": LagunaXS21RendererConfig,
616
637
  "llama-3": Llama3RendererConfig,
617
638
  "minimax-m2": MiniMaxM2RendererConfig,
618
639
  "nemotron-3": Nemotron3RendererConfig,
@@ -659,6 +680,7 @@ __all__ = [
659
680
  "KimiK25RendererConfig",
660
681
  "KimiK2RendererConfig",
661
682
  "LagunaXS2RendererConfig",
683
+ "LagunaXS21RendererConfig",
662
684
  "Llama3RendererConfig",
663
685
  "MiniMaxM2RendererConfig",
664
686
  "Nemotron3RendererConfig",
@@ -1,4 +1,4 @@
1
- """Laguna-XS.2 Renderer.
1
+ """Laguna-XS.2 / XS-2.1 Renderer.
2
2
 
3
3
  Main properties:
4
4
  - Prefix is the single token ``〈|EOS|〉`` (also the EOS / stop token).
@@ -12,13 +12,30 @@ Main properties:
12
12
  - Tool calls: ``<tool_call>`` / ``</tool_call>`` ARE single tokens, but the
13
13
  inner ``<arg_key>`` / ``</arg_key>`` / ``<arg_value>`` / ``</arg_value>``
14
14
  markers are plain text — parsed via regex on the decoded inner block.
15
- - The template bakes in a default system prompt when ``messages[0]`` is not
16
- a system message. The system block also contains the tools section (under
17
- a ``### Tools`` header with an ``<available_tools>`` listing and prose
18
- format instructions that vary on ``enable_thinking``).
15
+ - Both templates bake in the same default Poolside system prompt when
16
+ ``messages[0]`` is not a system message; a caller-supplied system
17
+ message overrides it, and an *empty* one opts out of the ``<system>``
18
+ block entirely (absent tools). The system block also contains the
19
+ tools section (under a ``### Tools`` header with an
20
+ ``<available_tools>`` listing).
19
21
  - Reasoning is rendered for every assistant message — no last-user-index
20
22
  gating. ``thinking_retention`` is accepted for protocol uniformity but
21
23
  is effectively a no-op since past reasoning is preserved by default.
24
+
25
+ XS-2.1's template (upstream rev ``575f0f28``) is served by the
26
+ :class:`LagunaXS21Renderer` subclass below:
27
+
28
+ - Role tags hug their content: ``<user>{content}</user>``, no inner
29
+ newlines.
30
+ - Assistant reasoning is gated on ``enable_thinking``: on, the turn
31
+ opens ``<think>{reasoning}</think>`` verbatim (empty reasoning
32
+ included); off, it opens with a bare ``</think>`` and message
33
+ reasoning is not rendered. Content and tool-call args render verbatim.
34
+ - Tool-call args pack tightly
35
+ (``<arg_key>k</arg_key><arg_value>v</arg_value>``) and the tools
36
+ section ends at ``</available_tools>``.
37
+ - The ``<system>`` block is emitted whenever there is system content,
38
+ tools, or ``enable_thinking`` — even if that leaves it empty.
22
39
  """
23
40
 
24
41
  from __future__ import annotations
@@ -39,7 +56,7 @@ from renderers.base import (
39
56
  resolve_thinking_retention,
40
57
  should_rerender_for_thinking_retention,
41
58
  )
42
- from renderers.configs import LagunaXS2RendererConfig
59
+ from renderers.configs import LagunaXS2RendererConfig, LagunaXS21RendererConfig
43
60
  from renderers.parsing import parse_laguna_xs2
44
61
 
45
62
  _DEFAULT_SYSTEM_MESSAGE = (
@@ -76,12 +93,21 @@ _TOOLS_FOOTER_NO_THINKING = (
76
93
  "</tool_call>"
77
94
  )
78
95
 
96
+ # XS-2.1 tools section: this header, one tojson line per tool, then a
97
+ # bare "</available_tools>" close.
98
+ _TOOLS_HEADER_XS21 = (
99
+ "### Tools\n\n"
100
+ "You may call functions to assist with the user query.\n"
101
+ "All available function signatures are listed below:\n"
102
+ "<available_tools>\n"
103
+ )
104
+
79
105
 
80
106
  class LagunaXS2Renderer:
81
107
  def __init__(
82
108
  self,
83
109
  tokenizer: PreTrainedTokenizer,
84
- config: LagunaXS2RendererConfig | None = None,
110
+ config: LagunaXS2RendererConfig | LagunaXS21RendererConfig | None = None,
85
111
  ):
86
112
  self._tokenizer = tokenizer
87
113
  self.config = config or LagunaXS2RendererConfig()
@@ -89,6 +115,10 @@ class LagunaXS2Renderer:
89
115
  self.config,
90
116
  "all",
91
117
  )
118
+ # Both templates bake in the same default Poolside system prompt;
119
+ # an empty caller-supplied system message opts out of the
120
+ # <system> block (each variant's render mirrors its own gate).
121
+ self._default_system_message = _DEFAULT_SYSTEM_MESSAGE
92
122
 
93
123
  self._eos = self._token_id("〈|EOS|〉")
94
124
  self._think = self._token_id("<think>")
@@ -184,7 +214,7 @@ class LagunaXS2Renderer:
184
214
  emit_special(self._eos, -1, is_sampled=False, is_content=False)
185
215
 
186
216
  # ── System header (absorbs messages[0] if it's a system message) ──
187
- system_content = _DEFAULT_SYSTEM_MESSAGE
217
+ system_content = self._default_system_message
188
218
  system_msg_idx = -1
189
219
  caller_has_system = bool(messages and messages[0].get("role") == "system")
190
220
  if caller_has_system:
@@ -196,16 +226,12 @@ class LagunaXS2Renderer:
196
226
  # gate: when the caller passes an empty system message and no tools,
197
227
  # the whole ``<system>...</system>`` block is omitted.
198
228
  if has_sys_content or tools:
199
- # The template emits ``<system>\n`` then conditionally a second
200
- # ``\n``. Bundle those into one emit so BPE merges ``\n\n`` into
201
- # its single-token form (rather than two ``\n`` atoms).
202
- emit_text(
203
- "<system>\n\n" if has_sys_content else "<system>\n",
204
- -1,
205
- is_sampled=False,
206
- is_content=False,
207
- )
208
229
  if has_sys_content:
230
+ # The template emits ``<system>\n`` then a second ``\n``
231
+ # before the system body. Bundle those into one emit so BPE
232
+ # merges ``\n\n`` into its single-token form (rather than
233
+ # two ``\n`` atoms).
234
+ emit_text("<system>\n\n", -1, is_sampled=False, is_content=False)
209
235
  # If the caller provided system content, it's body bytes;
210
236
  # otherwise this is the default system prompt (scaffold).
211
237
  sys_is_content = caller_has_system
@@ -224,6 +250,11 @@ class LagunaXS2Renderer:
224
250
  if self.config.enable_thinking
225
251
  else _TOOLS_FOOTER_NO_THINKING
226
252
  )
253
+ if not has_sys_content:
254
+ # No system body: ``<system>\n`` runs straight into the
255
+ # tools header's ``\n\n`` — encode them together so BPE
256
+ # merges the ``\n\n\n`` seam as the template does.
257
+ tool_text = "<system>\n" + tool_text
227
258
  emit_text(tool_text, -1, is_sampled=False, is_content=False)
228
259
  emit_text("\n</system>\n", -1, is_sampled=False, is_content=False)
229
260
 
@@ -450,7 +481,12 @@ class LagunaXS2Renderer:
450
481
  emit_text,
451
482
  emit_text_segments,
452
483
  ) -> None:
453
- if self.config.render_assistant_messages_raw:
484
+ # Raw passthrough is an XS.2-only template gate; the XS-2.1
485
+ # config doesn't define it.
486
+ if (
487
+ isinstance(self.config, LagunaXS2RendererConfig)
488
+ and self.config.render_assistant_messages_raw
489
+ ):
454
490
  self._render_assistant_raw(
455
491
  msg_idx,
456
492
  content,
@@ -582,3 +618,347 @@ class LagunaXS2Renderer:
582
618
  emit_text("\n", msg_idx, is_sampled=False, is_content=False)
583
619
  emit_special(self._assistant_end, msg_idx, is_sampled=True, is_content=True)
584
620
  emit_text("\n", msg_idx, is_sampled=False, is_content=False)
621
+
622
+
623
+ class LagunaXS21Renderer(LagunaXS2Renderer):
624
+ """Laguna-XS-2.1 — mirrors the ``poolside/Laguna-XS-2.1`` chat
625
+ template (upstream rev ``575f0f28``); see the module docstring for
626
+ its format.
627
+
628
+ Token wiring, stop tokens, and the parsing skeleton are shared with
629
+ :class:`LagunaXS2Renderer`; ``render``, ``bridge_to_next_turn``, and
630
+ the assistant emit implement this template's format.
631
+ """
632
+
633
+ def __init__(
634
+ self,
635
+ tokenizer: PreTrainedTokenizer,
636
+ config: LagunaXS21RendererConfig | None = None,
637
+ ):
638
+ super().__init__(tokenizer, config or LagunaXS21RendererConfig())
639
+
640
+ def render(
641
+ self,
642
+ messages: list[Message],
643
+ *,
644
+ tools: list[ToolSpec] | None = None,
645
+ add_generation_prompt: bool = False,
646
+ ) -> RenderedTokens:
647
+ if not messages:
648
+ raise ValueError("No messages provided.")
649
+
650
+ tokens: list[int] = []
651
+ indices: list[int] = []
652
+ sampled: list[bool] = []
653
+ content_mask: list[bool] = []
654
+
655
+ def emit_special(
656
+ token_id: int, msg_idx: int, *, is_sampled: bool, is_content: bool
657
+ ) -> None:
658
+ tokens.append(token_id)
659
+ indices.append(msg_idx)
660
+ sampled.append(is_sampled)
661
+ content_mask.append(is_content)
662
+
663
+ def emit_text(
664
+ text: str, msg_idx: int, *, is_sampled: bool, is_content: bool
665
+ ) -> None:
666
+ ids = self._encode(text)
667
+ tokens.extend(ids)
668
+ indices.extend([msg_idx] * len(ids))
669
+ sampled.extend([is_sampled] * len(ids))
670
+ content_mask.extend([is_content] * len(ids))
671
+
672
+ def emit_text_segments(
673
+ segments: list[tuple[str, bool]], msg_idx: int, *, is_sampled: bool
674
+ ) -> None:
675
+ # Role tags hug the body with no whitespace, so a BPE merge
676
+ # can pull wrap bytes and body bytes into one token —
677
+ # overlap attribution keeps every body byte in the content
678
+ # run.
679
+ for tok_id, is_content in attribute_text_segments(
680
+ self._tokenizer, segments, overlap_is_content=True
681
+ ):
682
+ tokens.append(tok_id)
683
+ indices.append(msg_idx)
684
+ sampled.append(is_sampled)
685
+ content_mask.append(is_content)
686
+
687
+ emit_special(self._eos, -1, is_sampled=False, is_content=False)
688
+
689
+ # ── System header (absorbs messages[0] if it's a system message) ──
690
+ system_content = self._default_system_message
691
+ caller_has_system = bool(messages and messages[0].get("role") == "system")
692
+ if caller_has_system:
693
+ system_content = self._visible_text(messages[0].get("content"))
694
+
695
+ has_sys_content = bool(system_content and system_content.strip())
696
+ # The template's gate is ``has_sys or tools or enable_thinking`` —
697
+ # an empty caller system message opts out of the default, and with
698
+ # neither tools nor thinking the block vanishes entirely.
699
+ if has_sys_content or tools or self.config.enable_thinking:
700
+ # The whole header is one plain-text run — ``<system>`` glues
701
+ # straight onto the body with no newline — so it must be
702
+ # tokenized in a single BPE pass. In the header, content bytes
703
+ # exist exactly when the caller supplied the system message
704
+ # (the default prompt is scaffold), so the is_content bit also
705
+ # selects the message index: body → 0, everything else → -1.
706
+ header_segs: list[tuple[str, bool]] = [("<system>", False)]
707
+ if has_sys_content:
708
+ header_segs.append((system_content.rstrip(), caller_has_system))
709
+ if tools:
710
+ header_segs.append(("\n\n", False))
711
+ if tools:
712
+ tool_text = _TOOLS_HEADER_XS21
713
+ for tool in tools:
714
+ tool_text += json.dumps(tool, ensure_ascii=False) + "\n"
715
+ tool_text += "</available_tools>"
716
+ header_segs.append((tool_text, False))
717
+ header_segs.append(("</system>\n", False))
718
+ for tok_id, is_content in attribute_text_segments(
719
+ self._tokenizer, header_segs, overlap_is_content=True
720
+ ):
721
+ emit_special(
722
+ tok_id,
723
+ 0 if is_content else -1,
724
+ is_sampled=False,
725
+ is_content=is_content,
726
+ )
727
+
728
+ # ── Per-message loop ──────────────────────────────────────────
729
+ for i, msg in enumerate(messages):
730
+ content = self._visible_text(msg.get("content"))
731
+
732
+ match msg["role"]:
733
+ case "system":
734
+ # The template slices a leading system message off the
735
+ # loop (it lives in the header); later ones render.
736
+ if i == 0:
737
+ continue
738
+ sys_segs: list[tuple[str, bool]] = [("<system>", False)]
739
+ if content:
740
+ sys_segs.append((content, True))
741
+ sys_segs.append(("</system>\n", False))
742
+ emit_text_segments(sys_segs, i, is_sampled=False)
743
+ case "user":
744
+ user_segs: list[tuple[str, bool]] = [("<user>", False)]
745
+ if content:
746
+ user_segs.append((content, True))
747
+ user_segs.append(("</user>\n", False))
748
+ emit_text_segments(user_segs, i, is_sampled=False)
749
+ case "assistant":
750
+ self._render_assistant(
751
+ msg,
752
+ i,
753
+ content,
754
+ emit_special=emit_special,
755
+ emit_text=emit_text,
756
+ emit_text_segments=emit_text_segments,
757
+ )
758
+ case "tool":
759
+ tool_segs: list[tuple[str, bool]] = [("<tool_response>", False)]
760
+ if content:
761
+ tool_segs.append((content, True))
762
+ tool_segs.append(("</tool_response>\n", False))
763
+ emit_text_segments(tool_segs, i, is_sampled=False)
764
+
765
+ # ── Generation prompt (no newline after <assistant>) ──────────
766
+ if add_generation_prompt:
767
+ emit_special(self._assistant, -1, is_sampled=False, is_content=False)
768
+ if self.config.enable_thinking:
769
+ emit_special(self._think, -1, is_sampled=False, is_content=False)
770
+ else:
771
+ emit_special(self._think_end, -1, is_sampled=False, is_content=False)
772
+
773
+ return RenderedTokens(
774
+ token_ids=tokens,
775
+ message_indices=indices,
776
+ sampled_mask=sampled,
777
+ is_content=content_mask,
778
+ message_roles=[m.get("role") or "" for m in messages],
779
+ message_tool_names=extract_message_tool_names(messages),
780
+ )
781
+
782
+ def parse_response(
783
+ self,
784
+ token_ids: list[int],
785
+ *,
786
+ tools: list[ToolSpec] | None = None,
787
+ ) -> ParsedResponse:
788
+ # The XS-2.1 template renders reasoning and content verbatim (no
789
+ # newline wrapping), so the parse is verbatim too.
790
+ return parse_laguna_xs2(
791
+ self._tokenizer,
792
+ token_ids,
793
+ stop_ids={self._assistant_end, self._eos},
794
+ think_id=self._think,
795
+ think_end_id=self._think_end,
796
+ tool_call_id=self._tool_call,
797
+ tool_call_end_id=self._tool_call_end,
798
+ tools=tools,
799
+ strip_newlines=False,
800
+ )
801
+
802
+ def bridge_to_next_turn(
803
+ self,
804
+ previous_prompt_ids: list[int],
805
+ previous_completion_ids: list[int],
806
+ new_messages: list[Message],
807
+ *,
808
+ tools: list[ToolSpec] | None = None,
809
+ ) -> RenderedTokens | None:
810
+ if (
811
+ not previous_prompt_ids
812
+ or not new_messages
813
+ or reject_assistant_in_extension(new_messages)
814
+ ):
815
+ return None
816
+ if should_rerender_for_thinking_retention(
817
+ self.effective_thinking_retention,
818
+ new_messages,
819
+ ):
820
+ return None
821
+
822
+ # ``</assistant>`` is the canonical turn close; ``〈|EOS|〉`` also
823
+ # stops generation. Truncation (no stop token at the tail)
824
+ # synthesises the close. The inter-turn ``\n`` the template puts
825
+ # after ``</assistant>`` is prepended to the first extension
826
+ # message below so the seam encodes with the tag run.
827
+ previous_ids = list(previous_prompt_ids) + list(previous_completion_ids)
828
+ stop_ids = {self._assistant_end, self._eos}
829
+ if (
830
+ not previous_ids[len(previous_prompt_ids) :]
831
+ or previous_ids[-1] not in stop_ids
832
+ ):
833
+ previous_ids.append(self._assistant_end)
834
+
835
+ ext: list[int] = []
836
+ ext_indices: list[int] = []
837
+ ext_sampled: list[bool] = []
838
+ ext_content: list[bool] = []
839
+
840
+ def emit_special(
841
+ token_id: int,
842
+ msg_idx: int = -1,
843
+ *,
844
+ is_sampled: bool = False,
845
+ is_content: bool = False,
846
+ ) -> None:
847
+ ext.append(token_id)
848
+ ext_indices.append(msg_idx)
849
+ ext_sampled.append(is_sampled)
850
+ ext_content.append(is_content)
851
+
852
+ def emit_text_segments(
853
+ segments: list[tuple[str, bool]],
854
+ msg_idx: int = -1,
855
+ *,
856
+ is_sampled: bool = False,
857
+ ) -> None:
858
+ for tok_id, is_content in attribute_text_segments(
859
+ self._tokenizer, segments, overlap_is_content=True
860
+ ):
861
+ ext.append(tok_id)
862
+ ext_indices.append(msg_idx)
863
+ ext_sampled.append(is_sampled)
864
+ ext_content.append(is_content)
865
+
866
+ _OPEN = {"user": "<user>", "system": "<system>", "tool": "<tool_response>"}
867
+ _CLOSE = {
868
+ "user": "</user>\n",
869
+ "system": "</system>\n",
870
+ "tool": "</tool_response>\n",
871
+ }
872
+ for i, msg in enumerate(new_messages):
873
+ role = msg.get("role")
874
+ if role not in _OPEN:
875
+ return None
876
+ content = self._visible_text(msg.get("content"))
877
+ lead = "\n" if i == 0 else ""
878
+ segs: list[tuple[str, bool]] = [(lead + _OPEN[role], False)]
879
+ if content:
880
+ segs.append((content, True))
881
+ segs.append((_CLOSE[role], False))
882
+ emit_text_segments(segs, i)
883
+
884
+ emit_special(self._assistant, -1)
885
+ if self.config.enable_thinking:
886
+ emit_special(self._think, -1)
887
+ else:
888
+ emit_special(self._think_end, -1)
889
+
890
+ total_len = len(previous_ids) + len(ext)
891
+ return RenderedTokens(
892
+ token_ids=previous_ids + ext,
893
+ message_indices=[-1] * len(previous_ids) + ext_indices,
894
+ sampled_mask=[False] * total_len,
895
+ is_content=[False] * len(previous_ids) + ext_content,
896
+ message_roles=[m.get("role") or "" for m in new_messages],
897
+ message_tool_names=extract_message_tool_names(new_messages),
898
+ )
899
+
900
+ def _render_assistant(
901
+ self,
902
+ msg: Message,
903
+ msg_idx: int,
904
+ content: str,
905
+ *,
906
+ emit_special,
907
+ emit_text,
908
+ emit_text_segments,
909
+ ) -> None:
910
+ reasoning_content = ""
911
+ if isinstance(msg.get("reasoning_content"), str):
912
+ reasoning_content = msg["reasoning_content"]
913
+ else:
914
+ part_thinking = self._thinking_text(msg.get("content"))
915
+ if part_thinking:
916
+ reasoning_content = part_thinking
917
+
918
+ # ``<assistant>`` plus the think tag that follows are exactly the
919
+ # generation prompt for the active mode — template-injected
920
+ # scaffolding the model never samples.
921
+ emit_special(self._assistant, msg_idx, is_sampled=False, is_content=False)
922
+
923
+ if self.config.enable_thinking:
924
+ # ``<think>{reasoning}</think>`` renders verbatim, even when
925
+ # the reasoning is empty; the opener is the gen-prompt
926
+ # prefill, the rest the model sampled.
927
+ emit_special(self._think, msg_idx, is_sampled=False, is_content=False)
928
+ if reasoning_content:
929
+ emit_text(reasoning_content, msg_idx, is_sampled=True, is_content=True)
930
+ emit_special(self._think_end, msg_idx, is_sampled=True, is_content=True)
931
+ else:
932
+ # Thinking off: any reasoning on the message is dropped and
933
+ # the turn opens with the prefilled ``</think>``.
934
+ emit_special(self._think_end, msg_idx, is_sampled=False, is_content=False)
935
+
936
+ if content:
937
+ emit_text(content, msg_idx, is_sampled=True, is_content=True)
938
+
939
+ tool_calls = msg.get("tool_calls") or []
940
+ for tc in tool_calls:
941
+ func = tc.get("function") or tc
942
+ name = func.get("name", "")
943
+ arguments = func.get("arguments", {})
944
+ if isinstance(arguments, str):
945
+ try:
946
+ arguments = json.loads(arguments)
947
+ except json.JSONDecodeError:
948
+ arguments = {}
949
+
950
+ emit_special(self._tool_call, msg_idx, is_sampled=True, is_content=True)
951
+ inner = name
952
+ if isinstance(arguments, dict):
953
+ for k, v in arguments.items():
954
+ inner += "<arg_key>" + k + "</arg_key>"
955
+ if isinstance(v, str):
956
+ val_text = v
957
+ else:
958
+ val_text = json.dumps(v, ensure_ascii=False)
959
+ inner += "<arg_value>" + val_text + "</arg_value>"
960
+ emit_text(inner, msg_idx, is_sampled=True, is_content=True)
961
+ emit_special(self._tool_call_end, msg_idx, is_sampled=True, is_content=True)
962
+
963
+ emit_special(self._assistant_end, msg_idx, is_sampled=True, is_content=True)
964
+ emit_text("\n", msg_idx, is_sampled=False, is_content=False)