renderers 0.1.12.dev18__tar.gz → 0.1.12.dev20__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 (97) hide show
  1. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/PKG-INFO +56 -1
  2. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/README.md +55 -0
  3. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/_version.py +2 -2
  4. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/base.py +23 -2
  5. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/deepseek_v3.py +5 -0
  6. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/deepseek_v4.py +19 -18
  7. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/default.py +6 -0
  8. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/gemma4.py +6 -0
  9. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/glm45.py +11 -18
  10. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/glm5.py +12 -17
  11. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/gpt_oss.py +6 -0
  12. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/hy3.py +8 -0
  13. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/inkling.py +6 -0
  14. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/kimi_k2.py +5 -0
  15. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/kimi_k25.py +6 -0
  16. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/laguna_xs2.py +5 -0
  17. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/llama_3.py +5 -0
  18. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/minimax_m2.py +6 -0
  19. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/nemotron3.py +6 -0
  20. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/parsing.py +183 -298
  21. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/prime_qwen3.py +5 -0
  22. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/qwen3.py +6 -0
  23. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/qwen35.py +6 -0
  24. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/qwen3_vl.py +5 -0
  25. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/reasoning.py +18 -7
  26. renderers-0.1.12.dev20/renderers/tagged.py +192 -0
  27. renderers-0.1.12.dev20/tests/audit_prefix_stability.py +81 -0
  28. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/conftest.py +12 -0
  29. renderers-0.1.12.dev20/tests/fixtures/prefix_stability.json +792 -0
  30. renderers-0.1.12.dev20/tests/reference_continuations.py +164 -0
  31. renderers-0.1.12.dev20/tests/replay.py +48 -0
  32. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_bridge.py +5 -0
  33. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_deepseek_r1.py +6 -0
  34. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_deepseek_v4.py +12 -7
  35. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_disabled_thinking_stability.py +5 -0
  36. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_gemma4.py +5 -0
  37. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_glm_tool_name_validation.py +5 -0
  38. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_hy3.py +5 -0
  39. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_inkling.py +5 -0
  40. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_laguna_m1.py +5 -0
  41. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_laguna_xs21.py +5 -0
  42. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_multimodal.py +5 -0
  43. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_parity.py +10 -2
  44. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_parse_response.py +11 -3
  45. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_parse_response_robustness.py +4 -5
  46. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_qwen38.py +5 -0
  47. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_reasoning_boundaries.py +26 -8
  48. renderers-0.1.12.dev20/tests/test_roundtrip.py +211 -0
  49. renderers-0.1.12.dev20/tests/test_tagged_rollout.py +258 -0
  50. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_tool_arg_type_preservation.py +5 -0
  51. renderers-0.1.12.dev18/tests/test_roundtrip.py +0 -323
  52. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/.github/workflows/publish-dev.yml +0 -0
  53. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/.github/workflows/publish.yml +0 -0
  54. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/.github/workflows/style.yml +0 -0
  55. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/.github/workflows/test.yml +0 -0
  56. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/.gitignore +0 -0
  57. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/.pre-commit-config.yaml +0 -0
  58. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/LICENSE +0 -0
  59. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/docs/renderer-config.md +0 -0
  60. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/examples/README.md +0 -0
  61. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/examples/sglang/multiturn_generate_sglang.py +0 -0
  62. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/examples/sglang/online_multiturn_sglang.py +0 -0
  63. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/examples/tinker/multiturn_generate_tinker.py +0 -0
  64. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/examples/transformers/multiturn_generate_transformers.py +0 -0
  65. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/examples/vllm/multiturn_generate_vllm.py +0 -0
  66. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/pyproject.toml +0 -0
  67. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/__init__.py +0 -0
  68. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/client.py +0 -0
  69. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/configs.py +0 -0
  70. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/custom.py +0 -0
  71. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/deepseek_r1.py +0 -0
  72. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/laguna_s21.py +0 -0
  73. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/parsers.py +0 -0
  74. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/qwen36.py +0 -0
  75. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/renderers/qwen38.py +0 -0
  76. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/parity.py +0 -0
  77. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/reference_rendering.py +0 -0
  78. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_build_helpers.py +0 -0
  79. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_client.py +0 -0
  80. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_custom.py +0 -0
  81. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_incremental.py +0 -0
  82. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_is_content.py +0 -0
  83. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_kimi_k25_tool_schema.py +0 -0
  84. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_llama_3.py +0 -0
  85. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_load_tokenizer.py +0 -0
  86. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_message_indices.py +0 -0
  87. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_message_tool_names.py +0 -0
  88. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_nemotron35_vlm.py +0 -0
  89. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_nemotron3_ultra.py +0 -0
  90. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_offsetless_tokenizers.py +0 -0
  91. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_parsers.py +0 -0
  92. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_preserve_thinking.py +0 -0
  93. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_qwen35_size_coverage.py +0 -0
  94. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_renderer_config.py +0 -0
  95. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_sampled_mask.py +0 -0
  96. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/tests/test_tokens_per_message.py +0 -0
  97. {renderers-0.1.12.dev18 → renderers-0.1.12.dev20}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: renderers
3
- Version: 0.1.12.dev18
3
+ Version: 0.1.12.dev20
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
@@ -120,6 +120,8 @@ Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.
120
120
 
121
121
  ```python
122
122
  class Renderer(Protocol):
123
+ @property
124
+ def is_prefix_stable(self) -> bool: ...
123
125
  def render(messages, *, tools=None, add_generation_prompt=False) -> RenderedTokens: ...
124
126
  def render_ids(messages, *, tools=None, add_generation_prompt=False) -> list[int]: ...
125
127
  def parse_response(token_ids) -> ParsedResponse: ...
@@ -132,6 +134,35 @@ class Renderer(Protocol):
132
134
  - `ParsedResponse` is `(content, reasoning_content, tool_calls)`. It scans token ids for special-token boundaries (e.g. id `151657` for `<tool_call>` on Qwen3) — a literal `"<tool_call>"` in user content tokenizes to ordinary text ids and never matches.
133
135
  - Round-trip: rendering `[user, assistant(content, reasoning, tool_calls)]`, slicing the assistant completion, and feeding it through `parse_response` returns an equivalent structured message. Tested per-renderer in `tests/test_roundtrip.py`.
134
136
 
137
+ ### Prefix stability
138
+
139
+ `renderer.is_prefix_stable` describes the constructed renderer after config and
140
+ `chat_template_kwargs` resolution. A `True` value guarantees that full renders
141
+ of assistant-terminated conversations stay token prefixes when more
142
+ user/tool/assistant messages are appended, with a fixed system/developer
143
+ preamble, tools, and `add_generation_prompt=False`. `False` means this is not
144
+ guaranteed; opaque `DefaultRenderer` templates report `False`.
145
+
146
+ The flag follows the template's emit rules, independently of the
147
+ `thinking_retention` bridge override. For example, GLM-5 with
148
+ `clear_thinking=False` is stable, while the default strips earlier reasoning.
149
+ Disabling thinking generation alone does not guarantee stability for datasets
150
+ that already contain reasoning. Other causes include GPT-OSS's final-turn
151
+ terminator and Nemotron's moving effort hint. DeepSeek V4 reports `False`
152
+ because its task/continuation message controls can rewrite the prefix; Inkling
153
+ reports `False` because reused tool-call IDs can change earlier tool-response
154
+ names, even though it retains reasoning.
155
+
156
+ SFT consumers can use this flag to warn that a single full-conversation render
157
+ may omit earlier reasoning. `build_training_sample` still returns one sample
158
+ per conversation, without expanding it into one sample per assistant turn.
159
+ The guarantee assumes unchanged message contents and deterministic tokenization
160
+ and media preprocessing. Stability does not guarantee that the template emits
161
+ every input field (some always omit reasoning), or that rendering reproduces sampled tokens. Continue
162
+ using `bridge_to_next_turn` for sampled trajectories. Custom renderers should
163
+ expose the same property; consumers supporting older/custom implementations
164
+ can conservatively use `getattr(renderer, "is_prefix_stable", False)`.
165
+
135
166
  ### `bridge_to_next_turn` (the core contract)
136
167
 
137
168
  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.
@@ -276,6 +307,30 @@ uv run pytest
276
307
 
277
308
  Round-trip parity (render → parse → original) and token-level parity against each model's independent reference encoder are tested per renderer. Most references use `apply_chat_template`; DeepSeek V4 uses its shipped Python encoder, and GPT-OSS uses Harmony.
278
309
 
310
+ Prefix-stability audit witnesses live in
311
+ [`tests/fixtures/prefix_stability.json`](tests/fixtures/prefix_stability.json).
312
+ Each case records a renderer/config, a conversation ending in an assistant,
313
+ messages to append, and the reason the original tokens stop being a prefix.
314
+ Shared scenarios keep the corpus small; paired stable settings guard the
315
+ configuration switches. `DefaultRenderer` is treated as unknown, without
316
+ inventing a witness for its opaque template.
317
+
318
+ This audit is opt-in and is not collected by the regular test suite or CI.
319
+ Run it manually when changing renderer emission rules or the stability
320
+ classification:
321
+
322
+ ```bash
323
+ uv run pytest tests/audit_prefix_stability.py -q
324
+ ```
325
+
326
+ The tests render both inputs and compare the original token IDs with the
327
+ same-length prefix of the extended render. They print the first differing
328
+ tokens and a decoded text diff on failure. These are executable witnesses,
329
+ not snapshots of token IDs that could pass without exercising the renderer.
330
+ Add a named case for each new instability mechanism or configuration branch.
331
+ A witness disproves stability; finite positive controls do not prove stability
332
+ for every possible conversation or third-party tokenizer/processor.
333
+
279
334
  ## License
280
335
 
281
336
  Licensed under the [Apache License, Version 2.0](LICENSE).
@@ -100,6 +100,8 @@ Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.
100
100
 
101
101
  ```python
102
102
  class Renderer(Protocol):
103
+ @property
104
+ def is_prefix_stable(self) -> bool: ...
103
105
  def render(messages, *, tools=None, add_generation_prompt=False) -> RenderedTokens: ...
104
106
  def render_ids(messages, *, tools=None, add_generation_prompt=False) -> list[int]: ...
105
107
  def parse_response(token_ids) -> ParsedResponse: ...
@@ -112,6 +114,35 @@ class Renderer(Protocol):
112
114
  - `ParsedResponse` is `(content, reasoning_content, tool_calls)`. It scans token ids for special-token boundaries (e.g. id `151657` for `<tool_call>` on Qwen3) — a literal `"<tool_call>"` in user content tokenizes to ordinary text ids and never matches.
113
115
  - Round-trip: rendering `[user, assistant(content, reasoning, tool_calls)]`, slicing the assistant completion, and feeding it through `parse_response` returns an equivalent structured message. Tested per-renderer in `tests/test_roundtrip.py`.
114
116
 
117
+ ### Prefix stability
118
+
119
+ `renderer.is_prefix_stable` describes the constructed renderer after config and
120
+ `chat_template_kwargs` resolution. A `True` value guarantees that full renders
121
+ of assistant-terminated conversations stay token prefixes when more
122
+ user/tool/assistant messages are appended, with a fixed system/developer
123
+ preamble, tools, and `add_generation_prompt=False`. `False` means this is not
124
+ guaranteed; opaque `DefaultRenderer` templates report `False`.
125
+
126
+ The flag follows the template's emit rules, independently of the
127
+ `thinking_retention` bridge override. For example, GLM-5 with
128
+ `clear_thinking=False` is stable, while the default strips earlier reasoning.
129
+ Disabling thinking generation alone does not guarantee stability for datasets
130
+ that already contain reasoning. Other causes include GPT-OSS's final-turn
131
+ terminator and Nemotron's moving effort hint. DeepSeek V4 reports `False`
132
+ because its task/continuation message controls can rewrite the prefix; Inkling
133
+ reports `False` because reused tool-call IDs can change earlier tool-response
134
+ names, even though it retains reasoning.
135
+
136
+ SFT consumers can use this flag to warn that a single full-conversation render
137
+ may omit earlier reasoning. `build_training_sample` still returns one sample
138
+ per conversation, without expanding it into one sample per assistant turn.
139
+ The guarantee assumes unchanged message contents and deterministic tokenization
140
+ and media preprocessing. Stability does not guarantee that the template emits
141
+ every input field (some always omit reasoning), or that rendering reproduces sampled tokens. Continue
142
+ using `bridge_to_next_turn` for sampled trajectories. Custom renderers should
143
+ expose the same property; consumers supporting older/custom implementations
144
+ can conservatively use `getattr(renderer, "is_prefix_stable", False)`.
145
+
115
146
  ### `bridge_to_next_turn` (the core contract)
116
147
 
117
148
  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.
@@ -256,6 +287,30 @@ uv run pytest
256
287
 
257
288
  Round-trip parity (render → parse → original) and token-level parity against each model's independent reference encoder are tested per renderer. Most references use `apply_chat_template`; DeepSeek V4 uses its shipped Python encoder, and GPT-OSS uses Harmony.
258
289
 
290
+ Prefix-stability audit witnesses live in
291
+ [`tests/fixtures/prefix_stability.json`](tests/fixtures/prefix_stability.json).
292
+ Each case records a renderer/config, a conversation ending in an assistant,
293
+ messages to append, and the reason the original tokens stop being a prefix.
294
+ Shared scenarios keep the corpus small; paired stable settings guard the
295
+ configuration switches. `DefaultRenderer` is treated as unknown, without
296
+ inventing a witness for its opaque template.
297
+
298
+ This audit is opt-in and is not collected by the regular test suite or CI.
299
+ Run it manually when changing renderer emission rules or the stability
300
+ classification:
301
+
302
+ ```bash
303
+ uv run pytest tests/audit_prefix_stability.py -q
304
+ ```
305
+
306
+ The tests render both inputs and compare the original token IDs with the
307
+ same-length prefix of the extended render. They print the first differing
308
+ tokens and a decoded text diff on failure. These are executable witnesses,
309
+ not snapshots of token IDs that could pass without exercising the renderer.
310
+ Add a named case for each new instability mechanism or configuration branch.
311
+ A witness disproves stability; finite positive controls do not prove stability
312
+ for every possible conversation or third-party tokenizer/processor.
313
+
259
314
  ## License
260
315
 
261
316
  Licensed under the [Apache License, Version 2.0](LICENSE).
@@ -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.dev18'
22
- __version_tuple__ = version_tuple = (0, 1, 12, 'dev18')
21
+ __version__ = version = '0.1.12.dev20'
22
+ __version_tuple__ = version_tuple = (0, 1, 12, 'dev20')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -722,6 +722,26 @@ class ChatTemplateTokenizer(Tokenizer, Protocol):
722
722
  class Renderer(Protocol):
723
723
  """Owns message ↔ token conversion for a specific model family."""
724
724
 
725
+ @property
726
+ def is_prefix_stable(self) -> bool:
727
+ """Whether extending a conversation preserves its rendered token prefix.
728
+
729
+ For valid conversations ending in an assistant message, ``True`` means
730
+ that appending user/tool/assistant messages leaves the existing token
731
+ IDs unchanged when both renders use ``add_generation_prompt=False``.
732
+ The system/developer preamble, tools, and renderer configuration must
733
+ stay fixed. This describes full renders, not sampled completions or
734
+ the ability to bridge them; keep using ``bridge_to_next_turn`` for that.
735
+
736
+ The declaration is conservative over message contents (including
737
+ supplied reasoning even when thinking generation is disabled) and
738
+ tools. ``False`` means stability is not guaranteed, including opaque
739
+ templates. It does not mean every conversation changes. Conversely,
740
+ ``True`` does not promise to emit fields the template always ignores.
741
+ Custom renderers should declare this property from their emit rules.
742
+ """
743
+ ...
744
+
725
745
  def render(
726
746
  self,
727
747
  messages: list[Message],
@@ -762,8 +782,9 @@ class Renderer(Protocol):
762
782
  Pass the exact ``prompt_ids`` used for sampling whenever available.
763
783
  This identifies reasoning prefilled by the prompt, including Gemma's
764
784
  post-tool thought channel and partial assistant prefixes. Omitting it,
765
- passing None, and passing [] all parse a self-contained completion;
766
- generation settings do not imply an initial reasoning channel.
785
+ passing None, and passing [] are equivalent: the format's completion
786
+ grammar applies without prompt context. GLM and DeepSeek V4 also use
787
+ the configured generation mode to determine the initial channel.
767
788
 
768
789
  ``tools`` is the same list passed to ``render`` for this turn.
769
790
  XML-style formats (Qwen3.5, GLM, MiniMax, Laguna) render argument
@@ -56,6 +56,11 @@ class DeepSeekV3Renderer:
56
56
  stored for protocol uniformity.
57
57
  """
58
58
 
59
+ @property
60
+ def is_prefix_stable(self) -> bool:
61
+ """Whether full renders preserve completed conversation prefixes."""
62
+ return True
63
+
59
64
  #: Default typed config; the R1 subclass overrides this.
60
65
  _config_cls: type = DeepSeekV3RendererConfig
61
66
  _implied_thinking_retention = "all"
@@ -22,8 +22,6 @@ 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
-
27
25
  from renderers.base import (
28
26
  Message,
29
27
  ParsedResponse,
@@ -41,7 +39,7 @@ from renderers.base import (
41
39
  )
42
40
  from renderers.configs import DeepSeekV4RendererConfig
43
41
  from renderers.parsing import parse_deepseek_v4
44
-
42
+ from renderers.reasoning import prompt_ends_in_reasoning
45
43
 
46
44
  _BOS = "<|begin▁of▁sentence|>"
47
45
  _EOS = "<|end▁of▁sentence|>"
@@ -294,6 +292,12 @@ def _prepare_messages(messages: list[Message]) -> list[_LogicalMessage]:
294
292
  class DeepSeekV4Renderer:
295
293
  """Renderer for ``deepseek-ai/DeepSeek-V4-Flash-0731``."""
296
294
 
295
+ @property
296
+ def is_prefix_stable(self) -> bool:
297
+ """Whether full renders preserve completed conversation prefixes."""
298
+ # Task transitions and unterminated assistant continuations can rewrite the prefix.
299
+ return False
300
+
297
301
  _implied_thinking_retention = "tool_cycle"
298
302
 
299
303
  def __init__(
@@ -792,15 +796,18 @@ class DeepSeekV4Renderer:
792
796
  self._tokenizer,
793
797
  token_ids,
794
798
  stop_ids={self._eos},
795
- thinking_enabled=prompt_ends_in_reasoning(
796
- self._tokenizer,
797
- prompt_ids,
798
- stop_ids=set(self.get_stop_token_ids()),
799
- assistant_prefix="<|Assistant|>",
799
+ thinking_enabled=(
800
+ prompt_ends_in_reasoning(
801
+ self._tokenizer,
802
+ prompt_ids,
803
+ stop_ids=set(self.get_stop_token_ids()),
804
+ assistant_prefix="<|Assistant|>",
805
+ )
806
+ if prompt_ids
807
+ else self.config.enable_thinking
800
808
  ),
801
809
  think_end_id=self._think_end,
802
810
  think_start_id=self._think_start,
803
- dsml_id=self._dsml,
804
811
  )
805
812
 
806
813
  def get_stop_token_ids(self) -> list[int]:
@@ -821,16 +828,10 @@ class DeepSeekV4Renderer:
821
828
  ):
822
829
  return None
823
830
 
824
- boundary = scan_reasoning(
825
- self._tokenizer,
826
- previous_completion_ids,
827
- prompt_ids=previous_prompt_ids,
828
- stop_ids=set(self.get_stop_token_ids()),
829
- tool_start_id=self._dsml,
830
- tool_start_closes_reasoning=True,
831
- assistant_prefix="<|Assistant|>",
831
+ boundary = self.parse_response(
832
+ previous_completion_ids, prompt_ids=previous_prompt_ids, tools=tools
832
833
  )
833
- if boundary.is_open:
834
+ if boundary.reasoning_complete is False:
834
835
  if any(t in self.get_stop_token_ids() for t in previous_completion_ids):
835
836
  return None
836
837
  previous_completion_ids = [*previous_completion_ids, self._think_end]
@@ -97,6 +97,12 @@ class DefaultRenderer:
97
97
  :class:`renderers.DefaultRendererConfig`).
98
98
  """
99
99
 
100
+ @property
101
+ def is_prefix_stable(self) -> bool:
102
+ """Whether full renders preserve completed conversation prefixes."""
103
+ # An opaque Jinja template cannot promise prefix stability.
104
+ return False
105
+
100
106
  def __init__(
101
107
  self,
102
108
  tokenizer: ChatTemplateTokenizer,
@@ -431,6 +431,12 @@ class _ArgumentParser:
431
431
  class Gemma4Renderer:
432
432
  """Deterministic renderer for the canonical Gemma 4 instruction models."""
433
433
 
434
+ @property
435
+ def is_prefix_stable(self) -> bool:
436
+ """Whether full renders preserve completed conversation prefixes."""
437
+ # preserve_thinking only retains tool-call reasoning, not final answers.
438
+ return False
439
+
434
440
  _config_cls = Gemma4RendererConfig
435
441
 
436
442
  def __init__(
@@ -13,14 +13,12 @@ from __future__ import annotations
13
13
  import json
14
14
  from typing import Any
15
15
 
16
- from renderers.reasoning import scan_reasoning, prompt_ends_in_reasoning
17
-
18
16
  from renderers.base import (
19
17
  Message,
20
18
  ParsedResponse,
21
19
  RenderedTokens,
22
- ToolSpec,
23
20
  Tokenizer,
21
+ ToolSpec,
24
22
  _content_mask_or_empty,
25
23
  attribute_text_segments,
26
24
  extract_message_tool_names,
@@ -55,6 +53,12 @@ _TOOLS_FOOTER = (
55
53
  class GLM45Renderer:
56
54
  """Deterministic message → token renderer for GLM-4.5 Air models."""
57
55
 
56
+ @property
57
+ def is_prefix_stable(self) -> bool:
58
+ """Whether full renders preserve completed conversation prefixes."""
59
+ # Historical reasoning is stripped regardless of bridge policy.
60
+ return False
61
+
58
62
  def __init__(
59
63
  self,
60
64
  tokenizer: Tokenizer,
@@ -290,12 +294,7 @@ class GLM45Renderer:
290
294
  arg_value_id=self._arg_value,
291
295
  arg_value_end_id=self._arg_value_end,
292
296
  tools=tools,
293
- prefilled_thinking=prompt_ends_in_reasoning(
294
- self._tokenizer,
295
- prompt_ids,
296
- stop_ids=set(self.get_stop_token_ids()),
297
- assistant_prefix="<|assistant|>",
298
- ),
297
+ thinking_enabled=self.config.enable_thinking,
299
298
  )
300
299
 
301
300
  def get_stop_token_ids(self) -> list[int]:
@@ -316,16 +315,10 @@ class GLM45Renderer:
316
315
  ):
317
316
  return None
318
317
 
319
- boundary = scan_reasoning(
320
- self._tokenizer,
321
- previous_completion_ids,
322
- prompt_ids=previous_prompt_ids,
323
- stop_ids=set(self.get_stop_token_ids()),
324
- tool_start_id=self._tool_call_tok,
325
- tool_start_closes_reasoning=True,
326
- assistant_prefix="<|assistant|>",
318
+ boundary = self.parse_response(
319
+ previous_completion_ids, prompt_ids=previous_prompt_ids, tools=tools
327
320
  )
328
- if boundary.is_open:
321
+ if boundary.reasoning_complete is False:
329
322
  if any(t in self.get_stop_token_ids() for t in previous_completion_ids):
330
323
  return None
331
324
  previous_completion_ids = [*previous_completion_ids, self._think_end]
@@ -14,8 +14,6 @@ from __future__ import annotations
14
14
  import json
15
15
  from typing import Any
16
16
 
17
- from renderers.reasoning import scan_reasoning, prompt_ends_in_reasoning
18
-
19
17
  from renderers.base import (
20
18
  Message,
21
19
  ParsedResponse,
@@ -57,6 +55,14 @@ _TOOLS_FOOTER = (
57
55
  class GLM5Renderer:
58
56
  """Deterministic message → token renderer for GLM-5 models."""
59
57
 
58
+ @property
59
+ def is_prefix_stable(self) -> bool:
60
+ """Whether full renders preserve completed conversation prefixes."""
61
+ # GLM-5.1 also changes empty thinking wrappers across user boundaries.
62
+ return not self.config.clear_thinking and not (
63
+ self.empty_think_on_last_assistant and self.config.enable_thinking
64
+ )
65
+
60
66
  # GLM-5.1 flips this on: even when the most-recent assistant has no
61
67
  # reasoning content, the template wraps it with ``<think></think>``
62
68
  # instead of just emitting ``</think>`` as a separator. Subclassed in
@@ -343,12 +349,7 @@ class GLM5Renderer:
343
349
  arg_value_id=self._arg_value,
344
350
  arg_value_end_id=self._arg_value_end,
345
351
  tools=tools,
346
- prefilled_thinking=prompt_ends_in_reasoning(
347
- self._tokenizer,
348
- prompt_ids,
349
- stop_ids=set(self.get_stop_token_ids()),
350
- assistant_prefix="<|assistant|>",
351
- ),
352
+ thinking_enabled=getattr(self.config, "enable_thinking", True),
352
353
  )
353
354
 
354
355
  def get_stop_token_ids(self) -> list[int]:
@@ -369,16 +370,10 @@ class GLM5Renderer:
369
370
  ):
370
371
  return None
371
372
 
372
- boundary = scan_reasoning(
373
- self._tokenizer,
374
- previous_completion_ids,
375
- prompt_ids=previous_prompt_ids,
376
- stop_ids=set(self.get_stop_token_ids()),
377
- tool_start_id=self._tool_call_tok,
378
- tool_start_closes_reasoning=True,
379
- assistant_prefix="<|assistant|>",
373
+ boundary = self.parse_response(
374
+ previous_completion_ids, prompt_ids=previous_prompt_ids, tools=tools
380
375
  )
381
- if boundary.is_open:
376
+ if boundary.reasoning_complete is False:
382
377
  if any(t in self.get_stop_token_ids() for t in previous_completion_ids):
383
378
  return None
384
379
  previous_completion_ids = [*previous_completion_ids, self._think_end]
@@ -121,6 +121,12 @@ def _arguments_to_str(arguments: Any) -> str:
121
121
  class GptOssRenderer:
122
122
  """Deterministic message → token renderer for OpenAI gpt-oss (harmony)."""
123
123
 
124
+ @property
125
+ def is_prefix_stable(self) -> bool:
126
+ """Whether full renders preserve completed conversation prefixes."""
127
+ # The final assistant terminator changes even when auto_drop_analysis=False.
128
+ return False
129
+
124
130
  def __init__(
125
131
  self,
126
132
  tokenizer: Tokenizer,
@@ -76,6 +76,14 @@ _TOOL_RESPONSE_END = f"</tool_response{_HYTK}>"
76
76
  class Hy3Renderer:
77
77
  """Deterministic message → token renderer for Tencent Hy3 models."""
78
78
 
79
+ @property
80
+ def is_prefix_stable(self) -> bool:
81
+ """Whether full renders preserve completed conversation prefixes."""
82
+ # Require retention regardless of whether tools are supplied.
83
+ return not self.config.raw_last_assistant and (
84
+ self.config.is_training or self.config.preserved_thinking is True
85
+ )
86
+
79
87
  def __init__(
80
88
  self,
81
89
  tokenizer: Tokenizer,
@@ -173,6 +173,12 @@ class InklingRenderer:
173
173
  the :class:`~renderers.base.MultimodalRenderer` protocol.
174
174
  """
175
175
 
176
+ @property
177
+ def is_prefix_stable(self) -> bool:
178
+ """Whether full renders preserve completed conversation prefixes."""
179
+ # Reused tool-call IDs can change names resolved for earlier tool responses.
180
+ return False
181
+
176
182
  def __init__(
177
183
  self,
178
184
  tokenizer: Tokenizer,
@@ -47,6 +47,11 @@ class KimiK2Renderer:
47
47
  but have no effect on the byte-level output.
48
48
  """
49
49
 
50
+ @property
51
+ def is_prefix_stable(self) -> bool:
52
+ """Whether full renders preserve completed conversation prefixes."""
53
+ return True
54
+
50
55
  def __init__(
51
56
  self,
52
57
  tokenizer: Tokenizer,
@@ -593,6 +593,12 @@ class KimiK25Renderer:
593
593
  The tokenizer should be ``moonshotai/Kimi-K2-Instruct`` (same as K2).
594
594
  """
595
595
 
596
+ @property
597
+ def is_prefix_stable(self) -> bool:
598
+ """Whether full renders preserve completed conversation prefixes."""
599
+ # Completing a tool cycle strips its earlier reasoning, even with thinking=False.
600
+ return False
601
+
596
602
  supports_process_multimodal = True
597
603
 
598
604
  def __init__(
@@ -120,6 +120,11 @@ _TOOLS_HEADER_XS21 = (
120
120
 
121
121
 
122
122
  class LagunaXS2Renderer:
123
+ @property
124
+ def is_prefix_stable(self) -> bool:
125
+ """Whether full renders preserve completed conversation prefixes."""
126
+ return True
127
+
123
128
  def __init__(
124
129
  self,
125
130
  tokenizer: Tokenizer,
@@ -91,6 +91,11 @@ _TOOLS_IN_USER_INTRO = (
91
91
  class Llama3Renderer:
92
92
  """Deterministic message → token renderer for Llama-3.x Instruct models."""
93
93
 
94
+ @property
95
+ def is_prefix_stable(self) -> bool:
96
+ """Whether full renders preserve completed conversation prefixes."""
97
+ return True
98
+
94
99
  def __init__(
95
100
  self,
96
101
  tokenizer: Tokenizer,
@@ -58,6 +58,12 @@ _TOOLS_INSTRUCTIONS = (
58
58
  class MiniMaxM2Renderer:
59
59
  """Deterministic message → token renderer for MiniMax M2 / M2.5 models."""
60
60
 
61
+ @property
62
+ def is_prefix_stable(self) -> bool:
63
+ """Whether full renders preserve completed conversation prefixes."""
64
+ # Only assistants after the last user retain reasoning.
65
+ return False
66
+
61
67
  def __init__(
62
68
  self,
63
69
  tokenizer: Tokenizer,
@@ -118,6 +118,12 @@ class Nemotron3Renderer:
118
118
  their own discriminator and differ only by the class-level hooks here.
119
119
  """
120
120
 
121
+ @property
122
+ def is_prefix_stable(self) -> bool:
123
+ """Whether full renders preserve completed conversation prefixes."""
124
+ # An effort hint moves to the newest user, independently of thinking retention.
125
+ return not self.config.truncate_history_thinking and not self._effort_hint
126
+
121
127
  # Variant hooks (overridden by ``Nemotron3UltraRenderer``): the default
122
128
  # config to build when none is passed, and whether to use Ultra's
123
129
  # reasoning-block glue.