renderers 0.1.12.dev17__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.
Files changed (92) hide show
  1. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/.github/workflows/test.yml +1 -1
  2. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/PKG-INFO +57 -2
  3. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/README.md +55 -0
  4. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/examples/sglang/multiturn_generate_sglang.py +1 -1
  5. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/examples/sglang/online_multiturn_sglang.py +1 -1
  6. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/examples/transformers/multiturn_generate_transformers.py +1 -1
  7. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/examples/vllm/multiturn_generate_vllm.py +1 -1
  8. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/pyproject.toml +1 -1
  9. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/_version.py +2 -2
  10. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/base.py +73 -42
  11. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/configs.py +16 -16
  12. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/deepseek_v3.py +5 -0
  13. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/deepseek_v4.py +6 -0
  14. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/default.py +6 -0
  15. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/gemma4.py +6 -0
  16. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/glm45.py +6 -0
  17. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/glm5.py +8 -0
  18. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/gpt_oss.py +6 -0
  19. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/hy3.py +8 -0
  20. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/inkling.py +6 -0
  21. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/kimi_k2.py +5 -0
  22. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/kimi_k25.py +6 -0
  23. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/laguna_xs2.py +5 -0
  24. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/llama_3.py +5 -0
  25. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/minimax_m2.py +6 -0
  26. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/nemotron3.py +6 -0
  27. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/prime_qwen3.py +5 -0
  28. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/qwen3.py +6 -0
  29. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/qwen35.py +6 -0
  30. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/qwen3_vl.py +5 -0
  31. renderers-0.1.12.dev19/tests/audit_prefix_stability.py +81 -0
  32. renderers-0.1.12.dev19/tests/fixtures/prefix_stability.json +792 -0
  33. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_parsers.py +10 -0
  34. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_renderer_config.py +6 -0
  35. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/uv.lock +5 -288
  36. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/.github/workflows/publish-dev.yml +0 -0
  37. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/.github/workflows/publish.yml +0 -0
  38. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/.github/workflows/style.yml +0 -0
  39. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/.gitignore +0 -0
  40. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/.pre-commit-config.yaml +0 -0
  41. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/LICENSE +0 -0
  42. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/docs/renderer-config.md +0 -0
  43. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/examples/README.md +0 -0
  44. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/examples/tinker/multiturn_generate_tinker.py +0 -0
  45. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/__init__.py +0 -0
  46. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/client.py +0 -0
  47. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/custom.py +0 -0
  48. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/deepseek_r1.py +0 -0
  49. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/laguna_s21.py +0 -0
  50. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/parsers.py +0 -0
  51. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/parsing.py +0 -0
  52. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/qwen36.py +0 -0
  53. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/qwen38.py +0 -0
  54. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/renderers/reasoning.py +0 -0
  55. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/conftest.py +0 -0
  56. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/parity.py +0 -0
  57. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/reference_rendering.py +0 -0
  58. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_bridge.py +0 -0
  59. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_build_helpers.py +0 -0
  60. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_client.py +0 -0
  61. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_custom.py +0 -0
  62. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_deepseek_r1.py +0 -0
  63. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_deepseek_v4.py +0 -0
  64. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_disabled_thinking_stability.py +0 -0
  65. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_gemma4.py +0 -0
  66. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_glm_tool_name_validation.py +0 -0
  67. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_hy3.py +0 -0
  68. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_incremental.py +0 -0
  69. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_inkling.py +0 -0
  70. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_is_content.py +0 -0
  71. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_kimi_k25_tool_schema.py +0 -0
  72. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_laguna_m1.py +0 -0
  73. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_laguna_xs21.py +0 -0
  74. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_llama_3.py +0 -0
  75. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_load_tokenizer.py +0 -0
  76. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_message_indices.py +0 -0
  77. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_message_tool_names.py +0 -0
  78. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_multimodal.py +0 -0
  79. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_nemotron35_vlm.py +0 -0
  80. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_nemotron3_ultra.py +0 -0
  81. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_offsetless_tokenizers.py +0 -0
  82. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_parity.py +0 -0
  83. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_parse_response.py +0 -0
  84. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_parse_response_robustness.py +0 -0
  85. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_preserve_thinking.py +0 -0
  86. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_qwen35_size_coverage.py +0 -0
  87. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_qwen38.py +0 -0
  88. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_reasoning_boundaries.py +0 -0
  89. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_roundtrip.py +0 -0
  90. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_sampled_mask.py +0 -0
  91. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/tests/test_tokens_per_message.py +0 -0
  92. {renderers-0.1.12.dev17 → renderers-0.1.12.dev19}/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", "3.14"]
24
+ python-version: ["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.dev17
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
7
- Requires-Python: <3.15,>=3.10
7
+ Requires-Python: <3.15,>=3.11
8
8
  Requires-Dist: jinja2
9
9
  Requires-Dist: numpy
10
10
  Requires-Dist: openai-harmony>=0.0.4
@@ -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).
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env -S uv run --script
2
2
  # /// script
3
- # requires-python = ">=3.10,<3.14"
3
+ # requires-python = ">=3.11,<3.14"
4
4
  # dependencies = [
5
5
  # "renderers>=0.1.6",
6
6
  # "sglang==0.5.10.post1",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env -S uv run --script
2
2
  # /// script
3
- # requires-python = ">=3.10,<3.14"
3
+ # requires-python = ">=3.11,<3.14"
4
4
  # dependencies = [
5
5
  # "renderers>=0.1.6",
6
6
  # "transformers>=5.3.0",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env -S uv run --script
2
2
  # /// script
3
- # requires-python = ">=3.10,<3.14"
3
+ # requires-python = ">=3.11,<3.14"
4
4
  # dependencies = [
5
5
  # "renderers>=0.1.6",
6
6
  # "transformers>=4.50.0",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env -S uv run --script
2
2
  # /// script
3
- # requires-python = ">=3.10,<3.14"
3
+ # requires-python = ">=3.11,<3.14"
4
4
  # dependencies = [
5
5
  # "renderers>=0.1.6",
6
6
  # "vllm>=0.20",
@@ -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.15"
16
+ requires-python = ">=3.11,<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.dev17'
22
- __version_tuple__ = version_tuple = (0, 1, 12, 'dev17')
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
@@ -1,15 +1,14 @@
1
- from __future__ import annotations
2
-
3
- import enum
4
1
  import logging
5
2
  from collections.abc import Mapping
6
3
  from dataclasses import dataclass, field
4
+ from enum import StrEnum
7
5
  from typing import (
8
6
  TYPE_CHECKING,
9
7
  Any,
10
8
  Callable,
11
9
  Literal,
12
10
  Protocol,
11
+ Required,
13
12
  TypedDict,
14
13
  cast,
15
14
  runtime_checkable,
@@ -110,8 +109,8 @@ class Message(TypedDict, total=False):
110
109
  Optional keys mirror the OpenAI chat format for tool calling.
111
110
  """
112
111
 
113
- role: str
114
- content: Content
112
+ role: Required[str]
113
+ content: Required[Content]
115
114
  tool_calls: list[ToolCall]
116
115
  tool_call_id: str
117
116
  name: str
@@ -562,7 +561,7 @@ class RenderedTokens:
562
561
  return mask
563
562
 
564
563
 
565
- class ToolCallParseStatus(str, enum.Enum):
564
+ class ToolCallParseStatus(StrEnum):
566
565
  """Per-attempt outcome of parsing a single ``<tool_call>`` block.
567
566
 
568
567
  The renderer parser's job is JSON-syntax → ``dict`` (the parser-level
@@ -723,6 +722,26 @@ class ChatTemplateTokenizer(Tokenizer, Protocol):
723
722
  class Renderer(Protocol):
724
723
  """Owns message ↔ token conversion for a specific model family."""
725
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
+
726
745
  def render(
727
746
  self,
728
747
  messages: list[Message],
@@ -1310,15 +1329,19 @@ def load_tokenizer(model_name_or_path: str):
1310
1329
  the requested Meta ID so auto-resolution still selects ``Llama3Renderer``.
1311
1330
  Requires the ``renderers[transformers]`` extra.
1312
1331
  """
1313
- _require_transformers("Loading a tokenizer")
1314
- load_name_or_path = _tokenizer_source_for(model_name_or_path)
1315
- kwargs = _tokenizer_load_kwargs(load_name_or_path)
1316
- tok = _load_tokenizer_via_auto(load_name_or_path, **kwargs)
1317
- return _preserve_requested_tokenizer_name(
1318
- tok,
1319
- requested_name_or_path=model_name_or_path,
1320
- loaded_name_or_path=load_name_or_path,
1321
- )
1332
+ try:
1333
+ _require_transformers("Loading a tokenizer")
1334
+ load_name_or_path = _tokenizer_source_for(model_name_or_path)
1335
+ kwargs = _tokenizer_load_kwargs(load_name_or_path)
1336
+ tok = _load_tokenizer_via_auto(load_name_or_path, **kwargs)
1337
+ return _preserve_requested_tokenizer_name(
1338
+ tok,
1339
+ requested_name_or_path=model_name_or_path,
1340
+ loaded_name_or_path=load_name_or_path,
1341
+ )
1342
+ except Exception as exc:
1343
+ exc.add_note(f"While loading tokenizer {model_name_or_path!r}.")
1344
+ raise
1322
1345
 
1323
1346
 
1324
1347
  def _populate_registry():
@@ -1393,7 +1416,7 @@ def _populate_registry():
1393
1416
 
1394
1417
  def create_renderer(
1395
1418
  tokenizer,
1396
- config: RendererConfig | None = None,
1419
+ config: "RendererConfig | None" = None,
1397
1420
  *,
1398
1421
  chat_template_kwargs: Mapping[str, Any] | None = None,
1399
1422
  ) -> Renderer:
@@ -1425,26 +1448,34 @@ def create_renderer(
1425
1448
  """
1426
1449
  _populate_registry()
1427
1450
 
1428
- config = _resolve_renderer_config(
1429
- tokenizer,
1430
- config,
1431
- chat_template_kwargs=chat_template_kwargs,
1432
- )
1433
- from renderers.configs import CustomRendererConfig
1434
- from renderers.custom import custom_renderer_config, load_custom_renderer
1435
-
1436
- if isinstance(config, CustomRendererConfig):
1437
- renderer_cls = load_custom_renderer(config.import_path)
1438
- return renderer_cls(tokenizer, custom_renderer_config(config))
1439
- cls = RENDERER_REGISTRY.get(config.name)
1440
- if cls is None:
1441
- raise ValueError(
1442
- f"Unknown renderer {config.name!r}. Available: {', '.join(sorted(RENDERER_REGISTRY))}"
1451
+ try:
1452
+ config = _resolve_renderer_config(
1453
+ tokenizer,
1454
+ config,
1455
+ chat_template_kwargs=chat_template_kwargs,
1456
+ )
1457
+ from renderers.configs import CustomRendererConfig
1458
+ from renderers.custom import custom_renderer_config, load_custom_renderer
1459
+
1460
+ if isinstance(config, CustomRendererConfig):
1461
+ renderer_cls = load_custom_renderer(config.import_path)
1462
+ return renderer_cls(tokenizer, custom_renderer_config(config))
1463
+ cls = RENDERER_REGISTRY.get(config.name)
1464
+ if cls is None:
1465
+ raise ValueError(
1466
+ f"Unknown renderer {config.name!r}. Available: {', '.join(sorted(RENDERER_REGISTRY))}"
1467
+ )
1468
+ return cls(tokenizer, config)
1469
+ except Exception as exc:
1470
+ model_name = getattr(tokenizer, "name_or_path", "<unnamed tokenizer>")
1471
+ renderer_name = config.name if config is not None else "auto"
1472
+ exc.add_note(
1473
+ f"While creating renderer {renderer_name!r} for tokenizer {model_name!r}."
1443
1474
  )
1444
- return cls(tokenizer, config)
1475
+ raise
1445
1476
 
1446
1477
 
1447
- def template_field_names(config: RendererConfig) -> frozenset[str]:
1478
+ def template_field_names(config: "RendererConfig") -> frozenset[str]:
1448
1479
  """Chat-template kwargs that ``config``'s renderer accepts, custom renderers included."""
1449
1480
  from renderers.configs import CustomRendererConfig
1450
1481
  from renderers.custom import load_custom_renderer
@@ -1457,9 +1488,9 @@ def template_field_names(config: RendererConfig) -> frozenset[str]:
1457
1488
 
1458
1489
 
1459
1490
  def merge_chat_template_kwargs(
1460
- config: RendererConfig,
1491
+ config: "RendererConfig",
1461
1492
  chat_template_kwargs: Mapping[str, Any] | None,
1462
- ) -> RendererConfig:
1493
+ ) -> "RendererConfig":
1463
1494
  """Return ``config`` with the template kwargs applied, validated against its allowlist."""
1464
1495
  if not chat_template_kwargs:
1465
1496
  return config
@@ -1504,10 +1535,10 @@ def merge_chat_template_kwargs(
1504
1535
 
1505
1536
  def _resolve_renderer_config(
1506
1537
  tokenizer,
1507
- config: RendererConfig | None,
1538
+ config: "RendererConfig | None",
1508
1539
  *,
1509
1540
  chat_template_kwargs: Mapping[str, Any] | None = None,
1510
- ) -> RendererConfig:
1541
+ ) -> "RendererConfig":
1511
1542
  """Resolve auto/default config and merge chat-template kwargs."""
1512
1543
  from renderers.configs import AutoRendererConfig
1513
1544
 
@@ -1526,10 +1557,10 @@ def _resolve_renderer_config(
1526
1557
 
1527
1558
  def _resolve_auto_config(
1528
1559
  tokenizer,
1529
- auto: AutoRendererConfig,
1560
+ auto: "AutoRendererConfig",
1530
1561
  *,
1531
1562
  chat_template_kwargs: Mapping[str, Any] | None = None,
1532
- ) -> RendererConfig:
1563
+ ) -> "RendererConfig":
1533
1564
  """Map ``AutoRendererConfig`` → concrete typed config via the
1534
1565
  tokenizer's ``name_or_path``.
1535
1566
 
@@ -2117,8 +2148,8 @@ def introduces_user_query(
2117
2148
 
2118
2149
  def resolve_thinking_retention(
2119
2150
  config: Any,
2120
- implied: ResolvedThinkingRetention,
2121
- ) -> ResolvedThinkingRetention:
2151
+ implied: "ResolvedThinkingRetention",
2152
+ ) -> "ResolvedThinkingRetention":
2122
2153
  """Resolve the effective bridge policy for a renderer instance.
2123
2154
 
2124
2155
  ``config.thinking_retention is None`` means "derive from template knobs";
@@ -2132,7 +2163,7 @@ def resolve_thinking_retention(
2132
2163
 
2133
2164
 
2134
2165
  def should_rerender_for_thinking_retention(
2135
- thinking_retention: ResolvedThinkingRetention,
2166
+ thinking_retention: "ResolvedThinkingRetention",
2136
2167
  new_messages: list[Message],
2137
2168
  *,
2138
2169
  is_user_query: Callable[[Message], bool] = _is_user_message,
@@ -21,7 +21,7 @@ keys its tokenizer's template will honour, so it can't enumerate them.
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
- from typing import Annotated, ClassVar, Literal, Union
24
+ from typing import Annotated, ClassVar, Literal, Self, Union
25
25
 
26
26
  from pydantic import ConfigDict, Field, model_validator
27
27
  from pydantic_config import BaseConfig
@@ -159,7 +159,7 @@ class DefaultRendererConfig(BaseRendererConfig):
159
159
  _allow_opaque_template_kwargs = True
160
160
 
161
161
  @model_validator(mode="after")
162
- def _reject_legacy_preserve_flags(self):
162
+ def _reject_legacy_preserve_flags(self) -> Self:
163
163
  # ``extra="allow"`` would otherwise swallow the removed ``preserve_*``
164
164
  # bools into ``model_extra`` and forward them to apply_chat_template,
165
165
  # silently dropping the user's intent (DefaultRenderer can't
@@ -261,7 +261,7 @@ class Qwen36RendererConfig(BaseRendererConfig):
261
261
  _internal_fields = frozenset({"image_cache_max"})
262
262
 
263
263
  @model_validator(mode="after")
264
- def _check_thinking_retention(self):
264
+ def _check_thinking_retention(self) -> Self:
265
265
  _reject_thinking_retention_conflict(
266
266
  self,
267
267
  "preserve_thinking",
@@ -307,7 +307,7 @@ class Qwen38RendererConfig(BaseRendererConfig):
307
307
  _internal_fields = frozenset({"image_cache_max"})
308
308
 
309
309
  @model_validator(mode="after")
310
- def _check_thinking_retention(self):
310
+ def _check_thinking_retention(self) -> Self:
311
311
  _reject_thinking_retention_conflict(
312
312
  self,
313
313
  "preserve_thinking",
@@ -350,7 +350,7 @@ class Gemma4RendererConfig(BaseRendererConfig):
350
350
  _internal_fields = frozenset({"image_cache_max"})
351
351
 
352
352
  @model_validator(mode="after")
353
- def _check_thinking_retention(self):
353
+ def _check_thinking_retention(self) -> Self:
354
354
  _reject_thinking_retention_conflict(
355
355
  self,
356
356
  "preserve_thinking",
@@ -377,7 +377,7 @@ class GLM5RendererConfig(BaseRendererConfig):
377
377
  to ``"all"``."""
378
378
 
379
379
  @model_validator(mode="after")
380
- def _check_thinking_retention(self):
380
+ def _check_thinking_retention(self) -> Self:
381
381
  _reject_thinking_retention_conflict(
382
382
  self,
383
383
  "clear_thinking",
@@ -401,7 +401,7 @@ class GLM51RendererConfig(BaseRendererConfig):
401
401
  """See :class:`GLM5RendererConfig.clear_thinking`."""
402
402
 
403
403
  @model_validator(mode="after")
404
- def _check_thinking_retention(self):
404
+ def _check_thinking_retention(self) -> Self:
405
405
  _reject_thinking_retention_conflict(
406
406
  self,
407
407
  "clear_thinking",
@@ -424,7 +424,7 @@ class GLM53RendererConfig(BaseRendererConfig):
424
424
  """Reasoning-effort system preamble emitted by the canonical template."""
425
425
 
426
426
  @model_validator(mode="after")
427
- def _check_thinking_retention(self):
427
+ def _check_thinking_retention(self) -> Self:
428
428
  _reject_thinking_retention_conflict(
429
429
  self,
430
430
  "clear_thinking",
@@ -506,7 +506,7 @@ class Hy3RendererConfig(BaseRendererConfig):
506
506
  and suppresses the generation prompt (``add_generation_prompt=False``)."""
507
507
 
508
508
  @model_validator(mode="after")
509
- def _check_thinking_retention(self):
509
+ def _check_thinking_retention(self) -> Self:
510
510
  if self.preserved_thinking is not None and self.thinking_retention is not None:
511
511
  implied = "all" if self.preserved_thinking else "tool_cycle"
512
512
  if self.thinking_retention != implied:
@@ -566,7 +566,7 @@ class InklingRendererConfig(BaseRendererConfig):
566
566
  _internal_fields = frozenset({"image_cache_max", "audio_cache_max"})
567
567
 
568
568
  @model_validator(mode="after")
569
- def _check_reasoning_effort(self):
569
+ def _check_reasoning_effort(self) -> Self:
570
570
  eff = self.reasoning_effort
571
571
  if isinstance(eff, str):
572
572
  if eff.strip() not in INKLING_EFFORT_MAP:
@@ -631,7 +631,7 @@ class GptOssRendererConfig(BaseRendererConfig):
631
631
  )
632
632
 
633
633
  @model_validator(mode="after")
634
- def _check_thinking_retention(self):
634
+ def _check_thinking_retention(self) -> Self:
635
635
  _reject_thinking_retention_conflict(
636
636
  self,
637
637
  "auto_drop_analysis",
@@ -836,7 +836,7 @@ class Nemotron3RendererConfig(BaseRendererConfig):
836
836
  policy to ``"all"``."""
837
837
 
838
838
  @model_validator(mode="after")
839
- def _check_thinking_retention(self):
839
+ def _check_thinking_retention(self) -> Self:
840
840
  _reject_thinking_retention_conflict(
841
841
  self,
842
842
  "truncate_history_thinking",
@@ -877,7 +877,7 @@ class Nemotron3UltraRendererConfig(BaseRendererConfig):
877
877
  """See :class:`Nemotron3RendererConfig.truncate_history_thinking`."""
878
878
 
879
879
  @model_validator(mode="after")
880
- def _check_thinking_retention(self):
880
+ def _check_thinking_retention(self) -> Self:
881
881
  _reject_thinking_retention_conflict(
882
882
  self,
883
883
  "truncate_history_thinking",
@@ -913,7 +913,7 @@ class Nemotron35RendererConfig(BaseRendererConfig):
913
913
  """See :class:`Nemotron3RendererConfig.truncate_history_thinking`."""
914
914
 
915
915
  @model_validator(mode="after")
916
- def _check_thinking_retention(self):
916
+ def _check_thinking_retention(self) -> Self:
917
917
  _reject_thinking_retention_conflict(
918
918
  self,
919
919
  "truncate_history_thinking",
@@ -984,7 +984,7 @@ class DeepSeekV4RendererConfig(BaseRendererConfig):
984
984
  """
985
985
 
986
986
  @model_validator(mode="after")
987
- def _check_thinking_retention(self):
987
+ def _check_thinking_retention(self) -> Self:
988
988
  _reject_thinking_retention_conflict(
989
989
  self,
990
990
  "drop_thinking",
@@ -1019,7 +1019,7 @@ class CustomRendererConfig(BaseRendererConfig):
1019
1019
  _internal_fields = frozenset({"import_path"})
1020
1020
 
1021
1021
  @model_validator(mode="after")
1022
- def _validate_renderer_fields(self):
1022
+ def _validate_renderer_fields(self) -> Self:
1023
1023
  # Fail at config load, not at the first render: the import path must
1024
1024
  # resolve and the renderer's config must accept the other fields.
1025
1025
  from renderers.custom import custom_renderer_config
@@ -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,