renderers 0.1.12.dev3__tar.gz → 0.1.12.dev5__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 (87) hide show
  1. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/.github/workflows/test.yml +1 -1
  2. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/PKG-INFO +36 -6
  3. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/README.md +34 -4
  4. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/examples/sglang/multiturn_generate_sglang.py +6 -2
  5. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/examples/sglang/online_multiturn_sglang.py +4 -2
  6. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/examples/tinker/multiturn_generate_tinker.py +6 -2
  7. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/examples/transformers/multiturn_generate_transformers.py +6 -2
  8. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/examples/vllm/multiturn_generate_vllm.py +6 -2
  9. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/pyproject.toml +1 -1
  10. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/_version.py +2 -2
  11. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/base.py +14 -0
  12. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/client.py +6 -1
  13. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/deepseek_v3.py +26 -0
  14. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/deepseek_v4.py +24 -1
  15. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/default.py +52 -28
  16. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/gemma4.py +64 -11
  17. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/glm45.py +22 -0
  18. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/glm5.py +22 -0
  19. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/gpt_oss.py +61 -3
  20. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/hy3.py +28 -0
  21. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/inkling.py +41 -0
  22. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/kimi_k2.py +26 -0
  23. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/kimi_k25.py +51 -94
  24. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/laguna_xs2.py +44 -0
  25. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/llama_3.py +1 -0
  26. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/minimax_m2.py +22 -0
  27. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/nemotron3.py +21 -0
  28. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/parsers.py +4 -6
  29. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/parsing.py +234 -127
  30. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/prime_qwen3.py +21 -0
  31. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/qwen3.py +20 -0
  32. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/qwen35.py +20 -0
  33. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/qwen3_vl.py +21 -0
  34. renderers-0.1.12.dev5/renderers/reasoning.py +199 -0
  35. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_bridge.py +7 -0
  36. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_client.py +29 -2
  37. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_deepseek_v4.py +32 -4
  38. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_gemma4.py +1 -1
  39. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_glm_tool_name_validation.py +1 -1
  40. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_hy3.py +4 -1
  41. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_laguna_m1.py +1 -1
  42. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_laguna_xs21.py +36 -4
  43. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_multimodal.py +8 -2
  44. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_parse_response.py +25 -13
  45. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_parse_response_robustness.py +14 -14
  46. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_parsers.py +14 -0
  47. renderers-0.1.12.dev5/tests/test_reasoning_boundaries.py +504 -0
  48. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_roundtrip.py +7 -7
  49. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/uv.lock +271 -10
  50. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/.github/workflows/publish-dev.yml +0 -0
  51. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/.github/workflows/publish.yml +0 -0
  52. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/.github/workflows/style.yml +0 -0
  53. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/.gitignore +0 -0
  54. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/.pre-commit-config.yaml +0 -0
  55. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/LICENSE +0 -0
  56. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/docs/renderer-config.md +0 -0
  57. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/examples/README.md +0 -0
  58. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/__init__.py +0 -0
  59. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/configs.py +0 -0
  60. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/deepseek_r1.py +0 -0
  61. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/laguna_s21.py +0 -0
  62. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/qwen36.py +0 -0
  63. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/renderers/qwen38.py +0 -0
  64. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/conftest.py +0 -0
  65. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/parity.py +0 -0
  66. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/reference_rendering.py +0 -0
  67. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_build_helpers.py +0 -0
  68. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_deepseek_r1.py +0 -0
  69. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_disabled_thinking_stability.py +0 -0
  70. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_incremental.py +0 -0
  71. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_inkling.py +0 -0
  72. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_is_content.py +0 -0
  73. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_kimi_k25_tool_schema.py +0 -0
  74. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_llama_3.py +0 -0
  75. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_load_tokenizer.py +0 -0
  76. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_message_indices.py +0 -0
  77. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_message_tool_names.py +0 -0
  78. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_nemotron3_ultra.py +0 -0
  79. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_offsetless_tokenizers.py +0 -0
  80. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_parity.py +0 -0
  81. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_preserve_thinking.py +0 -0
  82. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_qwen35_size_coverage.py +0 -0
  83. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_qwen38.py +0 -0
  84. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_renderer_config.py +0 -0
  85. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_sampled_mask.py +0 -0
  86. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_tokens_per_message.py +0 -0
  87. {renderers-0.1.12.dev3 → renderers-0.1.12.dev5}/tests/test_tool_arg_type_preservation.py +0 -0
@@ -21,7 +21,7 @@ jobs:
21
21
  strategy:
22
22
  fail-fast: false
23
23
  matrix:
24
- python-version: ["3.10", "3.11", "3.12", "3.13"]
24
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
25
25
 
26
26
  steps:
27
27
  - uses: actions/checkout@v4
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: renderers
3
- Version: 0.1.12.dev3
3
+ Version: 0.1.12.dev5
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
7
- Requires-Python: <3.14,>=3.10
7
+ Requires-Python: <3.15,>=3.10
8
8
  Requires-Dist: jinja2
9
9
  Requires-Dist: numpy
10
10
  Requires-Dist: openai-harmony>=0.0.4
@@ -63,10 +63,39 @@ prompt_ids = r.render_ids(
63
63
  # Feed prompt_ids to a Token-In, Token-Out endpoint.
64
64
  # It returns completion_ids sampled by the model.
65
65
 
66
- parsed = r.parse_response(completion_ids)
66
+ parsed = r.parse_response(completion_ids, prompt_ids=prompt_ids)
67
67
  # ParsedResponse(content=..., reasoning_content=..., tool_calls=...)
68
68
  ```
69
69
 
70
+ Pass the exact sampling `prompt_ids` into `parse_response` to identify reasoning
71
+ opened by the generation prompt. Without its closing marker, the sampled text
72
+ stays in `reasoning_content`, with empty `content` and no executable tool calls,
73
+ even if the engine reports `stop`. This applies to every supported reasoning
74
+ format, including Harmony analysis and Gemma's post-tool thought channel.
75
+ Omitting `prompt_ids`, passing `None`, and passing `[]` all parse the completion
76
+ as self-contained: reasoning needs its own initial opener or channel header.
77
+ Generation settings never supply missing parsing context. If the prompt supplied
78
+ the opener, pass that prompt; `generate` does this automatically.
79
+
80
+ For reasoning-first formats, only an initial `<think>` (after any assistant
81
+ header) or reasoning already opened in the prompt starts reasoning. After its
82
+ first closing marker, the rest is content. Later think markers remain literal
83
+ content: `Before<think>example</think>After` is entirely content. Harmony,
84
+ Gemma, and Inkling follow their explicit channel or message-segment grammar.
85
+
86
+ `ParsedResponse.reasoning_complete` is `False` while a reasoning channel is open,
87
+ `True` when it is closed (or the format has no reasoning channel), and `None` when
88
+ its state cannot be determined. It does not certify tool syntax, normal engine
89
+ termination, nonempty final output, or task success. The generate client forwards
90
+ this narrow signal and passes prompt context automatically. Custom renderers used
91
+ with `generate` should accept `parse_response(..., tools=None, prompt_ids=None)`;
92
+ formats that do not need prompt context can ignore it.
93
+
94
+ Summary consumers should require normal termination (`finish_reason="stop"`),
95
+ nonempty final content, and no tool calls. A length-limited final answer remains
96
+ truncated even when its reasoning channel is closed. Keep rejected attempts in
97
+ usage/training accounting, and replace conversation history only after acceptance.
98
+
70
99
  For the next turn, extend the previous sampled stream instead of re-rendering history:
71
100
 
72
101
  ```python
@@ -99,9 +128,10 @@ class Renderer(Protocol):
99
128
  Given `(prev_prompt_ids, prev_completion_ids)` and new environment messages, return ids for the next turn's prompt such that the result starts with `prev_prompt_ids + prev_completion_ids` byte-for-byte and continues with the new messages plus the next assistant opener. If that cannot be proven safe, return `None` and the caller falls back to a full render.
100
129
 
101
130
  Each hand-coded bridge:
102
- 1. Anchors at the previous turn's canonical close token. On clean stops it's already in `prev_completion_ids`. On truncation, the renderer synthesizes the close as non-loss prompt context.
103
- 2. Refuses assistant content in `new_messages` — re-rendering sampled tokens would replace them with canonical template bytes.
104
- 3. Renders only the new messages in the framing the model family expects.
131
+ 1. Checks the previous turn's reasoning state. If reasoning is unfinished and no stop was sampled, append its closing marker as non-loss prompt context before the canonical turn close. If a stop was already sampled inside reasoning, return `None`: inserting a close before it would violate the exact-prefix contract. Parsing never repairs the sampled output.
132
+ 2. Anchors at the previous turn's canonical close token. On clean stops it's already in `prev_completion_ids`. On truncation, the renderer synthesizes the close as non-loss prompt context.
133
+ 3. Refuses assistant content in `new_messages` — re-rendering sampled tokens would replace them with canonical template bytes.
134
+ 4. Renders only the new messages in the framing the model family expects.
105
135
 
106
136
  `DefaultRenderer.bridge_to_next_turn` returns `None` unconditionally — the template's close is unknown, so the contract can't be proven.
107
137
 
@@ -43,10 +43,39 @@ prompt_ids = r.render_ids(
43
43
  # Feed prompt_ids to a Token-In, Token-Out endpoint.
44
44
  # It returns completion_ids sampled by the model.
45
45
 
46
- parsed = r.parse_response(completion_ids)
46
+ parsed = r.parse_response(completion_ids, prompt_ids=prompt_ids)
47
47
  # ParsedResponse(content=..., reasoning_content=..., tool_calls=...)
48
48
  ```
49
49
 
50
+ Pass the exact sampling `prompt_ids` into `parse_response` to identify reasoning
51
+ opened by the generation prompt. Without its closing marker, the sampled text
52
+ stays in `reasoning_content`, with empty `content` and no executable tool calls,
53
+ even if the engine reports `stop`. This applies to every supported reasoning
54
+ format, including Harmony analysis and Gemma's post-tool thought channel.
55
+ Omitting `prompt_ids`, passing `None`, and passing `[]` all parse the completion
56
+ as self-contained: reasoning needs its own initial opener or channel header.
57
+ Generation settings never supply missing parsing context. If the prompt supplied
58
+ the opener, pass that prompt; `generate` does this automatically.
59
+
60
+ For reasoning-first formats, only an initial `<think>` (after any assistant
61
+ header) or reasoning already opened in the prompt starts reasoning. After its
62
+ first closing marker, the rest is content. Later think markers remain literal
63
+ content: `Before<think>example</think>After` is entirely content. Harmony,
64
+ Gemma, and Inkling follow their explicit channel or message-segment grammar.
65
+
66
+ `ParsedResponse.reasoning_complete` is `False` while a reasoning channel is open,
67
+ `True` when it is closed (or the format has no reasoning channel), and `None` when
68
+ its state cannot be determined. It does not certify tool syntax, normal engine
69
+ termination, nonempty final output, or task success. The generate client forwards
70
+ this narrow signal and passes prompt context automatically. Custom renderers used
71
+ with `generate` should accept `parse_response(..., tools=None, prompt_ids=None)`;
72
+ formats that do not need prompt context can ignore it.
73
+
74
+ Summary consumers should require normal termination (`finish_reason="stop"`),
75
+ nonempty final content, and no tool calls. A length-limited final answer remains
76
+ truncated even when its reasoning channel is closed. Keep rejected attempts in
77
+ usage/training accounting, and replace conversation history only after acceptance.
78
+
50
79
  For the next turn, extend the previous sampled stream instead of re-rendering history:
51
80
 
52
81
  ```python
@@ -79,9 +108,10 @@ class Renderer(Protocol):
79
108
  Given `(prev_prompt_ids, prev_completion_ids)` and new environment messages, return ids for the next turn's prompt such that the result starts with `prev_prompt_ids + prev_completion_ids` byte-for-byte and continues with the new messages plus the next assistant opener. If that cannot be proven safe, return `None` and the caller falls back to a full render.
80
109
 
81
110
  Each hand-coded bridge:
82
- 1. Anchors at the previous turn's canonical close token. On clean stops it's already in `prev_completion_ids`. On truncation, the renderer synthesizes the close as non-loss prompt context.
83
- 2. Refuses assistant content in `new_messages` — re-rendering sampled tokens would replace them with canonical template bytes.
84
- 3. Renders only the new messages in the framing the model family expects.
111
+ 1. Checks the previous turn's reasoning state. If reasoning is unfinished and no stop was sampled, append its closing marker as non-loss prompt context before the canonical turn close. If a stop was already sampled inside reasoning, return `None`: inserting a close before it would violate the exact-prefix contract. Parsing never repairs the sampled output.
112
+ 2. Anchors at the previous turn's canonical close token. On clean stops it's already in `prev_completion_ids`. On truncation, the renderer synthesizes the close as non-loss prompt context.
113
+ 3. Refuses assistant content in `new_messages` — re-rendering sampled tokens would replace them with canonical template bytes.
114
+ 4. Renders only the new messages in the framing the model family expects.
85
115
 
86
116
  `DefaultRenderer.bridge_to_next_turn` returns `None` unconditionally — the template's close is unknown, so the contract can't be proven.
87
117
 
@@ -138,7 +138,7 @@ def main() -> None:
138
138
  )
139
139
  output1 = engine.generate(input_ids=prompt_ids, sampling_params=sampling)
140
140
  completion1 = completion_ids(output1, prompt_ids)
141
- parsed1 = renderer.parse_response(completion1)
141
+ parsed1 = renderer.parse_response(completion1, prompt_ids=prompt_ids)
142
142
  print_parsed(label, "turn 1", parsed1)
143
143
 
144
144
  assistant = {"role": "assistant", "content": parsed1.content}
@@ -197,7 +197,11 @@ def main() -> None:
197
197
 
198
198
  output2 = engine.generate(input_ids=bridged_ids, sampling_params=sampling)
199
199
  completion2 = completion_ids(output2, bridged_ids)
200
- print_parsed(label, "turn 2", renderer.parse_response(completion2))
200
+ print_parsed(
201
+ label,
202
+ "turn 2",
203
+ renderer.parse_response(completion2, prompt_ids=bridged_ids),
204
+ )
201
205
 
202
206
  engine.shutdown()
203
207
 
@@ -161,7 +161,7 @@ async def run_one(
161
161
  max_new_tokens=max_new_tokens,
162
162
  )
163
163
  completion1 = completion_ids(output1, prompt_ids)
164
- parsed1 = renderer.parse_response(completion1)
164
+ parsed1 = renderer.parse_response(completion1, prompt_ids=prompt_ids)
165
165
  print_parsed(label, "turn 1", parsed1)
166
166
 
167
167
  assistant: dict[str, Any] = {"role": "assistant", "content": parsed1.content}
@@ -226,7 +226,9 @@ async def run_one(
226
226
  max_new_tokens=max_new_tokens,
227
227
  )
228
228
  completion2 = completion_ids(output2, bridged_ids)
229
- print_parsed(label, "turn 2", renderer.parse_response(completion2))
229
+ print_parsed(
230
+ label, "turn 2", renderer.parse_response(completion2, prompt_ids=bridged_ids)
231
+ )
230
232
 
231
233
 
232
234
  async def main() -> None:
@@ -128,7 +128,7 @@ async def main() -> None:
128
128
  sampling_params=sampling_params,
129
129
  )
130
130
  completion1 = list(output1.sequences[0].tokens)
131
- parsed1 = renderer.parse_response(completion1)
131
+ parsed1 = renderer.parse_response(completion1, prompt_ids=prompt_ids)
132
132
  print_parsed(label, "turn 1", parsed1)
133
133
 
134
134
  assistant = {"role": "assistant", "content": parsed1.content}
@@ -191,7 +191,11 @@ async def main() -> None:
191
191
  sampling_params=sampling_params,
192
192
  )
193
193
  completion2 = list(output2.sequences[0].tokens)
194
- print_parsed(label, "turn 2", renderer.parse_response(completion2))
194
+ print_parsed(
195
+ label,
196
+ "turn 2",
197
+ renderer.parse_response(completion2, prompt_ids=bridged_ids),
198
+ )
195
199
 
196
200
 
197
201
  if __name__ == "__main__":
@@ -135,7 +135,7 @@ def main() -> None:
135
135
  pad_token_id=pad_token_id,
136
136
  )[0]
137
137
  completion1 = output1[input_ids.shape[-1] :].tolist()
138
- parsed1 = renderer.parse_response(completion1)
138
+ parsed1 = renderer.parse_response(completion1, prompt_ids=prompt_ids)
139
139
  print_parsed(label, "turn 1", parsed1)
140
140
 
141
141
  assistant = {"role": "assistant", "content": parsed1.content}
@@ -203,7 +203,11 @@ def main() -> None:
203
203
  pad_token_id=pad_token_id,
204
204
  )[0]
205
205
  completion2 = output2[bridged_input_ids.shape[-1] :].tolist()
206
- print_parsed(label, "turn 2", renderer.parse_response(completion2))
206
+ print_parsed(
207
+ label,
208
+ "turn 2",
209
+ renderer.parse_response(completion2, prompt_ids=bridged_ids),
210
+ )
207
211
 
208
212
  del hf_model
209
213
  gc.collect()
@@ -131,7 +131,7 @@ def main() -> None:
131
131
  use_tqdm=False,
132
132
  )[0]
133
133
  completion1 = list(output1.outputs[0].token_ids)
134
- parsed1 = renderer.parse_response(completion1)
134
+ parsed1 = renderer.parse_response(completion1, prompt_ids=prompt_ids)
135
135
  print_parsed(label, "turn 1", parsed1)
136
136
 
137
137
  assistant = {"role": "assistant", "content": parsed1.content}
@@ -194,7 +194,11 @@ def main() -> None:
194
194
  use_tqdm=False,
195
195
  )[0]
196
196
  completion2 = list(output2.outputs[0].token_ids)
197
- print_parsed(label, "turn 2", renderer.parse_response(completion2))
197
+ print_parsed(
198
+ label,
199
+ "turn 2",
200
+ renderer.parse_response(completion2, prompt_ids=bridged_ids),
201
+ )
198
202
 
199
203
  del llm
200
204
  gc.collect()
@@ -13,7 +13,7 @@ description = "Chat template renderers — deterministic message-to-token conver
13
13
  readme = "README.md"
14
14
  license = "Apache-2.0"
15
15
  license-files = ["LICENSE"]
16
- requires-python = ">=3.10,<3.14"
16
+ requires-python = ">=3.10,<3.15"
17
17
  dependencies = [
18
18
  "numpy",
19
19
  "openai>=1.108.1",
@@ -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.dev3'
22
- __version_tuple__ = version_tuple = (0, 1, 12, 'dev3')
21
+ __version__ = version = '0.1.12.dev5'
22
+ __version_tuple__ = version_tuple = (0, 1, 12, 'dev5')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -632,6 +632,13 @@ class ParsedResponse:
632
632
  content: str
633
633
  reasoning_content: str | None = None
634
634
  tool_calls: list[ParsedToolCall] = field(default_factory=list)
635
+ reasoning_complete: bool | None = True
636
+ """Whether the reasoning channel is closed at the end of the completion.
637
+
638
+ False means generation ended inside reasoning; this is unrelated to the
639
+ engine finish reason, tool-call validity, or whether final text is useful.
640
+ None means the parser cannot determine the state for its reasoning format.
641
+ """
635
642
 
636
643
 
637
644
  @dataclass
@@ -743,9 +750,16 @@ class Renderer(Protocol):
743
750
  token_ids: list[int],
744
751
  *,
745
752
  tools: list[ToolSpec] | None = None,
753
+ prompt_ids: list[int] | None = None,
746
754
  ) -> ParsedResponse:
747
755
  """Parse completion tokens back into a structured message.
748
756
 
757
+ Pass the exact ``prompt_ids`` used for sampling whenever available.
758
+ This identifies reasoning prefilled by the prompt, including Gemma's
759
+ post-tool thought channel and partial assistant prefixes. Omitting it,
760
+ passing None, and passing [] all parse a self-contained completion;
761
+ generation settings do not imply an initial reasoning channel.
762
+
749
763
  ``tools`` is the same list passed to ``render`` for this turn.
750
764
  XML-style formats (Qwen3.5, GLM, MiniMax, Laguna) render argument
751
765
  values verbatim inside ``<arg_value>`` tags with no quoting, so
@@ -367,7 +367,11 @@ async def generate(
367
367
 
368
368
  completion_logprobs = _parse_completion_logprobs(choice, completion_ids)
369
369
 
370
- parsed = renderer.parse_response(completion_ids, tools=tools)
370
+ parsed = renderer.parse_response(
371
+ completion_ids,
372
+ prompt_ids=list(effective_prompt_ids or prompt_ids),
373
+ tools=tools,
374
+ )
371
375
 
372
376
  routed_experts = choice.get("routed_experts")
373
377
  # vLLM's native kept-set sampling masks (``--return-sampling-mask``):
@@ -401,6 +405,7 @@ async def generate(
401
405
  "reasoning_content": parsed.reasoning_content,
402
406
  "tool_calls": parsed.tool_calls,
403
407
  "finish_reason": finish_reason,
408
+ "reasoning_complete": parsed.reasoning_complete,
404
409
  "routed_experts": routed_experts,
405
410
  "sampling_mask": sampling_mask,
406
411
  # The mm sidecar consumed on the request side, surfaced back so
@@ -14,6 +14,8 @@ from __future__ import annotations
14
14
 
15
15
  import json
16
16
 
17
+ from renderers.reasoning import scan_reasoning, prompt_ends_in_reasoning
18
+
17
19
  from renderers.base import (
18
20
  Message,
19
21
  ParsedResponse,
@@ -283,6 +285,7 @@ class DeepSeekV3Renderer:
283
285
  token_ids: list[int],
284
286
  *,
285
287
  tools: list[ToolSpec] | None = None, # noqa: ARG002 — args land in a ```json fence, schema not needed
288
+ prompt_ids: list[int] | None = None,
286
289
  ) -> ParsedResponse:
287
290
  return parse_deepseek_v3(
288
291
  self._tokenizer,
@@ -293,6 +296,12 @@ class DeepSeekV3Renderer:
293
296
  tool_call_begin_id=self._tool_call_begin,
294
297
  tool_call_end_id=self._tool_call_end,
295
298
  tool_sep_id=self._tool_sep,
299
+ prefilled_thinking=prompt_ends_in_reasoning(
300
+ self._tokenizer,
301
+ prompt_ids,
302
+ stop_ids=set(self.get_stop_token_ids()),
303
+ assistant_prefix="<|Assistant|>",
304
+ ),
296
305
  )
297
306
 
298
307
  def get_stop_token_ids(self) -> list[int]:
@@ -312,6 +321,23 @@ class DeepSeekV3Renderer:
312
321
  or reject_assistant_in_extension(new_messages)
313
322
  ):
314
323
  return None
324
+
325
+ boundary = scan_reasoning(
326
+ self._tokenizer,
327
+ previous_completion_ids,
328
+ prompt_ids=previous_prompt_ids,
329
+ stop_ids=set(self.get_stop_token_ids()),
330
+ tool_start_id=self._tool_calls_begin,
331
+ assistant_prefix="<|Assistant|>",
332
+ )
333
+ if boundary.is_open:
334
+ if any(t in self.get_stop_token_ids() for t in previous_completion_ids):
335
+ return None
336
+ previous_completion_ids = [
337
+ *previous_completion_ids,
338
+ *list(self._tokenizer.encode("</think>", add_special_tokens=False)),
339
+ ]
340
+
315
341
  if should_rerender_for_thinking_retention(
316
342
  self.effective_thinking_retention,
317
343
  new_messages,
@@ -22,6 +22,8 @@ import json
22
22
  from dataclasses import dataclass, field
23
23
  from typing import Any, Mapping
24
24
 
25
+ from renderers.reasoning import scan_reasoning, prompt_ends_in_reasoning
26
+
25
27
  from renderers.base import (
26
28
  Message,
27
29
  ParsedResponse,
@@ -783,13 +785,20 @@ class DeepSeekV4Renderer:
783
785
  token_ids: list[int],
784
786
  *,
785
787
  tools: list[ToolSpec] | None = None, # noqa: ARG002
788
+ prompt_ids: list[int] | None = None,
786
789
  ) -> ParsedResponse:
787
790
  return parse_deepseek_v4(
788
791
  self._tokenizer,
789
792
  token_ids,
790
793
  stop_ids={self._eos},
791
- thinking_enabled=self.config.enable_thinking,
794
+ thinking_enabled=prompt_ends_in_reasoning(
795
+ self._tokenizer,
796
+ prompt_ids,
797
+ stop_ids=set(self.get_stop_token_ids()),
798
+ assistant_prefix="<|Assistant|>",
799
+ ),
792
800
  think_end_id=self._think_end,
801
+ think_start_id=self._think_start,
793
802
  dsml_id=self._dsml,
794
803
  )
795
804
 
@@ -810,6 +819,20 @@ class DeepSeekV4Renderer:
810
819
  or reject_assistant_in_extension(new_messages)
811
820
  ):
812
821
  return None
822
+
823
+ boundary = scan_reasoning(
824
+ self._tokenizer,
825
+ previous_completion_ids,
826
+ prompt_ids=previous_prompt_ids,
827
+ stop_ids=set(self.get_stop_token_ids()),
828
+ tool_start_id=self._dsml,
829
+ assistant_prefix="<|Assistant|>",
830
+ )
831
+ if boundary.is_open:
832
+ if any(t in self.get_stop_token_ids() for t in previous_completion_ids):
833
+ return None
834
+ previous_completion_ids = [*previous_completion_ids, self._think_end]
835
+
813
836
  if should_rerender_for_thinking_retention(
814
837
  self.effective_thinking_retention,
815
838
  new_messages,
@@ -11,6 +11,8 @@ from __future__ import annotations
11
11
  import json
12
12
  from typing import Any
13
13
 
14
+ from renderers.reasoning import scan_reasoning, prompt_ends_in_reasoning
15
+
14
16
  from renderers.base import (
15
17
  ChatTemplateTokenizer,
16
18
  Message,
@@ -20,8 +22,14 @@ from renderers.base import (
20
22
  extract_message_tool_names,
21
23
  resolve_thinking_retention,
22
24
  )
25
+ from renderers.parsing import (
26
+ _reasoning_end_token_index,
27
+ _strip_stop_tokens,
28
+ )
29
+
23
30
  from renderers.configs import DefaultRendererConfig
24
31
  from renderers.parsers import (
32
+ ThinkTextReasoningParser,
25
33
  get_reasoning_parser,
26
34
  get_tool_parser,
27
35
  )
@@ -176,50 +184,66 @@ class DefaultRenderer:
176
184
  token_ids: list[int],
177
185
  *,
178
186
  tools: list[ToolSpec] | None = None, # noqa: ARG002 — DefaultRenderer relies on configured tool_parser, schema not consulted here
187
+ prompt_ids: list[int] | None = None,
179
188
  ) -> ParsedResponse:
189
+ ids = _strip_stop_tokens(token_ids, set(self.get_stop_token_ids()))
190
+ boundary = scan_reasoning(
191
+ self._tokenizer,
192
+ ids,
193
+ prefilled=prompt_ends_in_reasoning(
194
+ self._tokenizer,
195
+ prompt_ids,
196
+ stop_ids=set(self.get_stop_token_ids()),
197
+ ),
198
+ )
199
+ if boundary.is_open:
200
+ return ParsedResponse(
201
+ content="", reasoning_content=boundary.text, reasoning_complete=False
202
+ )
203
+ reasoning_end = (
204
+ _reasoning_end_token_index(self._tokenizer, ids)
205
+ if boundary.text is not None
206
+ else 0
207
+ )
180
208
  # 1. Extract tool calls while we still have token ids (most formats
181
209
  # use special-token delimiters, so id-level matching is reliable).
182
210
  if self._tool_parser is not None:
183
- content_ids, tool_calls = self._tool_parser.extract(list(token_ids))
211
+ content_ids, tool_calls = self._tool_parser.extract(ids[reasoning_end:])
212
+ content_ids = [*ids[:reasoning_end], *content_ids]
213
+ for tc in tool_calls:
214
+ if tc.token_span is not None:
215
+ start, end = tc.token_span
216
+ tc.token_span = (start + reasoning_end, end + reasoning_end)
184
217
  else:
185
- content_ids = list(token_ids)
218
+ content_ids = ids
186
219
  tool_calls = []
187
220
 
188
221
  # 2. Decode (keep special tokens so a downstream reasoning parser can
189
222
  # still see things like <think>/</think> when they're tokens).
190
223
  text = self._tokenizer.decode(content_ids, skip_special_tokens=False)
191
224
 
192
- # 3. Extract reasoning from the decoded text. Falls back to a built-in
193
- # <think>...</think> sniff so unconfigured users get the same behavior
194
- # as before. Preserve whitespace at the <think>/</think> boundary —
195
- # the chat template round-trips it verbatim (e.g. GLM emits
196
- # `{{ '\\n<think>' + reasoning_content + '</think>' }}` then
197
- # `{{- content }}` with no separator), so a leading `\\n` on content
198
- # or trailing `\\n` on reasoning_content must stay in the parsed
199
- # fields for re-render to be byte-identical. Stripping here causes
200
- # the re-rendered assistant message to shift by one BPE token after
201
- # `</think>`, cascading through downstream tokenization and breaking
202
- # the "extension property" in trajectory step tokenization.
203
- if self._reasoning_parser is not None:
204
- reasoning_content, text = self._reasoning_parser.extract(text)
225
+ # Use the same initial boundary for extraction and tool scanning.
226
+ # Preserve whitespace inside reasoning and after its close: templates
227
+ # may emit these fields back-to-back, so stripping shifts token offsets.
228
+ if self._reasoning_parser is None or isinstance(
229
+ self._reasoning_parser, ThinkTextReasoningParser
230
+ ):
231
+ reasoning_content = boundary.text
232
+ if boundary.text is not None:
233
+ text = text.partition("</think>")[2]
205
234
  else:
206
- reasoning_content = None
207
- if "</think>" in text:
208
- before, after = text.split("</think>", 1)
209
- if "<think>" in before:
210
- reasoning_content = before.split("<think>", 1)[-1]
211
- else:
212
- reasoning_content = before
213
- text = after
214
-
215
- # Strip any remaining special tokens from the final content (we kept
216
- # them around for the reasoning parser above).
235
+ reasoning_content, text = self._reasoning_parser.extract(text)
236
+
217
237
  text = _strip_special_tokens(self._tokenizer, text)
218
238
 
219
239
  return ParsedResponse(
220
240
  content=text,
221
241
  reasoning_content=reasoning_content if reasoning_content else None,
222
242
  tool_calls=tool_calls,
243
+ reasoning_complete=True
244
+ if isinstance(self._reasoning_parser, ThinkTextReasoningParser)
245
+ or reasoning_content is not None
246
+ else None,
223
247
  )
224
248
 
225
249
  def get_stop_token_ids(self) -> list[int]:
@@ -254,9 +278,9 @@ def _resolve_parser(value, tokenizer, factory):
254
278
 
255
279
 
256
280
  def _strip_special_tokens(tokenizer, text: str) -> str:
257
- """Remove any special-token substrings that slipped into decoded text."""
281
+ """Remove framing tokens while preserving literal think markers in content."""
258
282
  specials = getattr(tokenizer, "all_special_tokens", None) or []
259
283
  for token in specials:
260
- if token and token in text:
284
+ if token and token not in {"<think>", "</think>"} and token in text:
261
285
  text = text.replace(token, "")
262
286
  return text