renderers 0.1.8.dev54__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.dev54 → renderers-0.1.8.dev55}/PKG-INFO +1 -1
  2. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/docs/renderer-config.md +2 -1
  3. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/__init__.py +2 -0
  4. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/_version.py +2 -2
  5. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/base.py +29 -5
  6. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/configs.py +11 -13
  7. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/laguna_xs2.py +384 -13
  8. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/parsing.py +27 -16
  9. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/conftest.py +2 -2
  10. renderers-0.1.8.dev55/tests/test_laguna_xs21.py +376 -0
  11. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_renderer_config_parity.py +1 -0
  12. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_roundtrip.py +4 -0
  13. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/.github/workflows/publish-dev.yml +0 -0
  14. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/.github/workflows/publish.yml +0 -0
  15. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/.github/workflows/style.yml +0 -0
  16. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/.github/workflows/test.yml +0 -0
  17. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/.gitignore +0 -0
  18. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/.pre-commit-config.yaml +0 -0
  19. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/LICENSE +0 -0
  20. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/README.md +0 -0
  21. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/examples/README.md +0 -0
  22. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/examples/sglang/multiturn_generate_sglang.py +0 -0
  23. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/examples/sglang/online_multiturn_sglang.py +0 -0
  24. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/examples/tinker/multiturn_generate_tinker.py +0 -0
  25. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/examples/transformers/multiturn_generate_transformers.py +0 -0
  26. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/examples/vllm/multiturn_generate_vllm.py +0 -0
  27. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/pyproject.toml +0 -0
  28. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/client.py +0 -0
  29. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/deepseek_r1.py +0 -0
  30. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/deepseek_v3.py +0 -0
  31. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/default.py +0 -0
  32. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/glm45.py +0 -0
  33. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/glm5.py +0 -0
  34. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/gpt_oss.py +0 -0
  35. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/kimi_k2.py +0 -0
  36. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/kimi_k25.py +0 -0
  37. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/llama_3.py +0 -0
  38. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/minimax_m2.py +0 -0
  39. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/nemotron3.py +0 -0
  40. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/parsers.py +0 -0
  41. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/qwen3.py +0 -0
  42. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/qwen35.py +0 -0
  43. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/qwen36.py +0 -0
  44. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/qwen3_vl.py +0 -0
  45. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_bridge.py +0 -0
  46. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_build_helpers.py +0 -0
  47. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_client.py +0 -0
  48. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_deepseek_r1.py +0 -0
  49. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_gpt_oss_harmony_parity.py +0 -0
  50. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_incremental.py +0 -0
  51. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_is_content.py +0 -0
  52. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_kimi_k25_tool_schema.py +0 -0
  53. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_llama_3.py +0 -0
  54. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_load_tokenizer.py +0 -0
  55. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_message_indices.py +0 -0
  56. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_message_tool_names.py +0 -0
  57. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_multimodal.py +0 -0
  58. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_nemotron3_parity.py +0 -0
  59. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_nemotron3_ultra.py +0 -0
  60. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_parse_response.py +0 -0
  61. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_parse_response_robustness.py +0 -0
  62. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_parsers.py +0 -0
  63. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_preserve_thinking.py +0 -0
  64. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_qwen35_size_coverage.py +0 -0
  65. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_render_ids.py +0 -0
  66. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_renderer_config.py +0 -0
  67. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_sampled_mask.py +0 -0
  68. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_tokens_per_message.py +0 -0
  69. {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_tool_arg_type_preservation.py +0 -0
  70. {renderers-0.1.8.dev54 → 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.dev54
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:
@@ -87,6 +87,7 @@ _LAZY_RENDERERS: dict[str, str] = {
87
87
  "GptOssRenderer": "renderers.gpt_oss",
88
88
  "KimiK25Renderer": "renderers.kimi_k25",
89
89
  "KimiK2Renderer": "renderers.kimi_k2",
90
+ "LagunaXS21Renderer": "renderers.laguna_xs2",
90
91
  "LagunaXS2Renderer": "renderers.laguna_xs2",
91
92
  "Llama3Renderer": "renderers.llama_3",
92
93
  "MiniMaxM2Renderer": "renderers.minimax_m2",
@@ -140,6 +141,7 @@ __all__ = [
140
141
  "KimiK2RendererConfig",
141
142
  "LagunaXS2Renderer",
142
143
  "LagunaXS2RendererConfig",
144
+ "LagunaXS21Renderer",
143
145
  "LagunaXS21RendererConfig",
144
146
  "Llama3Renderer",
145
147
  "Llama3RendererConfig",
@@ -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.dev54'
22
- __version_tuple__ = version_tuple = (0, 1, 8, 'dev54')
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,8 @@ 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. XS-2.1's template is byte-identical to XS.2's minus
1064
- # the default system message; the config name selects the variant.
1063
+ # Poolside Laguna. The two checkpoints ship different chat templates,
1064
+ # each mirrored by its own renderer class.
1065
1065
  "poolside/Laguna-XS.2": "laguna-xs.2",
1066
1066
  "poolside/Laguna-XS-2.1": "laguna-xs-2.1",
1067
1067
  # GPT-OSS.
@@ -1304,7 +1304,7 @@ def _populate_registry():
1304
1304
  from renderers.gpt_oss import GptOssRenderer
1305
1305
  from renderers.kimi_k2 import KimiK2Renderer
1306
1306
  from renderers.kimi_k25 import KimiK25Renderer
1307
- from renderers.laguna_xs2 import LagunaXS2Renderer
1307
+ from renderers.laguna_xs2 import LagunaXS2Renderer, LagunaXS21Renderer
1308
1308
  from renderers.llama_3 import Llama3Renderer
1309
1309
  from renderers.minimax_m2 import MiniMaxM2Renderer
1310
1310
  from renderers.nemotron3 import Nemotron3Renderer, Nemotron3UltraRenderer
@@ -1329,7 +1329,7 @@ def _populate_registry():
1329
1329
  "kimi-k2": KimiK2Renderer,
1330
1330
  "kimi-k2.5": KimiK25Renderer,
1331
1331
  "laguna-xs.2": LagunaXS2Renderer,
1332
- "laguna-xs-2.1": LagunaXS2Renderer,
1332
+ "laguna-xs-2.1": LagunaXS21Renderer,
1333
1333
  "llama-3": Llama3Renderer,
1334
1334
  "nemotron-3": Nemotron3Renderer,
1335
1335
  "nemotron-3-ultra": Nemotron3UltraRenderer,
@@ -1764,6 +1764,8 @@ def _get_offset_tokenizer(tokenizer):
1764
1764
  def attribute_text_segments(
1765
1765
  tokenizer,
1766
1766
  segments: "list[tuple[str, bool]]",
1767
+ *,
1768
+ overlap_is_content: bool = False,
1767
1769
  ) -> "list[tuple[int, bool]]":
1768
1770
  """Tokenize concatenated segments as a single BPE pass and return
1769
1771
  ``(token_id, is_content)`` pairs.
@@ -1782,6 +1784,16 @@ def attribute_text_segments(
1782
1784
  tokens (rare; usually pre-tokenizer artefacts) are attributed to
1783
1785
  the most recently entered segment.
1784
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
+
1785
1797
  Requires a HuggingFace fast tokenizer with offset tracking. Every
1786
1798
  model in ``MODEL_RENDERER_MAP`` ships one, so the offset lookup
1787
1799
  always succeeds for tokenizers obtained via :func:`load_tokenizer`.
@@ -1818,13 +1830,25 @@ def attribute_text_segments(
1818
1830
 
1819
1831
  out: list[tuple[int, bool]] = []
1820
1832
  last_is_content = spans[-1][2] if spans else False
1821
- for tok_id, (start, _end) in zip(token_ids, offsets):
1833
+ for tok_id, (start, end) in zip(token_ids, offsets):
1822
1834
  if start >= total_len:
1823
1835
  # Token's character offset is past every segment (shouldn't
1824
1836
  # normally happen for add_special_tokens=False, but defensive
1825
1837
  # against tokenizer-specific edge cases).
1826
1838
  out.append((tok_id, last_is_content))
1827
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
1828
1852
  # Find the segment that contains `start`. Segments are
1829
1853
  # contiguous and ordered, so a linear scan is fine — the inner
1830
1854
  # loop runs at most len(segments) times per token and segments
@@ -423,24 +423,22 @@ class LagunaXS2RendererConfig(BaseRendererConfig):
423
423
 
424
424
 
425
425
  class LagunaXS21RendererConfig(BaseRendererConfig):
426
- """Laguna XS-2.1 renderer config — distinct discriminator so auto
427
- resolution gives XS-2.1 checkpoints the no-default-system-message
428
- template variant.
429
-
430
- XS-2.1's chat template is byte-identical to XS.2's except it ships
431
- no default system message: when the caller provides none (and no
432
- tools), the ``<system>`` block is omitted entirely. Shares
433
- :class:`renderers.laguna_xs2.LagunaXS2Renderer`, which selects the
434
- variant from ``config.name``.
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`.
435
432
  """
436
433
 
437
434
  name: Literal["laguna-xs-2.1"] = "laguna-xs-2.1"
438
435
 
439
436
  enable_thinking: bool = False
440
- """See :class:`LagunaXS2RendererConfig.enable_thinking`."""
441
-
442
- render_assistant_messages_raw: bool = False
443
- """See :class:`LagunaXS2RendererConfig.render_assistant_messages_raw`."""
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."""
444
442
 
445
443
 
446
444
  class Llama3RendererConfig(BaseRendererConfig):
@@ -12,16 +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
- - XS.2's template bakes in a default system prompt when ``messages[0]`` is
16
- not a system message; XS-2.1's (otherwise byte-identical) template
17
- removed it, so no system message and no tools means no ``<system>``
18
- block at all. The config's ``name`` selects the variant. The system
19
- block also contains the tools section (under a ``### Tools`` header
20
- with an ``<available_tools>`` listing and prose format instructions
21
- 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).
22
21
  - Reasoning is rendered for every assistant message — no last-user-index
23
22
  gating. ``thinking_retention`` is accepted for protocol uniformity but
24
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.
25
39
  """
26
40
 
27
41
  from __future__ import annotations
@@ -79,6 +93,15 @@ _TOOLS_FOOTER_NO_THINKING = (
79
93
  "</tool_call>"
80
94
  )
81
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
+
82
105
 
83
106
  class LagunaXS2Renderer:
84
107
  def __init__(
@@ -92,11 +115,10 @@ class LagunaXS2Renderer:
92
115
  self.config,
93
116
  "all",
94
117
  )
95
- # XS.2's template bakes in a default system prompt; XS-2.1's
96
- # (otherwise byte-identical) template removed it.
97
- self._default_system_message = (
98
- _DEFAULT_SYSTEM_MESSAGE if self.config.name == "laguna-xs.2" else ""
99
- )
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
100
122
 
101
123
  self._eos = self._token_id("〈|EOS|〉")
102
124
  self._think = self._token_id("<think>")
@@ -459,7 +481,12 @@ class LagunaXS2Renderer:
459
481
  emit_text,
460
482
  emit_text_segments,
461
483
  ) -> None:
462
- 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
+ ):
463
490
  self._render_assistant_raw(
464
491
  msg_idx,
465
492
  content,
@@ -591,3 +618,347 @@ class LagunaXS2Renderer:
591
618
  emit_text("\n", msg_idx, is_sampled=False, is_content=False)
592
619
  emit_special(self._assistant_end, msg_idx, is_sampled=True, is_content=True)
593
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)