renderers 0.1.12.dev18__tar.gz → 0.1.12.dev19__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/PKG-INFO +56 -1
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/README.md +55 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/_version.py +2 -2
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/base.py +20 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/deepseek_v3.py +5 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/deepseek_v4.py +6 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/default.py +6 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/gemma4.py +6 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/glm45.py +6 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/glm5.py +8 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/gpt_oss.py +6 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/hy3.py +8 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/inkling.py +6 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/kimi_k2.py +5 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/kimi_k25.py +6 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/laguna_xs2.py +5 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/llama_3.py +5 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/minimax_m2.py +6 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/nemotron3.py +6 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/prime_qwen3.py +5 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/qwen3.py +6 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/qwen35.py +6 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/qwen3_vl.py +5 -0
- renderers-0.1.12.dev19/tests/audit_prefix_stability.py +81 -0
- renderers-0.1.12.dev19/tests/fixtures/prefix_stability.json +792 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/.github/workflows/publish-dev.yml +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/.github/workflows/publish.yml +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/.github/workflows/style.yml +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/.github/workflows/test.yml +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/.gitignore +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/.pre-commit-config.yaml +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/LICENSE +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/docs/renderer-config.md +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/examples/README.md +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/examples/sglang/multiturn_generate_sglang.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/examples/sglang/online_multiturn_sglang.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/examples/tinker/multiturn_generate_tinker.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/examples/transformers/multiturn_generate_transformers.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/examples/vllm/multiturn_generate_vllm.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/pyproject.toml +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/__init__.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/client.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/configs.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/custom.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/deepseek_r1.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/laguna_s21.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/parsers.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/parsing.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/qwen36.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/qwen38.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/reasoning.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/conftest.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/parity.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/reference_rendering.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_bridge.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_build_helpers.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_client.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_custom.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_deepseek_r1.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_deepseek_v4.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_disabled_thinking_stability.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_gemma4.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_glm_tool_name_validation.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_hy3.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_incremental.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_inkling.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_is_content.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_kimi_k25_tool_schema.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_laguna_m1.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_laguna_xs21.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_llama_3.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_load_tokenizer.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_message_indices.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_message_tool_names.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_multimodal.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_nemotron35_vlm.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_nemotron3_ultra.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_offsetless_tokenizers.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_parity.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_parse_response.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_parse_response_robustness.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_parsers.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_preserve_thinking.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_qwen35_size_coverage.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_qwen38.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_reasoning_boundaries.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_renderer_config.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_roundtrip.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_sampled_mask.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_tokens_per_message.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_tool_arg_type_preservation.py +0 -0
- {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: renderers
|
|
3
|
-
Version: 0.1.12.
|
|
3
|
+
Version: 0.1.12.dev19
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1, 12, '
|
|
21
|
+
__version__ = version = '0.1.12.dev19'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 12, 'dev19')
|
|
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],
|
|
@@ -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"
|
|
@@ -294,6 +294,12 @@ def _prepare_messages(messages: list[Message]) -> list[_LogicalMessage]:
|
|
|
294
294
|
class DeepSeekV4Renderer:
|
|
295
295
|
"""Renderer for ``deepseek-ai/DeepSeek-V4-Flash-0731``."""
|
|
296
296
|
|
|
297
|
+
@property
|
|
298
|
+
def is_prefix_stable(self) -> bool:
|
|
299
|
+
"""Whether full renders preserve completed conversation prefixes."""
|
|
300
|
+
# Task transitions and unterminated assistant continuations can rewrite the prefix.
|
|
301
|
+
return False
|
|
302
|
+
|
|
297
303
|
_implied_thinking_retention = "tool_cycle"
|
|
298
304
|
|
|
299
305
|
def __init__(
|
|
@@ -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__(
|
|
@@ -55,6 +55,12 @@ _TOOLS_FOOTER = (
|
|
|
55
55
|
class GLM45Renderer:
|
|
56
56
|
"""Deterministic message → token renderer for GLM-4.5 Air models."""
|
|
57
57
|
|
|
58
|
+
@property
|
|
59
|
+
def is_prefix_stable(self) -> bool:
|
|
60
|
+
"""Whether full renders preserve completed conversation prefixes."""
|
|
61
|
+
# Historical reasoning is stripped regardless of bridge policy.
|
|
62
|
+
return False
|
|
63
|
+
|
|
58
64
|
def __init__(
|
|
59
65
|
self,
|
|
60
66
|
tokenizer: Tokenizer,
|
|
@@ -57,6 +57,14 @@ _TOOLS_FOOTER = (
|
|
|
57
57
|
class GLM5Renderer:
|
|
58
58
|
"""Deterministic message → token renderer for GLM-5 models."""
|
|
59
59
|
|
|
60
|
+
@property
|
|
61
|
+
def is_prefix_stable(self) -> bool:
|
|
62
|
+
"""Whether full renders preserve completed conversation prefixes."""
|
|
63
|
+
# GLM-5.1 also changes empty thinking wrappers across user boundaries.
|
|
64
|
+
return not self.config.clear_thinking and not (
|
|
65
|
+
self.empty_think_on_last_assistant and self.config.enable_thinking
|
|
66
|
+
)
|
|
67
|
+
|
|
60
68
|
# GLM-5.1 flips this on: even when the most-recent assistant has no
|
|
61
69
|
# reasoning content, the template wraps it with ``<think></think>``
|
|
62
70
|
# instead of just emitting ``</think>`` as a separator. Subclassed in
|
|
@@ -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.
|
|
@@ -193,6 +193,11 @@ class _TokenBuilder:
|
|
|
193
193
|
class PrimeQwen3Renderer:
|
|
194
194
|
"""Renderer for PrimeIntellect/Qwen3-0.6B and Qwen3-1.7B."""
|
|
195
195
|
|
|
196
|
+
@property
|
|
197
|
+
def is_prefix_stable(self) -> bool:
|
|
198
|
+
"""Whether full renders preserve completed conversation prefixes."""
|
|
199
|
+
return True
|
|
200
|
+
|
|
196
201
|
def __init__(
|
|
197
202
|
self,
|
|
198
203
|
tokenizer: Tokenizer,
|
|
@@ -59,6 +59,12 @@ _TOOLS_FOOTER = (
|
|
|
59
59
|
class Qwen3Renderer:
|
|
60
60
|
"""Deterministic message → token renderer for Qwen3 models."""
|
|
61
61
|
|
|
62
|
+
@property
|
|
63
|
+
def is_prefix_stable(self) -> bool:
|
|
64
|
+
"""Whether full renders preserve completed conversation prefixes."""
|
|
65
|
+
# Even with thinking disabled, supplied reasoning is stripped from history.
|
|
66
|
+
return False
|
|
67
|
+
|
|
62
68
|
def __init__(
|
|
63
69
|
self,
|
|
64
70
|
tokenizer: Tokenizer,
|
|
@@ -126,6 +126,12 @@ def _default_enable_thinking(tokenizer) -> bool:
|
|
|
126
126
|
class Qwen35Renderer:
|
|
127
127
|
"""Deterministic message → token renderer for Qwen3.5 models."""
|
|
128
128
|
|
|
129
|
+
@property
|
|
130
|
+
def is_prefix_stable(self) -> bool:
|
|
131
|
+
"""Whether full renders preserve completed conversation prefixes."""
|
|
132
|
+
# Qwen3.6/3.8 can retain every thinking block through their template knob.
|
|
133
|
+
return bool(getattr(self.config, "preserve_thinking", False))
|
|
134
|
+
|
|
129
135
|
supports_process_multimodal = True
|
|
130
136
|
_config_cls: type = Qwen35RendererConfig
|
|
131
137
|
|
|
@@ -312,6 +312,11 @@ class Qwen3VLRenderer:
|
|
|
312
312
|
``thinking_retention`` still controls whether the bridge is attempted.
|
|
313
313
|
"""
|
|
314
314
|
|
|
315
|
+
@property
|
|
316
|
+
def is_prefix_stable(self) -> bool:
|
|
317
|
+
"""Whether full renders preserve completed conversation prefixes."""
|
|
318
|
+
return True
|
|
319
|
+
|
|
315
320
|
supports_process_multimodal = True
|
|
316
321
|
|
|
317
322
|
def __init__(
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Manual audit: uv run pytest tests/audit_prefix_stability.py -q."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from difflib import unified_diff
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from conftest import _load
|
|
9
|
+
from renderers import DefaultRenderer, create_renderer
|
|
10
|
+
from renderers.base import RENDERER_REGISTRY, _populate_registry
|
|
11
|
+
from renderers.configs import _config_class_for
|
|
12
|
+
|
|
13
|
+
_CORPUS = json.loads(
|
|
14
|
+
(Path(__file__).parent / "fixtures" / "prefix_stability.json").read_text()
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _prefix_diff(tokenizer, before, after):
|
|
19
|
+
candidate = after[: len(before)]
|
|
20
|
+
if before == candidate:
|
|
21
|
+
return "No differing token: this witness now preserves the original prefix."
|
|
22
|
+
index = next(
|
|
23
|
+
(i for i, (left, right) in enumerate(zip(before, candidate)) if left != right),
|
|
24
|
+
min(len(before), len(candidate)),
|
|
25
|
+
)
|
|
26
|
+
diff = "\n".join(
|
|
27
|
+
unified_diff(
|
|
28
|
+
tokenizer.decode(before).splitlines(),
|
|
29
|
+
tokenizer.decode(candidate).splitlines(),
|
|
30
|
+
fromfile="original render",
|
|
31
|
+
tofile="same-length prefix after append",
|
|
32
|
+
lineterm="",
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
return (
|
|
36
|
+
f"First differing token: {index}\n"
|
|
37
|
+
f"Before: {before[index : index + 8]}\n"
|
|
38
|
+
f"After: {candidate[index : index + 8]}\n{diff}"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize("case", _CORPUS["cases"], ids=lambda case: case["id"])
|
|
43
|
+
def test_prefix_stability_witness(case):
|
|
44
|
+
config = dict(case["config"])
|
|
45
|
+
name = config.pop("name")
|
|
46
|
+
config_cls = _config_class_for(name)
|
|
47
|
+
tokenizer, _ = _load(case["model"], name)
|
|
48
|
+
renderer = create_renderer(tokenizer, config_cls(**config))
|
|
49
|
+
scenario = _CORPUS["scenarios"][case["scenario"]]
|
|
50
|
+
messages = scenario["messages"]
|
|
51
|
+
assert messages[-1]["role"] == "assistant" and scenario["append"]
|
|
52
|
+
before = renderer.render_ids(messages, tools=scenario.get("tools"))
|
|
53
|
+
after = renderer.render_ids(
|
|
54
|
+
messages + scenario["append"], tools=scenario.get("tools")
|
|
55
|
+
)
|
|
56
|
+
extends = after[: len(before)] == before
|
|
57
|
+
assert renderer.is_prefix_stable is case["stable"], case["reason"]
|
|
58
|
+
assert extends is case["stable"], (
|
|
59
|
+
f"{case['reason']}\n{_prefix_diff(tokenizer, before, after)}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_corpus_covers_builtin_renderers():
|
|
64
|
+
_populate_registry()
|
|
65
|
+
assert {case["config"]["name"] for case in _CORPUS["cases"]} | {"default"} == set(
|
|
66
|
+
RENDERER_REGISTRY
|
|
67
|
+
)
|
|
68
|
+
assert len({case["id"] for case in _CORPUS["cases"]}) == len(_CORPUS["cases"])
|
|
69
|
+
# An opaque template is unknown; inventing a counterexample would be wrong.
|
|
70
|
+
assert DefaultRenderer(object()).is_prefix_stable is False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_stability_uses_auto_resolved_template_kwargs():
|
|
74
|
+
tokenizer, _ = _load("Qwen/Qwen3.8-27B", "qwen3.8")
|
|
75
|
+
assert create_renderer(tokenizer).is_prefix_stable is True
|
|
76
|
+
assert (
|
|
77
|
+
create_renderer(
|
|
78
|
+
tokenizer, chat_template_kwargs={"preserve_thinking": False}
|
|
79
|
+
).is_prefix_stable
|
|
80
|
+
is False
|
|
81
|
+
)
|