renderers 0.1.12.dev8__tar.gz → 0.1.12.dev10__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.12.dev8 → renderers-0.1.12.dev10}/PKG-INFO +9 -1
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/README.md +8 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/_version.py +2 -2
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/deepseek_v4.py +1 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/gemma4.py +14 -10
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/glm45.py +1 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/glm5.py +1 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/inkling.py +6 -1
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/kimi_k2.py +1 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/kimi_k25.py +12 -3
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/minimax_m2.py +1 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/nemotron3.py +1 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/parsing.py +81 -21
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/prime_qwen3.py +1 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/qwen3.py +19 -7
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/qwen35.py +1 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/qwen3_vl.py +1 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/reasoning.py +37 -3
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_bridge.py +18 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_reasoning_boundaries.py +114 -6
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/.github/workflows/publish-dev.yml +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/.github/workflows/publish.yml +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/.github/workflows/style.yml +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/.github/workflows/test.yml +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/.gitignore +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/.pre-commit-config.yaml +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/LICENSE +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/docs/renderer-config.md +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/examples/README.md +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/examples/sglang/multiturn_generate_sglang.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/examples/sglang/online_multiturn_sglang.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/examples/tinker/multiturn_generate_tinker.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/examples/transformers/multiturn_generate_transformers.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/examples/vllm/multiturn_generate_vllm.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/pyproject.toml +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/__init__.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/base.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/client.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/configs.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/deepseek_r1.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/deepseek_v3.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/default.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/gpt_oss.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/hy3.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/laguna_s21.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/laguna_xs2.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/llama_3.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/parsers.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/qwen36.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/qwen38.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/conftest.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/parity.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/reference_rendering.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_build_helpers.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_client.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_deepseek_r1.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_deepseek_v4.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_disabled_thinking_stability.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_gemma4.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_glm_tool_name_validation.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_hy3.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_incremental.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_inkling.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_is_content.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_kimi_k25_tool_schema.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_laguna_m1.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_laguna_xs21.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_llama_3.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_load_tokenizer.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_message_indices.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_message_tool_names.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_multimodal.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_nemotron35_vlm.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_nemotron3_ultra.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_offsetless_tokenizers.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_parity.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_parse_response.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_parse_response_robustness.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_parsers.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_preserve_thinking.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_qwen35_size_coverage.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_qwen38.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_renderer_config.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_roundtrip.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_sampled_mask.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_tokens_per_message.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_tool_arg_type_preservation.py +0 -0
- {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: renderers
|
|
3
|
-
Version: 0.1.12.
|
|
3
|
+
Version: 0.1.12.dev10
|
|
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
|
|
@@ -77,6 +77,14 @@ as self-contained: reasoning needs its own initial opener or channel header.
|
|
|
77
77
|
Generation settings never supply missing parsing context. If the prompt supplied
|
|
78
78
|
the opener, pass that prompt; `generate` does this automatically.
|
|
79
79
|
|
|
80
|
+
Where vLLM 0.26's parser does the same, the format's atomic tool-call opener
|
|
81
|
+
also ends reasoning that has no closing marker: Qwen3 / Qwen3-VL / Qwen3.5+,
|
|
82
|
+
Prime-Qwen3, Nemotron-3, GLM, MiniMax-M2, DeepSeek-V4, Kimi-K2 / K2.5, Gemma 4,
|
|
83
|
+
and Inkling. The call parses normally, and `bridge_to_next_turn` extends it
|
|
84
|
+
without inserting a close. A closing marker later in the completion still wins:
|
|
85
|
+
tool markup before it stays reasoning. Inkling ends thinking and tool segments
|
|
86
|
+
with the same `<|end_message|>`, so there the first opener decides.
|
|
87
|
+
|
|
80
88
|
For reasoning-first formats, only an initial `<think>` (after any assistant
|
|
81
89
|
header) or reasoning already opened in the prompt starts reasoning. After its
|
|
82
90
|
first closing marker, the rest is content. Later think markers remain literal
|
|
@@ -57,6 +57,14 @@ as self-contained: reasoning needs its own initial opener or channel header.
|
|
|
57
57
|
Generation settings never supply missing parsing context. If the prompt supplied
|
|
58
58
|
the opener, pass that prompt; `generate` does this automatically.
|
|
59
59
|
|
|
60
|
+
Where vLLM 0.26's parser does the same, the format's atomic tool-call opener
|
|
61
|
+
also ends reasoning that has no closing marker: Qwen3 / Qwen3-VL / Qwen3.5+,
|
|
62
|
+
Prime-Qwen3, Nemotron-3, GLM, MiniMax-M2, DeepSeek-V4, Kimi-K2 / K2.5, Gemma 4,
|
|
63
|
+
and Inkling. The call parses normally, and `bridge_to_next_turn` extends it
|
|
64
|
+
without inserting a close. A closing marker later in the completion still wins:
|
|
65
|
+
tool markup before it stays reasoning. Inkling ends thinking and tool segments
|
|
66
|
+
with the same `<|end_message|>`, so there the first opener decides.
|
|
67
|
+
|
|
60
68
|
For reasoning-first formats, only an initial `<think>` (after any assistant
|
|
61
69
|
header) or reasoning already opened in the prompt starts reasoning. After its
|
|
62
70
|
first closing marker, the rest is content. Later think markers remain literal
|
|
@@ -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.12.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1, 12, '
|
|
21
|
+
__version__ = version = '0.1.12.dev10'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 12, 'dev10')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -1146,6 +1146,7 @@ class Gemma4Renderer:
|
|
|
1146
1146
|
open_id=self._channel_start,
|
|
1147
1147
|
close_id=self._channel_end,
|
|
1148
1148
|
tool_start_id=self._tool_call_start,
|
|
1149
|
+
tool_start_closes_reasoning=True,
|
|
1149
1150
|
)
|
|
1150
1151
|
if boundary.is_open:
|
|
1151
1152
|
reasoning = (boundary.text or "").removeprefix("thought\n").strip()
|
|
@@ -1154,14 +1155,19 @@ class Gemma4Renderer:
|
|
|
1154
1155
|
reasoning_content=reasoning,
|
|
1155
1156
|
reasoning_complete=False,
|
|
1156
1157
|
)
|
|
1158
|
+
|
|
1159
|
+
def thought_end(start: int) -> int:
|
|
1160
|
+
"""Index of the closer, else of a tool call that ended the thought."""
|
|
1161
|
+
closer = self._channel_end
|
|
1162
|
+
if self._channel_end not in ids[start:] and boundary.closed_by_tool:
|
|
1163
|
+
closer = self._tool_call_start
|
|
1164
|
+
return next((i for i in range(start, len(ids)) if ids[i] == closer), -1)
|
|
1165
|
+
|
|
1157
1166
|
reasoning: str | None = None
|
|
1158
1167
|
content_ids: list[int] = []
|
|
1159
1168
|
cursor = 0
|
|
1160
1169
|
if ids and ids[0] == self._channel_start:
|
|
1161
|
-
channel_end =
|
|
1162
|
-
(i for i in range(1, len(ids)) if ids[i] == self._channel_end),
|
|
1163
|
-
-1,
|
|
1164
|
-
)
|
|
1170
|
+
channel_end = thought_end(1)
|
|
1165
1171
|
thought_start = 1
|
|
1166
1172
|
if ids[thought_start : thought_start + len(self._thought_prefix)] == (
|
|
1167
1173
|
self._thought_prefix
|
|
@@ -1176,20 +1182,17 @@ class Gemma4Renderer:
|
|
|
1176
1182
|
reasoning_complete=False,
|
|
1177
1183
|
)
|
|
1178
1184
|
reasoning = self._decode(ids[thought_start:channel_end]).strip()
|
|
1179
|
-
cursor = channel_end +
|
|
1185
|
+
cursor = channel_end + int(ids[channel_end] == self._channel_end)
|
|
1180
1186
|
elif prefilled_thinking:
|
|
1181
1187
|
# After a tool response, the canonical generation prompt already
|
|
1182
1188
|
# ends with ``<|channel>thought\n``. The sampled completion therefore
|
|
1183
1189
|
# starts with the thought body and contains only the closing
|
|
1184
1190
|
# ``<channel|>`` marker. The supplied prompt identifies this
|
|
1185
1191
|
# continuation, including when it truncates before the closer.
|
|
1186
|
-
channel_end =
|
|
1187
|
-
(i for i, token_id in enumerate(ids) if token_id == self._channel_end),
|
|
1188
|
-
-1,
|
|
1189
|
-
)
|
|
1192
|
+
channel_end = thought_end(0)
|
|
1190
1193
|
if channel_end != -1:
|
|
1191
1194
|
reasoning = self._decode(ids[:channel_end]).strip()
|
|
1192
|
-
cursor = channel_end +
|
|
1195
|
+
cursor = channel_end + int(ids[channel_end] == self._channel_end)
|
|
1193
1196
|
else:
|
|
1194
1197
|
return ParsedResponse(
|
|
1195
1198
|
content="",
|
|
@@ -1294,6 +1297,7 @@ class Gemma4Renderer:
|
|
|
1294
1297
|
prompt_ids=previous_prompt_ids,
|
|
1295
1298
|
stop_ids=set(self.get_stop_token_ids()),
|
|
1296
1299
|
tool_start_id=self._tool_call_start,
|
|
1300
|
+
tool_start_closes_reasoning=True,
|
|
1297
1301
|
initial_only=False,
|
|
1298
1302
|
open_id=self._channel_start,
|
|
1299
1303
|
close_id=self._channel_end,
|
|
@@ -831,7 +831,12 @@ class InklingRenderer:
|
|
|
831
831
|
segment = tail[segment_start:]
|
|
832
832
|
if segment and segment[0] == self._message_model:
|
|
833
833
|
segment = segment[1:]
|
|
834
|
-
|
|
834
|
+
# A tool-call opener ends an unclosed thinking segment (parse_inkling).
|
|
835
|
+
if (
|
|
836
|
+
segment
|
|
837
|
+
and segment[0] == self._content_thinking
|
|
838
|
+
and self._content_invoke_tool_json not in segment
|
|
839
|
+
):
|
|
835
840
|
if completion_end != len(previous_completion_ids):
|
|
836
841
|
return None
|
|
837
842
|
previous_completion_ids = [*previous_completion_ids, self._end_message]
|
|
@@ -378,6 +378,7 @@ class KimiK2Renderer:
|
|
|
378
378
|
prompt_ids=previous_prompt_ids,
|
|
379
379
|
stop_ids=set(self.get_stop_token_ids()),
|
|
380
380
|
tool_start_id=self._tool_calls_section_begin,
|
|
381
|
+
tool_start_closes_reasoning=True,
|
|
381
382
|
assistant_prefix="<|im_assistant|>assistant<|im_middle|>",
|
|
382
383
|
)
|
|
383
384
|
if boundary.is_open:
|
|
@@ -47,6 +47,7 @@ from renderers.base import (
|
|
|
47
47
|
)
|
|
48
48
|
from renderers.configs import KimiK25RendererConfig
|
|
49
49
|
from renderers.parsing import (
|
|
50
|
+
_find,
|
|
50
51
|
_reasoning_end_token_index,
|
|
51
52
|
parse_kimi_k2_section,
|
|
52
53
|
)
|
|
@@ -461,6 +462,7 @@ def _parse_kimi_k2_response(
|
|
|
461
462
|
tokenizer,
|
|
462
463
|
ids,
|
|
463
464
|
tool_start_id=tool_calls_section_begin_id,
|
|
465
|
+
tool_start_closes_reasoning=True,
|
|
464
466
|
prefilled=prefilled_thinking,
|
|
465
467
|
assistant_prefix="<|im_assistant|>assistant<|im_middle|>",
|
|
466
468
|
)
|
|
@@ -474,9 +476,13 @@ def _parse_kimi_k2_response(
|
|
|
474
476
|
# parse_qwen3). K2.5 renders </think> as text, so locate the boundary by
|
|
475
477
|
# decoding; the section scan then starts past it. content_ids still begins
|
|
476
478
|
# at 0, so the </think> text-split below recovers reasoning unchanged.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
479
|
+
# Without </think>, the section opener itself ends reasoning.
|
|
480
|
+
if boundary.closed_by_tool:
|
|
481
|
+
reasoning_end = _find(ids, tool_calls_section_begin_id)
|
|
482
|
+
elif boundary.text is not None:
|
|
483
|
+
reasoning_end = _reasoning_end_token_index(tokenizer, ids)
|
|
484
|
+
else:
|
|
485
|
+
reasoning_end = 0
|
|
480
486
|
|
|
481
487
|
# Token-ID path — produces spans. Only run if every relevant special
|
|
482
488
|
# token resolved at init (i.e. is in the tokenizer's vocab).
|
|
@@ -499,6 +505,8 @@ def _parse_kimi_k2_response(
|
|
|
499
505
|
tool_call_end_id=tool_call_end_id,
|
|
500
506
|
scan_start=reasoning_end,
|
|
501
507
|
)
|
|
508
|
+
if boundary.closed_by_tool:
|
|
509
|
+
content_ids = content_ids[reasoning_end:]
|
|
502
510
|
text = (
|
|
503
511
|
tokenizer.decode(content_ids, skip_special_tokens=False)
|
|
504
512
|
if content_ids
|
|
@@ -1030,6 +1038,7 @@ class KimiK25Renderer:
|
|
|
1030
1038
|
prompt_ids=previous_prompt_ids,
|
|
1031
1039
|
stop_ids=set(self.get_stop_token_ids()),
|
|
1032
1040
|
tool_start_id=self._tool_calls_section_begin,
|
|
1041
|
+
tool_start_closes_reasoning=True,
|
|
1033
1042
|
assistant_prefix="<|im_assistant|>assistant<|im_middle|>",
|
|
1034
1043
|
)
|
|
1035
1044
|
if boundary.is_open:
|
|
@@ -568,6 +568,7 @@ class Nemotron3Renderer:
|
|
|
568
568
|
prompt_ids=previous_prompt_ids,
|
|
569
569
|
stop_ids=set(self.get_stop_token_ids()),
|
|
570
570
|
tool_start_id=self._tool_call,
|
|
571
|
+
tool_start_closes_reasoning=True,
|
|
571
572
|
)
|
|
572
573
|
if boundary.is_open:
|
|
573
574
|
if any(t in self.get_stop_token_ids() for t in previous_completion_ids):
|
|
@@ -214,6 +214,7 @@ def parse_qwen3(
|
|
|
214
214
|
prefilled=prefilled_thinking,
|
|
215
215
|
close_id=reasoning_end_id,
|
|
216
216
|
tool_start_id=tool_call_id,
|
|
217
|
+
tool_start_closes_reasoning=True,
|
|
217
218
|
)
|
|
218
219
|
if boundary.is_open:
|
|
219
220
|
return ParsedResponse(
|
|
@@ -222,12 +223,15 @@ def parse_qwen3(
|
|
|
222
223
|
|
|
223
224
|
# Tool calls are parsed only after the initial reasoning region. An open
|
|
224
225
|
# region returned above; a closed region can contain non-executable drafts.
|
|
225
|
-
|
|
226
|
-
_find(ids,
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
226
|
+
if boundary.closed_by_tool:
|
|
227
|
+
scan_start = _find(ids, tool_call_id)
|
|
228
|
+
else:
|
|
229
|
+
reasoning_end = (
|
|
230
|
+
_find(ids, reasoning_end_id)
|
|
231
|
+
if reasoning_end_id is not None and boundary.text is not None
|
|
232
|
+
else -1
|
|
233
|
+
)
|
|
234
|
+
scan_start = reasoning_end + 1 if reasoning_end != -1 else 0
|
|
231
235
|
|
|
232
236
|
tc_start = _find(ids, tool_call_id, scan_start)
|
|
233
237
|
tool_calls: list[ParsedToolCall] = []
|
|
@@ -294,7 +298,10 @@ def parse_qwen3(
|
|
|
294
298
|
text = _decode(tokenizer, content_ids)
|
|
295
299
|
# Extract reasoning from text (Qwen3 doesn't have <think> as special token)
|
|
296
300
|
reasoning = None
|
|
297
|
-
if boundary.
|
|
301
|
+
if boundary.closed_by_tool:
|
|
302
|
+
reasoning = boundary.text.strip("\n").strip()
|
|
303
|
+
text = _decode(tokenizer, content_ids[scan_start:])
|
|
304
|
+
elif boundary.text is not None and "</think>" in text:
|
|
298
305
|
before, _, after = text.partition("</think>")
|
|
299
306
|
reasoning = boundary.text.strip("\n").strip()
|
|
300
307
|
text = after.strip("\n")
|
|
@@ -341,6 +348,7 @@ def parse_qwen35(
|
|
|
341
348
|
open_id=think_id,
|
|
342
349
|
close_id=think_end_id,
|
|
343
350
|
tool_start_id=tool_call_id,
|
|
351
|
+
tool_start_closes_reasoning=True,
|
|
344
352
|
)
|
|
345
353
|
if boundary.is_open:
|
|
346
354
|
return ParsedResponse(
|
|
@@ -351,10 +359,13 @@ def parse_qwen35(
|
|
|
351
359
|
reasoning = None
|
|
352
360
|
parse_offset = 0 # shift to map local indices back to stop-stripped ids
|
|
353
361
|
think_end = _find(ids, think_end_id)
|
|
354
|
-
if boundary.
|
|
362
|
+
if boundary.closed_by_tool:
|
|
363
|
+
reasoning = boundary.text.strip()
|
|
364
|
+
parse_offset = _find(ids, tool_call_id)
|
|
365
|
+
elif boundary.text is not None and think_end != -1:
|
|
355
366
|
reasoning = boundary.text.strip()
|
|
356
|
-
ids = ids[think_end + 1 :]
|
|
357
367
|
parse_offset = think_end + 1
|
|
368
|
+
ids = ids[parse_offset:]
|
|
358
369
|
|
|
359
370
|
tc_start = _find(ids, tool_call_id)
|
|
360
371
|
tool_calls: list[ParsedToolCall] = []
|
|
@@ -501,6 +512,7 @@ def parse_glm(
|
|
|
501
512
|
open_id=think_id,
|
|
502
513
|
close_id=think_end_id,
|
|
503
514
|
tool_start_id=tool_call_id,
|
|
515
|
+
tool_start_closes_reasoning=True,
|
|
504
516
|
assistant_prefix="<|assistant|>",
|
|
505
517
|
)
|
|
506
518
|
if boundary.is_open:
|
|
@@ -511,10 +523,13 @@ def parse_glm(
|
|
|
511
523
|
reasoning = None
|
|
512
524
|
parse_offset = 0
|
|
513
525
|
think_end = _find(ids, think_end_id)
|
|
514
|
-
if boundary.
|
|
526
|
+
if boundary.closed_by_tool:
|
|
527
|
+
reasoning = boundary.text.strip()
|
|
528
|
+
parse_offset = _find(ids, tool_call_id)
|
|
529
|
+
elif boundary.text is not None and think_end != -1:
|
|
515
530
|
reasoning = boundary.text.strip()
|
|
516
|
-
ids = ids[think_end + 1 :]
|
|
517
531
|
parse_offset = think_end + 1
|
|
532
|
+
ids = ids[parse_offset:]
|
|
518
533
|
|
|
519
534
|
tc_start = _find(ids, tool_call_id)
|
|
520
535
|
tool_calls: list[ParsedToolCall] = []
|
|
@@ -1236,6 +1251,7 @@ def parse_deepseek_v4(
|
|
|
1236
1251
|
open_id=think_start_id,
|
|
1237
1252
|
close_id=think_end_id,
|
|
1238
1253
|
tool_start_id=dsml_id,
|
|
1254
|
+
tool_start_closes_reasoning=True,
|
|
1239
1255
|
assistant_prefix="<|Assistant|>",
|
|
1240
1256
|
)
|
|
1241
1257
|
if boundary.is_open:
|
|
@@ -1246,14 +1262,25 @@ def parse_deepseek_v4(
|
|
|
1246
1262
|
reasoning: str | None = None
|
|
1247
1263
|
content_offset = 0
|
|
1248
1264
|
think_end = _find(ids, think_end_id)
|
|
1249
|
-
if boundary.
|
|
1265
|
+
if boundary.closed_by_tool:
|
|
1266
|
+
content_offset = int(bool(ids) and ids[0] == think_start_id)
|
|
1267
|
+
elif boundary.text is not None and think_end != -1:
|
|
1250
1268
|
reasoning = boundary.text
|
|
1251
1269
|
content_offset = think_end + 1
|
|
1252
1270
|
|
|
1253
1271
|
content_ids = ids[content_offset:]
|
|
1254
1272
|
decoded = _decode(tokenizer, content_ids)
|
|
1255
|
-
|
|
1256
|
-
|
|
1273
|
+
if boundary.closed_by_tool:
|
|
1274
|
+
# Reasoning ran into DSML markup without </think>. The section opener
|
|
1275
|
+
# is text around the atomic |DSML| token, so split before its tag
|
|
1276
|
+
# and the canonical blank line.
|
|
1277
|
+
prefix = _decode(tokenizer, content_ids[: _find(content_ids, dsml_id)])
|
|
1278
|
+
section_pos = len(prefix.removesuffix("<"))
|
|
1279
|
+
if decoded[:section_pos].endswith("\n\n"):
|
|
1280
|
+
section_pos -= 2
|
|
1281
|
+
reasoning = decoded[:section_pos]
|
|
1282
|
+
else:
|
|
1283
|
+
section_pos = decoded.find("\n\n<|DSML|tool_calls>")
|
|
1257
1284
|
if section_pos == -1 or dsml_id not in content_ids:
|
|
1258
1285
|
return ParsedResponse(
|
|
1259
1286
|
content=decoded,
|
|
@@ -1261,7 +1288,7 @@ def parse_deepseek_v4(
|
|
|
1261
1288
|
tool_calls=[],
|
|
1262
1289
|
)
|
|
1263
1290
|
|
|
1264
|
-
content = decoded[:section_pos]
|
|
1291
|
+
content = "" if boundary.closed_by_tool else decoded[:section_pos]
|
|
1265
1292
|
section_text = decoded[section_pos:]
|
|
1266
1293
|
section_token_offset = content_offset + _decoded_char_to_token_index(
|
|
1267
1294
|
tokenizer,
|
|
@@ -1431,6 +1458,7 @@ def parse_minimax(
|
|
|
1431
1458
|
open_id=think_id,
|
|
1432
1459
|
close_id=think_end_id,
|
|
1433
1460
|
tool_start_id=tool_call_id,
|
|
1461
|
+
tool_start_closes_reasoning=True,
|
|
1434
1462
|
assistant_prefix="]~b]ai\n",
|
|
1435
1463
|
)
|
|
1436
1464
|
if boundary.is_open:
|
|
@@ -1442,10 +1470,13 @@ def parse_minimax(
|
|
|
1442
1470
|
reasoning = None
|
|
1443
1471
|
parse_offset = 0
|
|
1444
1472
|
think_end = _find(ids, think_end_id)
|
|
1445
|
-
if boundary.
|
|
1473
|
+
if boundary.closed_by_tool:
|
|
1474
|
+
reasoning = boundary.text.strip()
|
|
1475
|
+
parse_offset = _find(ids, tool_call_id)
|
|
1476
|
+
elif boundary.text is not None and think_end != -1:
|
|
1446
1477
|
reasoning = boundary.text.strip()
|
|
1447
|
-
ids = ids[think_end + 1 :]
|
|
1448
1478
|
parse_offset = think_end + 1
|
|
1479
|
+
ids = ids[parse_offset:]
|
|
1449
1480
|
|
|
1450
1481
|
tc_start = _find(ids, tool_call_id)
|
|
1451
1482
|
tool_calls: list[ParsedToolCall] = []
|
|
@@ -1609,6 +1640,7 @@ def parse_kimi_k2(
|
|
|
1609
1640
|
ids,
|
|
1610
1641
|
prefilled=prefilled_thinking,
|
|
1611
1642
|
tool_start_id=tool_calls_section_begin_id,
|
|
1643
|
+
tool_start_closes_reasoning=True,
|
|
1612
1644
|
assistant_prefix="<|im_assistant|>assistant<|im_middle|>",
|
|
1613
1645
|
)
|
|
1614
1646
|
if boundary.is_open:
|
|
@@ -1616,6 +1648,12 @@ def parse_kimi_k2(
|
|
|
1616
1648
|
content="", reasoning_content=boundary.text, reasoning_complete=False
|
|
1617
1649
|
)
|
|
1618
1650
|
|
|
1651
|
+
if boundary.closed_by_tool:
|
|
1652
|
+
scan_start = _find(ids, tool_calls_section_begin_id)
|
|
1653
|
+
elif boundary.text is not None:
|
|
1654
|
+
scan_start = _reasoning_end_token_index(tokenizer, ids)
|
|
1655
|
+
else:
|
|
1656
|
+
scan_start = 0
|
|
1619
1657
|
content_ids, tool_calls = parse_kimi_k2_section(
|
|
1620
1658
|
tokenizer,
|
|
1621
1659
|
ids,
|
|
@@ -1624,14 +1662,15 @@ def parse_kimi_k2(
|
|
|
1624
1662
|
tool_call_begin_id=tool_call_begin_id,
|
|
1625
1663
|
tool_call_argument_begin_id=tool_call_argument_begin_id,
|
|
1626
1664
|
tool_call_end_id=tool_call_end_id,
|
|
1627
|
-
scan_start=
|
|
1628
|
-
if boundary.text is not None
|
|
1629
|
-
else 0,
|
|
1665
|
+
scan_start=scan_start,
|
|
1630
1666
|
)
|
|
1631
1667
|
|
|
1632
1668
|
text = _decode(tokenizer, content_ids)
|
|
1633
1669
|
reasoning: str | None = None
|
|
1634
|
-
if boundary.
|
|
1670
|
+
if boundary.closed_by_tool:
|
|
1671
|
+
reasoning = boundary.text.strip("\n").strip() or None
|
|
1672
|
+
text = _decode(tokenizer, content_ids[scan_start:])
|
|
1673
|
+
elif boundary.text is not None and "</think>" in text:
|
|
1635
1674
|
before, _, after = text.partition("</think>")
|
|
1636
1675
|
raw_think = boundary.text
|
|
1637
1676
|
reasoning = raw_think.strip("\n").strip() or None
|
|
@@ -2015,6 +2054,27 @@ def parse_inkling(
|
|
|
2015
2054
|
pass
|
|
2016
2055
|
elif body[0] == content_thinking_id or (pos == 0 and prefilled_thinking):
|
|
2017
2056
|
reasoning_ids = body[1:] if body[0] == content_thinking_id else body
|
|
2057
|
+
# A tool-call opener ends the thinking segment (as in vLLM); the
|
|
2058
|
+
# rest of the segment is the call's payload. Thinking and tool
|
|
2059
|
+
# segments share <|end_message|>, so it cannot arbitrate here.
|
|
2060
|
+
invoke = _find(reasoning_ids, invoke_json_id)
|
|
2061
|
+
if invoke != -1:
|
|
2062
|
+
reasoning_parts.append(_decode(tokenizer, reasoning_ids[:invoke]))
|
|
2063
|
+
tool_calls.append(
|
|
2064
|
+
_build_inkling_tool_call(
|
|
2065
|
+
name="",
|
|
2066
|
+
payload=_decode(tokenizer, reasoning_ids[invoke + 1 :]),
|
|
2067
|
+
token_span=(
|
|
2068
|
+
seg_end - len(reasoning_ids) + invoke,
|
|
2069
|
+
seg_end + 1 if terminated else n,
|
|
2070
|
+
),
|
|
2071
|
+
terminated=terminated,
|
|
2072
|
+
)
|
|
2073
|
+
)
|
|
2074
|
+
if not terminated:
|
|
2075
|
+
break
|
|
2076
|
+
pos = em + 1
|
|
2077
|
+
continue
|
|
2018
2078
|
reasoning_parts.append(_decode(tokenizer, reasoning_ids))
|
|
2019
2079
|
if not terminated:
|
|
2020
2080
|
return ParsedResponse(
|
|
@@ -660,6 +660,7 @@ class PrimeQwen3Renderer:
|
|
|
660
660
|
prompt_ids=previous_prompt_ids,
|
|
661
661
|
stop_ids=set(self.get_stop_token_ids()),
|
|
662
662
|
tool_start_id=self._tool_call,
|
|
663
|
+
tool_start_closes_reasoning=True,
|
|
663
664
|
)
|
|
664
665
|
if boundary.is_open:
|
|
665
666
|
if any(t in self.get_stop_token_ids() for t in previous_completion_ids):
|
|
@@ -93,9 +93,16 @@ class Qwen3Renderer:
|
|
|
93
93
|
return self._tokenizer.encode(text, add_special_tokens=False)
|
|
94
94
|
|
|
95
95
|
@staticmethod
|
|
96
|
-
def
|
|
96
|
+
def _content_text(content) -> str:
|
|
97
|
+
"""Return the text parts of a message in a renderer-safe form.
|
|
98
|
+
|
|
99
|
+
Qwen3's native template is text-only, but text-only messages can also
|
|
100
|
+
use OpenAI-style content lists: ``[{"type": "text", "text": ...}]``.
|
|
101
|
+
Treat both representations identically instead of silently dropping
|
|
102
|
+
the message body.
|
|
103
|
+
"""
|
|
97
104
|
if isinstance(content, str):
|
|
98
|
-
return content
|
|
105
|
+
return content
|
|
99
106
|
if isinstance(content, list):
|
|
100
107
|
parts: list[str] = []
|
|
101
108
|
for item in content:
|
|
@@ -103,9 +110,13 @@ class Qwen3Renderer:
|
|
|
103
110
|
parts.append(item)
|
|
104
111
|
elif isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
105
112
|
parts.append(item["text"])
|
|
106
|
-
return "".join(parts)
|
|
113
|
+
return "".join(parts)
|
|
107
114
|
return ""
|
|
108
115
|
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _query_boundary_text(content) -> str:
|
|
118
|
+
return Qwen3Renderer._content_text(content).strip()
|
|
119
|
+
|
|
109
120
|
@staticmethod
|
|
110
121
|
def _is_user_query_message(msg: Message) -> bool:
|
|
111
122
|
if msg.get("role") != "user":
|
|
@@ -186,7 +197,7 @@ class Qwen3Renderer:
|
|
|
186
197
|
# JSON as message body.
|
|
187
198
|
segments: list[tuple[str, bool]] = [("system\n", False)]
|
|
188
199
|
if first_is_system:
|
|
189
|
-
sys_content = messages[0].get("content")
|
|
200
|
+
sys_content = self._content_text(messages[0].get("content"))
|
|
190
201
|
if sys_content:
|
|
191
202
|
segments.append((sys_content, True))
|
|
192
203
|
segments.append(("\n\n", False))
|
|
@@ -199,7 +210,7 @@ class Qwen3Renderer:
|
|
|
199
210
|
emit_text("\n", sys_idx, is_sampled=False, is_content=False)
|
|
200
211
|
elif first_is_system:
|
|
201
212
|
emit_special(self._im_start, 0, is_sampled=False, is_content=False)
|
|
202
|
-
sys_content = messages[0].get("content")
|
|
213
|
+
sys_content = self._content_text(messages[0].get("content"))
|
|
203
214
|
sys_segments: list[tuple[str, bool]] = [("system\n", False)]
|
|
204
215
|
if sys_content:
|
|
205
216
|
sys_segments.append((sys_content, True))
|
|
@@ -214,7 +225,7 @@ class Qwen3Renderer:
|
|
|
214
225
|
num_messages = len(messages)
|
|
215
226
|
for i, msg in enumerate(messages):
|
|
216
227
|
role = msg["role"]
|
|
217
|
-
content =
|
|
228
|
+
content = self._content_text(msg.get("content"))
|
|
218
229
|
|
|
219
230
|
if role == "system":
|
|
220
231
|
if i == 0:
|
|
@@ -334,6 +345,7 @@ class Qwen3Renderer:
|
|
|
334
345
|
prompt_ids=previous_prompt_ids,
|
|
335
346
|
stop_ids=set(self.get_stop_token_ids()),
|
|
336
347
|
tool_start_id=self._tool_call,
|
|
348
|
+
tool_start_closes_reasoning=True,
|
|
337
349
|
)
|
|
338
350
|
if boundary.is_open:
|
|
339
351
|
if any(t in self.get_stop_token_ids() for t in previous_completion_ids):
|
|
@@ -415,7 +427,7 @@ class Qwen3Renderer:
|
|
|
415
427
|
|
|
416
428
|
for i, msg in enumerate(new_messages):
|
|
417
429
|
role = msg.get("role")
|
|
418
|
-
content =
|
|
430
|
+
content = self._content_text(msg.get("content"))
|
|
419
431
|
if role == "user":
|
|
420
432
|
emit_special(self._im_start, i)
|
|
421
433
|
user_segments: list[tuple[str, bool]] = [("user\n", False)]
|
|
@@ -716,6 +716,7 @@ class Qwen35Renderer:
|
|
|
716
716
|
prompt_ids=previous_prompt_ids,
|
|
717
717
|
stop_ids=set(self.get_stop_token_ids()),
|
|
718
718
|
tool_start_id=self._tool_call,
|
|
719
|
+
tool_start_closes_reasoning=True,
|
|
719
720
|
)
|
|
720
721
|
if boundary.is_open:
|
|
721
722
|
if any(t in self.get_stop_token_ids() for t in previous_completion_ids):
|
|
@@ -706,6 +706,7 @@ class Qwen3VLRenderer:
|
|
|
706
706
|
prompt_ids=previous_prompt_ids,
|
|
707
707
|
stop_ids=set(self.get_stop_token_ids()),
|
|
708
708
|
tool_start_id=self._tool_call,
|
|
709
|
+
tool_start_closes_reasoning=True,
|
|
709
710
|
)
|
|
710
711
|
if boundary.is_open:
|
|
711
712
|
if any(t in self.get_stop_token_ids() for t in previous_completion_ids):
|
|
@@ -5,10 +5,15 @@ from dataclasses import dataclass
|
|
|
5
5
|
|
|
6
6
|
@dataclass(frozen=True)
|
|
7
7
|
class ReasoningBoundary:
|
|
8
|
-
"""Reasoning state and text; None means no reasoning region was opened.
|
|
8
|
+
"""Reasoning state and text; None means no reasoning region was opened.
|
|
9
|
+
|
|
10
|
+
``closed_by_tool`` marks a region ended by a tool-call opener rather than
|
|
11
|
+
the format's close marker; the tool call then starts the content.
|
|
12
|
+
"""
|
|
9
13
|
|
|
10
14
|
is_open: bool
|
|
11
15
|
text: str | None
|
|
16
|
+
closed_by_tool: bool = False
|
|
12
17
|
|
|
13
18
|
|
|
14
19
|
def _decode(tokenizer, ids: list[int]) -> str:
|
|
@@ -87,6 +92,7 @@ def scan_reasoning(
|
|
|
87
92
|
open_id: int | None = None,
|
|
88
93
|
close_id: int | None = None,
|
|
89
94
|
tool_start_id: int | None = None,
|
|
95
|
+
tool_start_closes_reasoning: bool = False,
|
|
90
96
|
prompt_ids: list[int] | None = None,
|
|
91
97
|
stop_ids: set[int] | frozenset[int] = frozenset(),
|
|
92
98
|
open_marker: str = "<think>",
|
|
@@ -98,6 +104,10 @@ def scan_reasoning(
|
|
|
98
104
|
body, or continue reasoning opened in the prompt. A first close permanently
|
|
99
105
|
switches to content. Atomic delimiters match token IDs, not lookalike text.
|
|
100
106
|
Explicit channel formats can opt into channel transitions instead.
|
|
107
|
+
|
|
108
|
+
``tool_start_closes_reasoning`` lets an atomic tool-call opener end a
|
|
109
|
+
region that has no close marker, as vLLM's parsers do for these formats.
|
|
110
|
+
An explicit close still wins: tool markup before it stays reasoning.
|
|
101
111
|
"""
|
|
102
112
|
if prompt_ids is not None:
|
|
103
113
|
prefilled = prompt_ends_in_reasoning(
|
|
@@ -128,9 +138,16 @@ def scan_reasoning(
|
|
|
128
138
|
return ReasoningBoundary(False, None)
|
|
129
139
|
start = int(explicit)
|
|
130
140
|
close = next((i for i in range(start, len(ids)) if ids[i] == close_id), -1)
|
|
141
|
+
by_tool = False
|
|
142
|
+
if close == -1 and tool_start_closes_reasoning:
|
|
143
|
+
close = next(
|
|
144
|
+
(i for i in range(start, len(ids)) if ids[i] == tool_start_id), -1
|
|
145
|
+
)
|
|
146
|
+
by_tool = close != -1
|
|
131
147
|
return ReasoningBoundary(
|
|
132
148
|
close == -1,
|
|
133
149
|
_decode(tokenizer, ids[start : close if close != -1 else len(ids)]),
|
|
150
|
+
by_tool,
|
|
134
151
|
)
|
|
135
152
|
text = _decode(tokenizer, ids)
|
|
136
153
|
explicit = text.startswith(open_marker)
|
|
@@ -138,8 +155,14 @@ def scan_reasoning(
|
|
|
138
155
|
return ReasoningBoundary(False, None)
|
|
139
156
|
start = len(open_marker) if explicit else 0
|
|
140
157
|
close = text.find(close_marker, start)
|
|
158
|
+
by_tool = False
|
|
159
|
+
if close == -1 and tool_start_closes_reasoning:
|
|
160
|
+
tool = next((i for i, t in enumerate(ids) if t == tool_start_id), -1)
|
|
161
|
+
tool_pos = len(_decode(tokenizer, ids[:tool])) if tool != -1 else -1
|
|
162
|
+
if tool_pos >= start:
|
|
163
|
+
close, by_tool = tool_pos, True
|
|
141
164
|
return ReasoningBoundary(
|
|
142
|
-
close == -1, text[start : close if close != -1 else len(text)]
|
|
165
|
+
close == -1, text[start : close if close != -1 else len(text)], by_tool
|
|
143
166
|
)
|
|
144
167
|
|
|
145
168
|
events: list[tuple[int, int, bool | None]]
|
|
@@ -176,11 +199,21 @@ def scan_reasoning(
|
|
|
176
199
|
|
|
177
200
|
active = prefilled
|
|
178
201
|
start = 0
|
|
202
|
+
by_tool = False
|
|
179
203
|
closed_regions: list[tuple[int, int]] = []
|
|
180
|
-
|
|
204
|
+
events.sort()
|
|
205
|
+
for n, (pos, after, opening) in enumerate(events):
|
|
181
206
|
if opening is None:
|
|
182
207
|
if not active:
|
|
183
208
|
break
|
|
209
|
+
# While a region is active, its next close ends it; without one,
|
|
210
|
+
# the tool opener does.
|
|
211
|
+
if tool_start_closes_reasoning and all(
|
|
212
|
+
o is not False for _, _, o in events[n + 1 :]
|
|
213
|
+
):
|
|
214
|
+
closed_regions.append((start, pos))
|
|
215
|
+
active = False
|
|
216
|
+
by_tool = True
|
|
184
217
|
continue
|
|
185
218
|
if opening:
|
|
186
219
|
if not active:
|
|
@@ -196,4 +229,5 @@ def scan_reasoning(
|
|
|
196
229
|
return ReasoningBoundary(
|
|
197
230
|
is_open=active,
|
|
198
231
|
text="".join(decode_region(a, b) for a, b in closed_regions),
|
|
232
|
+
closed_by_tool=by_tool,
|
|
199
233
|
)
|
|
@@ -205,6 +205,24 @@ def test_bridge_extension_includes_new_message_text(
|
|
|
205
205
|
)
|
|
206
206
|
|
|
207
207
|
|
|
208
|
+
def test_qwen3_renders_openai_text_content_parts(br_renderer, br_tokenizer, br_model):
|
|
209
|
+
"""Text content lists must not become an empty user turn."""
|
|
210
|
+
if br_model not in {"Qwen/Qwen3-8B", "Qwen/Qwen3-4B"}:
|
|
211
|
+
pytest.skip(f"{br_model}: not a Qwen3 text renderer")
|
|
212
|
+
|
|
213
|
+
text = "CONTENT_LIST_SENTINEL_XYZ"
|
|
214
|
+
string_ids = br_renderer.render_ids(
|
|
215
|
+
[{"role": "user", "content": text}], add_generation_prompt=True
|
|
216
|
+
)
|
|
217
|
+
list_ids = br_renderer.render_ids(
|
|
218
|
+
[{"role": "user", "content": [{"type": "text", "text": text}]}],
|
|
219
|
+
add_generation_prompt=True,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
assert list_ids == string_ids
|
|
223
|
+
assert text in br_tokenizer.decode(list_ids, skip_special_tokens=False)
|
|
224
|
+
|
|
225
|
+
|
|
208
226
|
def test_bridge_declines_across_user_query_when_template_drops_thinking():
|
|
209
227
|
"""Qwen3's template drops a past block's thinking once a new user turn
|
|
210
228
|
arrives. The resolved ``tool_cycle`` bridge policy therefore treats a
|
|
@@ -6,7 +6,7 @@ import pytest
|
|
|
6
6
|
from parity import MODEL_CATALOG
|
|
7
7
|
|
|
8
8
|
from renderers import create_renderer
|
|
9
|
-
from renderers.base import load_tokenizer
|
|
9
|
+
from renderers.base import ToolCallParseStatus, load_tokenizer
|
|
10
10
|
from renderers.configs import config_from_name
|
|
11
11
|
|
|
12
12
|
|
|
@@ -157,15 +157,55 @@ def test_gemma_post_tool_prompt_supplies_reasoning_state():
|
|
|
157
157
|
assert result.reasoning_complete is False
|
|
158
158
|
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
# Formats whose vLLM 0.26 parser ends unclosed reasoning at a tool-call opener,
|
|
161
|
+
# with a well-formed call to ``danger``. The rest keep reasoning open.
|
|
162
|
+
_QWEN_XML_CALL = "<tool_call>\n<function=danger>\n</function>\n</tool_call>"
|
|
163
|
+
_GLM_CALL = "<tool_call>danger</tool_call>"
|
|
164
|
+
_KIMI_CALL = "<|tool_calls_section_begin|><|tool_call_begin|>functions.danger:0<|tool_call_argument_begin|>{}<|tool_call_end|><|tool_calls_section_end|>"
|
|
165
|
+
_TOOL_CALLS = {
|
|
166
|
+
"qwen3": '<tool_call>\n{"name": "danger", "arguments": {}}\n</tool_call>',
|
|
167
|
+
"qwen3-vl": '<tool_call>\n{"name": "danger", "arguments": {}}\n</tool_call>',
|
|
168
|
+
"qwen3.5": _QWEN_XML_CALL,
|
|
169
|
+
"qwen3.6": _QWEN_XML_CALL,
|
|
170
|
+
"qwen3.8": _QWEN_XML_CALL,
|
|
171
|
+
"prime-qwen3": _QWEN_XML_CALL,
|
|
172
|
+
"nemotron-3": _QWEN_XML_CALL,
|
|
173
|
+
"nemotron-3-ultra": _QWEN_XML_CALL,
|
|
174
|
+
"nemotron-3.5": _QWEN_XML_CALL,
|
|
175
|
+
"glm-4.5": _GLM_CALL,
|
|
176
|
+
"glm-5": _GLM_CALL,
|
|
177
|
+
"glm-5.1": _GLM_CALL,
|
|
178
|
+
"glm-5.3": _GLM_CALL,
|
|
179
|
+
"minimax-m2": '<minimax:tool_call>\n<invoke name="danger">\n</invoke>\n</minimax:tool_call>',
|
|
180
|
+
"deepseek-v4": '\n\n<|DSML|tool_calls>\n<|DSML|invoke name="danger">\n</|DSML|invoke>\n</|DSML|tool_calls>',
|
|
181
|
+
"kimi-k2": _KIMI_CALL,
|
|
182
|
+
"kimi-k2.5": _KIMI_CALL,
|
|
183
|
+
"gemma4": "<|tool_call>call:danger{}<tool_call|>",
|
|
184
|
+
"inkling": '<|content_invoke_tool_json|>{"name": "danger", "args": {}}<|end_message|>',
|
|
185
|
+
}
|
|
186
|
+
_STRICT_TOOL_OPENERS = {
|
|
187
|
+
"deepseek-r1",
|
|
188
|
+
"deepseek-v3",
|
|
189
|
+
"gpt-oss",
|
|
190
|
+
"hy3",
|
|
191
|
+
"laguna-m.1",
|
|
192
|
+
"laguna-s-2.1",
|
|
193
|
+
"laguna-xs-2.1",
|
|
194
|
+
"laguna-xs.2",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_every_reasoning_renderer_classifies_tool_openers():
|
|
199
|
+
reasoning_renderers = set(_CASES) - {"llama-3", "default"}
|
|
200
|
+
assert reasoning_renderers == set(_TOOL_CALLS) | _STRICT_TOOL_OPENERS
|
|
201
|
+
assert not set(_TOOL_CALLS) & _STRICT_TOOL_OPENERS
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@pytest.mark.parametrize("name", ["deepseek-r1", "laguna-xs-2.1"])
|
|
163
205
|
def test_tool_drafts_in_unfinished_reasoning_are_not_executable(name):
|
|
164
206
|
tok, renderer = _renderer(name)
|
|
165
207
|
prompt, incomplete, _ = _thinking_stream(name, tok, renderer)
|
|
166
208
|
draft = '<tool_call>{"name":"danger","arguments":{}}</tool_call>'
|
|
167
|
-
if name.startswith("kimi"):
|
|
168
|
-
draft = "<|tool_calls_section_begin|><|tool_call_begin|>functions.danger:0<|tool_call_argument_begin|>{}<|tool_call_end|><|tool_calls_section_end|>"
|
|
169
209
|
if name == "deepseek-r1":
|
|
170
210
|
draft = "<|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>danger\n```json\n{}\n```<|tool▁call▁end|><|tool▁calls▁end|>"
|
|
171
211
|
result = renderer.parse_response(
|
|
@@ -177,6 +217,74 @@ def test_tool_drafts_in_unfinished_reasoning_are_not_executable(name):
|
|
|
177
217
|
assert result.reasoning_complete is False
|
|
178
218
|
|
|
179
219
|
|
|
220
|
+
@pytest.mark.parametrize("name", sorted(_TOOL_CALLS))
|
|
221
|
+
@pytest.mark.parametrize("stop", [False, True])
|
|
222
|
+
def test_tool_opener_ends_unclosed_reasoning(name, stop):
|
|
223
|
+
tok, renderer = _renderer(name)
|
|
224
|
+
prompt, incomplete, _ = _thinking_stream(name, tok, renderer)
|
|
225
|
+
call = _encode(tok, _TOOL_CALLS[name])
|
|
226
|
+
sampled = incomplete + call + (renderer.get_stop_token_ids()[:1] if stop else [])
|
|
227
|
+
result = renderer.parse_response(sampled, prompt_ids=prompt)
|
|
228
|
+
assert result.content == ""
|
|
229
|
+
assert result.reasoning_content == "unfinished reasoning"
|
|
230
|
+
assert result.reasoning_complete is True
|
|
231
|
+
assert [(c.name, c.arguments, c.status) for c in result.tool_calls] == [
|
|
232
|
+
("danger", {}, ToolCallParseStatus.OK)
|
|
233
|
+
]
|
|
234
|
+
start, end = result.tool_calls[0].token_span
|
|
235
|
+
assert len(incomplete) <= start < end <= len(incomplete) + len(call)
|
|
236
|
+
assert "danger" in tok.decode(sampled[start:end], skip_special_tokens=False)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@pytest.mark.parametrize("name", sorted(set(_TOOL_CALLS) - {"inkling"}))
|
|
240
|
+
def test_explicit_reasoning_close_outranks_earlier_tool_opener(name):
|
|
241
|
+
# Inkling closes thinking and tool segments with the same <|end_message|>.
|
|
242
|
+
tok, renderer = _renderer(name)
|
|
243
|
+
prompt, incomplete, closed = _thinking_stream(name, tok, renderer)
|
|
244
|
+
draft = _encode(tok, _TOOL_CALLS[name])
|
|
245
|
+
result = renderer.parse_response(incomplete + draft + closed, prompt_ids=prompt)
|
|
246
|
+
assert result.content == "Answer"
|
|
247
|
+
assert "danger" in result.reasoning_content
|
|
248
|
+
assert result.tool_calls == []
|
|
249
|
+
assert result.reasoning_complete is True
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_inkling_prose_after_tool_opener_is_not_executable():
|
|
253
|
+
tok, renderer = _renderer("inkling")
|
|
254
|
+
prompt, incomplete, _ = _thinking_stream("inkling", tok, renderer)
|
|
255
|
+
draft = _encode(
|
|
256
|
+
tok, '<|content_invoke_tool_json|>{"name": "danger"} maybe not<|end_message|>'
|
|
257
|
+
)
|
|
258
|
+
result = renderer.parse_response(incomplete + draft, prompt_ids=prompt)
|
|
259
|
+
assert result.reasoning_content == "unfinished reasoning"
|
|
260
|
+
assert [c.status for c in result.tool_calls] == [ToolCallParseStatus.INVALID_JSON]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@pytest.mark.parametrize("name", sorted(_TOOL_CALLS))
|
|
264
|
+
@pytest.mark.parametrize("truncated", [False, True])
|
|
265
|
+
def test_bridge_extends_tool_call_that_ended_reasoning(name, truncated):
|
|
266
|
+
tok, renderer = _renderer(name)
|
|
267
|
+
prompt, incomplete, _ = _thinking_stream(name, tok, renderer)
|
|
268
|
+
call = _encode(tok, _TOOL_CALLS[name])
|
|
269
|
+
sampled = incomplete + (
|
|
270
|
+
call[: len(call) // 2]
|
|
271
|
+
if truncated
|
|
272
|
+
else call + renderer.get_stop_token_ids()[:1]
|
|
273
|
+
)
|
|
274
|
+
bridge = renderer.bridge_to_next_turn(
|
|
275
|
+
prompt, sampled, [{"role": "user", "content": "Next question."}]
|
|
276
|
+
)
|
|
277
|
+
assert bridge is not None
|
|
278
|
+
prefix_len = len(prompt) + len(sampled)
|
|
279
|
+
assert bridge.token_ids[:prefix_len] == prompt + sampled
|
|
280
|
+
assert not any(bridge.sampled_mask[prefix_len:])
|
|
281
|
+
close_ids = {
|
|
282
|
+
"gemma4": lambda: [renderer._channel_end],
|
|
283
|
+
"inkling": lambda: [renderer._end_message],
|
|
284
|
+
}.get(name, lambda: _encode(tok, "</think>"))()
|
|
285
|
+
assert bridge.token_ids[prefix_len : prefix_len + len(close_ids)] != close_ids
|
|
286
|
+
|
|
287
|
+
|
|
180
288
|
@pytest.mark.parametrize(
|
|
181
289
|
"name", ["qwen3", "qwen3.5", "deepseek-r1", "kimi-k2.5", "laguna-xs-2.1", "gemma4"]
|
|
182
290
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/examples/sglang/multiturn_generate_sglang.py
RENAMED
|
File without changes
|
|
File without changes
|
{renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/examples/tinker/multiturn_generate_tinker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|