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.
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/PKG-INFO +1 -1
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/docs/renderer-config.md +2 -1
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/__init__.py +2 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/_version.py +2 -2
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/base.py +29 -5
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/configs.py +11 -13
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/laguna_xs2.py +384 -13
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/parsing.py +27 -16
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/conftest.py +2 -2
- renderers-0.1.8.dev55/tests/test_laguna_xs21.py +376 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_renderer_config_parity.py +1 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_roundtrip.py +4 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/.github/workflows/publish-dev.yml +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/.github/workflows/publish.yml +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/.github/workflows/style.yml +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/.github/workflows/test.yml +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/.gitignore +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/.pre-commit-config.yaml +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/LICENSE +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/README.md +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/examples/README.md +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/examples/sglang/multiturn_generate_sglang.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/examples/sglang/online_multiturn_sglang.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/examples/tinker/multiturn_generate_tinker.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/examples/transformers/multiturn_generate_transformers.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/examples/vllm/multiturn_generate_vllm.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/pyproject.toml +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/client.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/deepseek_r1.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/deepseek_v3.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/default.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/glm45.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/glm5.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/gpt_oss.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/kimi_k2.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/kimi_k25.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/llama_3.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/minimax_m2.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/nemotron3.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/parsers.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/qwen3.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/qwen35.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/qwen36.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/renderers/qwen3_vl.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_bridge.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_build_helpers.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_client.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_deepseek_r1.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_gpt_oss_harmony_parity.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_incremental.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_is_content.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_kimi_k25_tool_schema.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_llama_3.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_load_tokenizer.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_message_indices.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_message_tool_names.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_multimodal.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_nemotron3_parity.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_nemotron3_ultra.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_parse_response.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_parse_response_robustness.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_parsers.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_preserve_thinking.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_qwen35_size_coverage.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_render_ids.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_renderer_config.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_sampled_mask.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_tokens_per_message.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/tests/test_tool_arg_type_preservation.py +0 -0
- {renderers-0.1.8.dev54 → renderers-0.1.8.dev55}/uv.lock +0 -0
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1, 8, '
|
|
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.
|
|
1064
|
-
#
|
|
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":
|
|
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,
|
|
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
|
|
427
|
-
|
|
428
|
-
template
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
"""
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
-
|
|
16
|
-
not a system message;
|
|
17
|
-
|
|
18
|
-
block
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
#
|
|
96
|
-
#
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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)
|