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.
Files changed (88) hide show
  1. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/PKG-INFO +9 -1
  2. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/README.md +8 -0
  3. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/_version.py +2 -2
  4. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/deepseek_v4.py +1 -0
  5. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/gemma4.py +14 -10
  6. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/glm45.py +1 -0
  7. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/glm5.py +1 -0
  8. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/inkling.py +6 -1
  9. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/kimi_k2.py +1 -0
  10. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/kimi_k25.py +12 -3
  11. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/minimax_m2.py +1 -0
  12. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/nemotron3.py +1 -0
  13. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/parsing.py +81 -21
  14. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/prime_qwen3.py +1 -0
  15. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/qwen3.py +19 -7
  16. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/qwen35.py +1 -0
  17. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/qwen3_vl.py +1 -0
  18. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/reasoning.py +37 -3
  19. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_bridge.py +18 -0
  20. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_reasoning_boundaries.py +114 -6
  21. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/.github/workflows/publish-dev.yml +0 -0
  22. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/.github/workflows/publish.yml +0 -0
  23. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/.github/workflows/style.yml +0 -0
  24. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/.github/workflows/test.yml +0 -0
  25. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/.gitignore +0 -0
  26. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/.pre-commit-config.yaml +0 -0
  27. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/LICENSE +0 -0
  28. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/docs/renderer-config.md +0 -0
  29. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/examples/README.md +0 -0
  30. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/examples/sglang/multiturn_generate_sglang.py +0 -0
  31. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/examples/sglang/online_multiturn_sglang.py +0 -0
  32. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/examples/tinker/multiturn_generate_tinker.py +0 -0
  33. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/examples/transformers/multiturn_generate_transformers.py +0 -0
  34. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/examples/vllm/multiturn_generate_vllm.py +0 -0
  35. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/pyproject.toml +0 -0
  36. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/__init__.py +0 -0
  37. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/base.py +0 -0
  38. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/client.py +0 -0
  39. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/configs.py +0 -0
  40. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/deepseek_r1.py +0 -0
  41. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/deepseek_v3.py +0 -0
  42. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/default.py +0 -0
  43. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/gpt_oss.py +0 -0
  44. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/hy3.py +0 -0
  45. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/laguna_s21.py +0 -0
  46. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/laguna_xs2.py +0 -0
  47. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/llama_3.py +0 -0
  48. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/parsers.py +0 -0
  49. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/qwen36.py +0 -0
  50. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/renderers/qwen38.py +0 -0
  51. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/conftest.py +0 -0
  52. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/parity.py +0 -0
  53. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/reference_rendering.py +0 -0
  54. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_build_helpers.py +0 -0
  55. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_client.py +0 -0
  56. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_deepseek_r1.py +0 -0
  57. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_deepseek_v4.py +0 -0
  58. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_disabled_thinking_stability.py +0 -0
  59. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_gemma4.py +0 -0
  60. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_glm_tool_name_validation.py +0 -0
  61. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_hy3.py +0 -0
  62. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_incremental.py +0 -0
  63. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_inkling.py +0 -0
  64. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_is_content.py +0 -0
  65. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_kimi_k25_tool_schema.py +0 -0
  66. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_laguna_m1.py +0 -0
  67. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_laguna_xs21.py +0 -0
  68. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_llama_3.py +0 -0
  69. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_load_tokenizer.py +0 -0
  70. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_message_indices.py +0 -0
  71. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_message_tool_names.py +0 -0
  72. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_multimodal.py +0 -0
  73. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_nemotron35_vlm.py +0 -0
  74. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_nemotron3_ultra.py +0 -0
  75. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_offsetless_tokenizers.py +0 -0
  76. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_parity.py +0 -0
  77. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_parse_response.py +0 -0
  78. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_parse_response_robustness.py +0 -0
  79. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_parsers.py +0 -0
  80. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_preserve_thinking.py +0 -0
  81. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_qwen35_size_coverage.py +0 -0
  82. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_qwen38.py +0 -0
  83. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_renderer_config.py +0 -0
  84. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_roundtrip.py +0 -0
  85. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_sampled_mask.py +0 -0
  86. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_tokens_per_message.py +0 -0
  87. {renderers-0.1.12.dev8 → renderers-0.1.12.dev10}/tests/test_tool_arg_type_preservation.py +0 -0
  88. {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.dev8
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.dev8'
22
- __version_tuple__ = version_tuple = (0, 1, 12, 'dev8')
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
@@ -826,6 +826,7 @@ class DeepSeekV4Renderer:
826
826
  prompt_ids=previous_prompt_ids,
827
827
  stop_ids=set(self.get_stop_token_ids()),
828
828
  tool_start_id=self._dsml,
829
+ tool_start_closes_reasoning=True,
829
830
  assistant_prefix="<|Assistant|>",
830
831
  )
831
832
  if boundary.is_open:
@@ -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 = next(
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 + 1
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 = next(
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 + 1
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,
@@ -334,6 +334,7 @@ class GLM45Renderer:
334
334
  prompt_ids=previous_prompt_ids,
335
335
  stop_ids=set(self.get_stop_token_ids()),
336
336
  tool_start_id=self._tool_call_tok,
337
+ tool_start_closes_reasoning=True,
337
338
  assistant_prefix="<|assistant|>",
338
339
  )
339
340
  if boundary.is_open:
@@ -377,6 +377,7 @@ class GLM5Renderer:
377
377
  prompt_ids=previous_prompt_ids,
378
378
  stop_ids=set(self.get_stop_token_ids()),
379
379
  tool_start_id=self._tool_call_tok,
380
+ tool_start_closes_reasoning=True,
380
381
  assistant_prefix="<|assistant|>",
381
382
  )
382
383
  if boundary.is_open:
@@ -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
- if segment and segment[0] == self._content_thinking:
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
- reasoning_end = (
478
- _reasoning_end_token_index(tokenizer, ids) if boundary.text is not None else 0
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:
@@ -349,6 +349,7 @@ class MiniMaxM2Renderer:
349
349
  prompt_ids=previous_prompt_ids,
350
350
  stop_ids=set(self.get_stop_token_ids()),
351
351
  tool_start_id=self._tool_call_tok,
352
+ tool_start_closes_reasoning=True,
352
353
  assistant_prefix="]~b]ai\n",
353
354
  )
354
355
  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
- reasoning_end = (
226
- _find(ids, reasoning_end_id)
227
- if reasoning_end_id is not None and boundary.text is not None
228
- else -1
229
- )
230
- scan_start = reasoning_end + 1 if reasoning_end != -1 else 0
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.text is not None and "</think>" in text:
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.text is not None and think_end != -1:
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.text is not None and think_end != -1:
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.text is not None and think_end != -1:
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
- section_marker = "\n\n<|DSML|tool_calls>"
1256
- section_pos = decoded.find(section_marker)
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.text is not None and think_end != -1:
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=_reasoning_end_token_index(tokenizer, ids)
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.text is not None and "</think>" in text:
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 _query_boundary_text(content) -> str:
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.strip()
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).strip()
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") or ""
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") or ""
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 = msg.get("content") if isinstance(msg.get("content"), str) else ""
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 = msg.get("content") if isinstance(msg.get("content"), str) else ""
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
- for pos, after, opening in sorted(events):
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
- @pytest.mark.parametrize(
161
- "name", ["qwen3", "qwen3.5", "deepseek-r1", "kimi-k2", "kimi-k2.5", "laguna-xs-2.1"]
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
  )