renderers 0.1.11.dev5__tar.gz → 0.1.12__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.11.dev5 → renderers-0.1.12}/.github/workflows/test.yml +1 -1
  2. {renderers-0.1.11.dev5 → renderers-0.1.12}/PKG-INFO +138 -8
  3. {renderers-0.1.11.dev5 → renderers-0.1.12}/README.md +136 -6
  4. {renderers-0.1.11.dev5 → renderers-0.1.12}/docs/renderer-config.md +2 -0
  5. {renderers-0.1.11.dev5 → renderers-0.1.12}/examples/sglang/multiturn_generate_sglang.py +7 -3
  6. {renderers-0.1.11.dev5 → renderers-0.1.12}/examples/sglang/online_multiturn_sglang.py +5 -3
  7. {renderers-0.1.11.dev5 → renderers-0.1.12}/examples/tinker/multiturn_generate_tinker.py +6 -2
  8. {renderers-0.1.11.dev5 → renderers-0.1.12}/examples/transformers/multiturn_generate_transformers.py +7 -3
  9. {renderers-0.1.11.dev5 → renderers-0.1.12}/examples/vllm/multiturn_generate_vllm.py +7 -3
  10. {renderers-0.1.11.dev5 → renderers-0.1.12}/pyproject.toml +1 -1
  11. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/__init__.py +23 -9
  12. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/_version.py +2 -2
  13. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/base.py +170 -53
  14. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/client.py +176 -19
  15. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/configs.py +76 -16
  16. renderers-0.1.12/renderers/custom.py +99 -0
  17. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/deepseek_v3.py +31 -0
  18. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/deepseek_v4.py +33 -7
  19. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/default.py +58 -28
  20. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/gemma4.py +84 -21
  21. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/glm45.py +28 -36
  22. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/glm5.py +245 -52
  23. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/gpt_oss.py +67 -3
  24. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/hy3.py +36 -0
  25. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/inkling.py +52 -0
  26. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/kimi_k2.py +32 -0
  27. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/kimi_k25.py +87 -99
  28. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/laguna_xs2.py +49 -0
  29. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/llama_3.py +6 -0
  30. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/minimax_m2.py +29 -0
  31. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/nemotron3.py +280 -0
  32. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/parsers.py +4 -6
  33. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/parsing.py +434 -382
  34. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/prime_qwen3.py +27 -0
  35. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/qwen3.py +45 -7
  36. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/qwen35.py +81 -31
  37. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/qwen38.py +31 -5
  38. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/qwen3_vl.py +72 -26
  39. renderers-0.1.12/renderers/reasoning.py +244 -0
  40. renderers-0.1.12/renderers/tagged.py +192 -0
  41. renderers-0.1.12/tests/audit_prefix_stability.py +81 -0
  42. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/conftest.py +12 -0
  43. renderers-0.1.12/tests/fixtures/prefix_stability.json +792 -0
  44. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/parity.py +8 -4
  45. renderers-0.1.12/tests/reference_continuations.py +164 -0
  46. renderers-0.1.12/tests/replay.py +48 -0
  47. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_bridge.py +30 -0
  48. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_build_helpers.py +104 -0
  49. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_client.py +194 -3
  50. renderers-0.1.12/tests/test_custom.py +194 -0
  51. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_deepseek_r1.py +6 -0
  52. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_deepseek_v4.py +44 -11
  53. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_disabled_thinking_stability.py +5 -0
  54. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_gemma4.py +6 -1
  55. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_glm_tool_name_validation.py +6 -1
  56. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_hy3.py +9 -1
  57. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_inkling.py +5 -0
  58. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_laguna_m1.py +6 -1
  59. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_laguna_xs21.py +41 -4
  60. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_message_indices.py +63 -0
  61. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_multimodal.py +36 -6
  62. renderers-0.1.12/tests/test_nemotron35_vlm.py +322 -0
  63. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_parity.py +10 -2
  64. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_parse_response.py +36 -16
  65. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_parse_response_robustness.py +18 -19
  66. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_parsers.py +24 -0
  67. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_qwen38.py +79 -0
  68. renderers-0.1.12/tests/test_reasoning_boundaries.py +630 -0
  69. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_renderer_config.py +13 -1
  70. renderers-0.1.12/tests/test_roundtrip.py +211 -0
  71. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_sampled_mask.py +2 -0
  72. renderers-0.1.12/tests/test_tagged_rollout.py +258 -0
  73. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_tool_arg_type_preservation.py +5 -0
  74. {renderers-0.1.11.dev5 → renderers-0.1.12}/uv.lock +269 -292
  75. renderers-0.1.11.dev5/tests/test_roundtrip.py +0 -323
  76. {renderers-0.1.11.dev5 → renderers-0.1.12}/.github/workflows/publish-dev.yml +0 -0
  77. {renderers-0.1.11.dev5 → renderers-0.1.12}/.github/workflows/publish.yml +0 -0
  78. {renderers-0.1.11.dev5 → renderers-0.1.12}/.github/workflows/style.yml +0 -0
  79. {renderers-0.1.11.dev5 → renderers-0.1.12}/.gitignore +0 -0
  80. {renderers-0.1.11.dev5 → renderers-0.1.12}/.pre-commit-config.yaml +0 -0
  81. {renderers-0.1.11.dev5 → renderers-0.1.12}/LICENSE +0 -0
  82. {renderers-0.1.11.dev5 → renderers-0.1.12}/examples/README.md +0 -0
  83. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/deepseek_r1.py +0 -0
  84. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/laguna_s21.py +0 -0
  85. {renderers-0.1.11.dev5 → renderers-0.1.12}/renderers/qwen36.py +0 -0
  86. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/reference_rendering.py +0 -0
  87. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_incremental.py +0 -0
  88. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_is_content.py +0 -0
  89. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_kimi_k25_tool_schema.py +0 -0
  90. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_llama_3.py +0 -0
  91. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_load_tokenizer.py +0 -0
  92. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_message_tool_names.py +0 -0
  93. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_nemotron3_ultra.py +0 -0
  94. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_offsetless_tokenizers.py +0 -0
  95. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_preserve_thinking.py +0 -0
  96. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_qwen35_size_coverage.py +0 -0
  97. {renderers-0.1.11.dev5 → renderers-0.1.12}/tests/test_tokens_per_message.py +0 -0
@@ -21,7 +21,7 @@ jobs:
21
21
  strategy:
22
22
  fail-fast: false
23
23
  matrix:
24
- python-version: ["3.10", "3.11", "3.12", "3.13"]
24
+ python-version: ["3.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.11.dev5
3
+ Version: 0.1.12
4
4
  Summary: Chat template renderers — deterministic message-to-token conversion for LLM training
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
7
- Requires-Python: <3.14,>=3.10
7
+ Requires-Python: <3.15,>=3.11
8
8
  Requires-Dist: jinja2
9
9
  Requires-Dist: numpy
10
10
  Requires-Dist: openai-harmony>=0.0.4
@@ -63,10 +63,47 @@ prompt_ids = r.render_ids(
63
63
  # Feed prompt_ids to a Token-In, Token-Out endpoint.
64
64
  # It returns completion_ids sampled by the model.
65
65
 
66
- parsed = r.parse_response(completion_ids)
66
+ parsed = r.parse_response(completion_ids, prompt_ids=prompt_ids)
67
67
  # ParsedResponse(content=..., reasoning_content=..., tool_calls=...)
68
68
  ```
69
69
 
70
+ Pass the exact sampling `prompt_ids` into `parse_response` to identify reasoning
71
+ opened by the generation prompt. Without its closing marker, the sampled text
72
+ stays in `reasoning_content`, with empty `content` and no executable tool calls,
73
+ even if the engine reports `stop`. This applies to every supported reasoning
74
+ format, including Harmony analysis and Gemma's post-tool thought channel.
75
+ Omitting `prompt_ids`, passing `None`, and passing `[]` all parse the completion
76
+ as self-contained: reasoning needs its own initial opener or channel header.
77
+ Generation settings never supply missing parsing context. If the prompt supplied
78
+ the opener, pass that prompt; `generate` does this automatically.
79
+
80
+ Where vLLM 0.26's parser does the same, the format's atomic tool-call opener
81
+ also ends reasoning that has no closing marker: Qwen3 / Qwen3-VL / Qwen3.5+,
82
+ Prime-Qwen3, Nemotron-3, GLM, MiniMax-M2, DeepSeek-V4, Kimi-K2 / K2.5, Gemma 4,
83
+ and Inkling. The call parses normally, and `bridge_to_next_turn` extends it
84
+ without inserting a close. A closing marker later in the completion still wins:
85
+ tool markup before it stays reasoning. Inkling ends thinking and tool segments
86
+ with the same `<|end_message|>`, so there the first opener decides.
87
+
88
+ For reasoning-first formats, only an initial `<think>` (after any assistant
89
+ header) or reasoning already opened in the prompt starts reasoning. After its
90
+ first closing marker, the rest is content. Later think markers remain literal
91
+ content: `Before<think>example</think>After` is entirely content. Harmony,
92
+ Gemma, and Inkling follow their explicit channel or message-segment grammar.
93
+
94
+ `ParsedResponse.reasoning_complete` is `False` while a reasoning channel is open,
95
+ `True` when it is closed (or the format has no reasoning channel), and `None` when
96
+ its state cannot be determined. It does not certify tool syntax, normal engine
97
+ termination, nonempty final output, or task success. The generate client forwards
98
+ this narrow signal and passes prompt context automatically. Custom renderers used
99
+ with `generate` should accept `parse_response(..., tools=None, prompt_ids=None)`;
100
+ formats that do not need prompt context can ignore it.
101
+
102
+ Summary consumers should require normal termination (`finish_reason="stop"`),
103
+ nonempty final content, and no tool calls. A length-limited final answer remains
104
+ truncated even when its reasoning channel is closed. Keep rejected attempts in
105
+ usage/training accounting, and replace conversation history only after acceptance.
106
+
70
107
  For the next turn, extend the previous sampled stream instead of re-rendering history:
71
108
 
72
109
  ```python
@@ -77,12 +114,14 @@ next_prompt_ids = r.bridge_to_next_turn(
77
114
  )
78
115
  ```
79
116
 
80
- Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.8`, `gemma4`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `deepseek-v4` (V4 Flash 0731), `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `laguna-xs.2`, `laguna-xs-2.1`, `laguna-s-2.1`, `laguna-m.1`, `nemotron-3`, `nemotron-3-ultra`, `nemotron-3.5`, `llama-3`, `gpt-oss`, `hy3`, `inkling` / `inkling-small`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper. `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.8`, `gemma4`, `kimi-k2.5` / `kimi-k2.6`, and the Inkling checkpoints are multimodal (Inkling handles both image **and** audio).
117
+ Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.8`, `gemma4`, `glm-5`, `glm-5.1`, `glm-5.3`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `deepseek-v4` (V4 Flash 0731), `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `laguna-xs.2`, `laguna-xs-2.1`, `laguna-s-2.1`, `laguna-m.1`, `nemotron-3`, `nemotron-3-ultra`, `nemotron-3.5`, `llama-3`, `gpt-oss`, `hy3`, `inkling` / `inkling-small`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper. `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.8`, `gemma4`, `kimi-k2.5` / `kimi-k2.6`, and the Inkling checkpoints are multimodal (Inkling handles both image **and** audio).
81
118
 
82
119
  ## API
83
120
 
84
121
  ```python
85
122
  class Renderer(Protocol):
123
+ @property
124
+ def is_prefix_stable(self) -> bool: ...
86
125
  def render(messages, *, tools=None, add_generation_prompt=False) -> RenderedTokens: ...
87
126
  def render_ids(messages, *, tools=None, add_generation_prompt=False) -> list[int]: ...
88
127
  def parse_response(token_ids) -> ParsedResponse: ...
@@ -90,18 +129,49 @@ class Renderer(Protocol):
90
129
  def bridge_to_next_turn(prev_prompt_ids, prev_completion_ids, new_messages, *, tools=None) -> list[int] | None: ...
91
130
  ```
92
131
 
93
- - `RenderedTokens` carries `token_ids` **and** `message_indices` — one entry per token attributing each to its source message (`-1` for structural scaffolding). Lets `build_training_sample` build a per-token loss mask in one render.
132
+ - `RenderedTokens` carries `token_ids` **and** `message_indices` — one entry per token attributing each to its owning message (`-1` for structural scaffolding). Sampled closing markers belong to their assistant, including GLM markers emitted at the next user/tool boundary; user/tool bodies keep their own ownership.
133
+ - `build_training_sample(..., message_loss_mask=[0, 0, 0, 1])` keeps all messages as context but restricts loss to selected messages. Supply one boolean or integer `0`/`1` per message, or `None` for the normal mask. Excluding an assistant also excludes its closing token. Selection never enables targets disabled by the existing role/content settings or changes token IDs.
94
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.
95
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`.
96
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
+
97
166
  ### `bridge_to_next_turn` (the core contract)
98
167
 
99
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.
100
169
 
101
170
  Each hand-coded bridge:
102
- 1. Anchors at the previous turn's canonical close token. On clean stops it's already in `prev_completion_ids`. On truncation, the renderer synthesizes the close as non-loss prompt context.
103
- 2. Refuses assistant content in `new_messages` — re-rendering sampled tokens would replace them with canonical template bytes.
104
- 3. Renders only the new messages in the framing the model family expects.
171
+ 1. Checks the previous turn's reasoning state. If reasoning is unfinished and no stop was sampled, append its closing marker as non-loss prompt context before the canonical turn close. If a stop was already sampled inside reasoning, return `None`: inserting a close before it would violate the exact-prefix contract. Parsing never repairs the sampled output.
172
+ 2. Anchors at the previous turn's canonical close token. On clean stops it's already in `prev_completion_ids`. On truncation, the renderer synthesizes the close as non-loss prompt context.
173
+ 3. Refuses assistant content in `new_messages` — re-rendering sampled tokens would replace them with canonical template bytes.
174
+ 4. Renders only the new messages in the framing the model family expects.
105
175
 
106
176
  `DefaultRenderer.bridge_to_next_turn` returns `None` unconditionally — the template's close is unknown, so the contract can't be proven.
107
177
 
@@ -183,6 +253,42 @@ One shared behaviour flag lives on typed renderer configs: `thinking_retention`,
183
253
 
184
254
  Generic `thinking_retention` does **not** change full `render()` output: a full re-render always follows the Python chat-template implementation. Only real template knobs can change full-render thinking behaviour. GLM-5 `clear_thinking=False`, Nemotron-3 `truncate_history_thinking=False`, Qwen3.6 `preserve_thinking=True`, and GPT-OSS `auto_drop_analysis=False` all imply bridge policy `"all"`; no-thinking generation knobs also imply `"all"` when `thinking_retention` is unset. Setting a direct keep/drop template knob and a contradictory `thinking_retention` raises at config-load. The full per-renderer mapping lives in [`docs/renderer-config.md`](docs/renderer-config.md).
185
255
 
256
+ ## Custom renderers
257
+
258
+ A renderer can live outside this package. Point a `custom` config at the class with `import_path`, as `my_module.Class` or `path/to/file.py:Class`; relative file paths resolve against the working directory. The class sets `config_class` to its own `BaseRendererConfig` subclass, which validates every other field of the config and supplies its `chat_template_kwargs` allowlist. For example, a GLM-5.3 renderer whose generation prompt prefills an empty think block, so the model starts at the answer:
259
+
260
+ ```python
261
+ from typing import Literal
262
+
263
+ from renderers.configs import GLM53RendererConfig
264
+ from renderers.glm5 import GLM53Renderer
265
+
266
+
267
+ class NoThinkingGLM53Config(GLM53RendererConfig):
268
+ name: Literal["glm-5.3-no-thinking"] = "glm-5.3-no-thinking"
269
+
270
+
271
+ class NoThinkingGLM53Renderer(GLM53Renderer):
272
+ config_class = NoThinkingGLM53Config
273
+
274
+ def _emit_generation_prompt(self, emit_special) -> None:
275
+ emit_special(self._assistant, -1, is_sampled=False, is_content=False)
276
+ emit_special(self._think, -1, is_sampled=False, is_content=False)
277
+ emit_special(self._think_end, -1, is_sampled=False, is_content=False)
278
+ ```
279
+
280
+ ```python
281
+ from renderers import CustomRendererConfig, create_renderer
282
+
283
+ config = CustomRendererConfig(
284
+ import_path="my_renderers.glm53_no_thinking.NoThinkingGLM53Renderer",
285
+ clear_thinking=True,
286
+ )
287
+ renderer = create_renderer(tokenizer, config)
288
+ ```
289
+
290
+ Downstream TOML configs select it the same way, with `name = "custom"` and an `import_path`.
291
+
186
292
  ## `DefaultRenderer`
187
293
 
188
294
  Fallback for unsupported text-only models. Wraps `apply_chat_template` and accepts `tool_parser` / `reasoning_parser` (vLLM convention) plus arbitrary Jinja kwargs via `DefaultRendererConfig`'s `extra="allow"`. Explicit `thinking_retention` is rejected: `bridge_to_next_turn` returns `None` because the template's close is unknown, so multi-turn rollouts fall back to full re-render. Implementing a hand-coded renderer is a few hundred lines of Python (`render_ids` + `parse_response` + `bridge_to_next_turn`) and is the only path that closes the failure modes above by construction.
@@ -201,6 +307,30 @@ uv run pytest
201
307
 
202
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.
203
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
+
204
334
  ## License
205
335
 
206
336
  Licensed under the [Apache License, Version 2.0](LICENSE).
@@ -43,10 +43,47 @@ prompt_ids = r.render_ids(
43
43
  # Feed prompt_ids to a Token-In, Token-Out endpoint.
44
44
  # It returns completion_ids sampled by the model.
45
45
 
46
- parsed = r.parse_response(completion_ids)
46
+ parsed = r.parse_response(completion_ids, prompt_ids=prompt_ids)
47
47
  # ParsedResponse(content=..., reasoning_content=..., tool_calls=...)
48
48
  ```
49
49
 
50
+ Pass the exact sampling `prompt_ids` into `parse_response` to identify reasoning
51
+ opened by the generation prompt. Without its closing marker, the sampled text
52
+ stays in `reasoning_content`, with empty `content` and no executable tool calls,
53
+ even if the engine reports `stop`. This applies to every supported reasoning
54
+ format, including Harmony analysis and Gemma's post-tool thought channel.
55
+ Omitting `prompt_ids`, passing `None`, and passing `[]` all parse the completion
56
+ as self-contained: reasoning needs its own initial opener or channel header.
57
+ Generation settings never supply missing parsing context. If the prompt supplied
58
+ the opener, pass that prompt; `generate` does this automatically.
59
+
60
+ Where vLLM 0.26's parser does the same, the format's atomic tool-call opener
61
+ also ends reasoning that has no closing marker: Qwen3 / Qwen3-VL / Qwen3.5+,
62
+ Prime-Qwen3, Nemotron-3, GLM, MiniMax-M2, DeepSeek-V4, Kimi-K2 / K2.5, Gemma 4,
63
+ and Inkling. The call parses normally, and `bridge_to_next_turn` extends it
64
+ without inserting a close. A closing marker later in the completion still wins:
65
+ tool markup before it stays reasoning. Inkling ends thinking and tool segments
66
+ with the same `<|end_message|>`, so there the first opener decides.
67
+
68
+ For reasoning-first formats, only an initial `<think>` (after any assistant
69
+ header) or reasoning already opened in the prompt starts reasoning. After its
70
+ first closing marker, the rest is content. Later think markers remain literal
71
+ content: `Before<think>example</think>After` is entirely content. Harmony,
72
+ Gemma, and Inkling follow their explicit channel or message-segment grammar.
73
+
74
+ `ParsedResponse.reasoning_complete` is `False` while a reasoning channel is open,
75
+ `True` when it is closed (or the format has no reasoning channel), and `None` when
76
+ its state cannot be determined. It does not certify tool syntax, normal engine
77
+ termination, nonempty final output, or task success. The generate client forwards
78
+ this narrow signal and passes prompt context automatically. Custom renderers used
79
+ with `generate` should accept `parse_response(..., tools=None, prompt_ids=None)`;
80
+ formats that do not need prompt context can ignore it.
81
+
82
+ Summary consumers should require normal termination (`finish_reason="stop"`),
83
+ nonempty final content, and no tool calls. A length-limited final answer remains
84
+ truncated even when its reasoning channel is closed. Keep rejected attempts in
85
+ usage/training accounting, and replace conversation history only after acceptance.
86
+
50
87
  For the next turn, extend the previous sampled stream instead of re-rendering history:
51
88
 
52
89
  ```python
@@ -57,12 +94,14 @@ next_prompt_ids = r.bridge_to_next_turn(
57
94
  )
58
95
  ```
59
96
 
60
- Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.8`, `gemma4`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `deepseek-v4` (V4 Flash 0731), `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `laguna-xs.2`, `laguna-xs-2.1`, `laguna-s-2.1`, `laguna-m.1`, `nemotron-3`, `nemotron-3-ultra`, `nemotron-3.5`, `llama-3`, `gpt-oss`, `hy3`, `inkling` / `inkling-small`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper. `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.8`, `gemma4`, `kimi-k2.5` / `kimi-k2.6`, and the Inkling checkpoints are multimodal (Inkling handles both image **and** audio).
97
+ Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.8`, `gemma4`, `glm-5`, `glm-5.1`, `glm-5.3`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `deepseek-v4` (V4 Flash 0731), `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `laguna-xs.2`, `laguna-xs-2.1`, `laguna-s-2.1`, `laguna-m.1`, `nemotron-3`, `nemotron-3-ultra`, `nemotron-3.5`, `llama-3`, `gpt-oss`, `hy3`, `inkling` / `inkling-small`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper. `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.8`, `gemma4`, `kimi-k2.5` / `kimi-k2.6`, and the Inkling checkpoints are multimodal (Inkling handles both image **and** audio).
61
98
 
62
99
  ## API
63
100
 
64
101
  ```python
65
102
  class Renderer(Protocol):
103
+ @property
104
+ def is_prefix_stable(self) -> bool: ...
66
105
  def render(messages, *, tools=None, add_generation_prompt=False) -> RenderedTokens: ...
67
106
  def render_ids(messages, *, tools=None, add_generation_prompt=False) -> list[int]: ...
68
107
  def parse_response(token_ids) -> ParsedResponse: ...
@@ -70,18 +109,49 @@ class Renderer(Protocol):
70
109
  def bridge_to_next_turn(prev_prompt_ids, prev_completion_ids, new_messages, *, tools=None) -> list[int] | None: ...
71
110
  ```
72
111
 
73
- - `RenderedTokens` carries `token_ids` **and** `message_indices` — one entry per token attributing each to its source message (`-1` for structural scaffolding). Lets `build_training_sample` build a per-token loss mask in one render.
112
+ - `RenderedTokens` carries `token_ids` **and** `message_indices` — one entry per token attributing each to its owning message (`-1` for structural scaffolding). Sampled closing markers belong to their assistant, including GLM markers emitted at the next user/tool boundary; user/tool bodies keep their own ownership.
113
+ - `build_training_sample(..., message_loss_mask=[0, 0, 0, 1])` keeps all messages as context but restricts loss to selected messages. Supply one boolean or integer `0`/`1` per message, or `None` for the normal mask. Excluding an assistant also excludes its closing token. Selection never enables targets disabled by the existing role/content settings or changes token IDs.
74
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.
75
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`.
76
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
+
77
146
  ### `bridge_to_next_turn` (the core contract)
78
147
 
79
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.
80
149
 
81
150
  Each hand-coded bridge:
82
- 1. Anchors at the previous turn's canonical close token. On clean stops it's already in `prev_completion_ids`. On truncation, the renderer synthesizes the close as non-loss prompt context.
83
- 2. Refuses assistant content in `new_messages` — re-rendering sampled tokens would replace them with canonical template bytes.
84
- 3. Renders only the new messages in the framing the model family expects.
151
+ 1. Checks the previous turn's reasoning state. If reasoning is unfinished and no stop was sampled, append its closing marker as non-loss prompt context before the canonical turn close. If a stop was already sampled inside reasoning, return `None`: inserting a close before it would violate the exact-prefix contract. Parsing never repairs the sampled output.
152
+ 2. Anchors at the previous turn's canonical close token. On clean stops it's already in `prev_completion_ids`. On truncation, the renderer synthesizes the close as non-loss prompt context.
153
+ 3. Refuses assistant content in `new_messages` — re-rendering sampled tokens would replace them with canonical template bytes.
154
+ 4. Renders only the new messages in the framing the model family expects.
85
155
 
86
156
  `DefaultRenderer.bridge_to_next_turn` returns `None` unconditionally — the template's close is unknown, so the contract can't be proven.
87
157
 
@@ -163,6 +233,42 @@ One shared behaviour flag lives on typed renderer configs: `thinking_retention`,
163
233
 
164
234
  Generic `thinking_retention` does **not** change full `render()` output: a full re-render always follows the Python chat-template implementation. Only real template knobs can change full-render thinking behaviour. GLM-5 `clear_thinking=False`, Nemotron-3 `truncate_history_thinking=False`, Qwen3.6 `preserve_thinking=True`, and GPT-OSS `auto_drop_analysis=False` all imply bridge policy `"all"`; no-thinking generation knobs also imply `"all"` when `thinking_retention` is unset. Setting a direct keep/drop template knob and a contradictory `thinking_retention` raises at config-load. The full per-renderer mapping lives in [`docs/renderer-config.md`](docs/renderer-config.md).
165
235
 
236
+ ## Custom renderers
237
+
238
+ A renderer can live outside this package. Point a `custom` config at the class with `import_path`, as `my_module.Class` or `path/to/file.py:Class`; relative file paths resolve against the working directory. The class sets `config_class` to its own `BaseRendererConfig` subclass, which validates every other field of the config and supplies its `chat_template_kwargs` allowlist. For example, a GLM-5.3 renderer whose generation prompt prefills an empty think block, so the model starts at the answer:
239
+
240
+ ```python
241
+ from typing import Literal
242
+
243
+ from renderers.configs import GLM53RendererConfig
244
+ from renderers.glm5 import GLM53Renderer
245
+
246
+
247
+ class NoThinkingGLM53Config(GLM53RendererConfig):
248
+ name: Literal["glm-5.3-no-thinking"] = "glm-5.3-no-thinking"
249
+
250
+
251
+ class NoThinkingGLM53Renderer(GLM53Renderer):
252
+ config_class = NoThinkingGLM53Config
253
+
254
+ def _emit_generation_prompt(self, emit_special) -> None:
255
+ emit_special(self._assistant, -1, is_sampled=False, is_content=False)
256
+ emit_special(self._think, -1, is_sampled=False, is_content=False)
257
+ emit_special(self._think_end, -1, is_sampled=False, is_content=False)
258
+ ```
259
+
260
+ ```python
261
+ from renderers import CustomRendererConfig, create_renderer
262
+
263
+ config = CustomRendererConfig(
264
+ import_path="my_renderers.glm53_no_thinking.NoThinkingGLM53Renderer",
265
+ clear_thinking=True,
266
+ )
267
+ renderer = create_renderer(tokenizer, config)
268
+ ```
269
+
270
+ Downstream TOML configs select it the same way, with `name = "custom"` and an `import_path`.
271
+
166
272
  ## `DefaultRenderer`
167
273
 
168
274
  Fallback for unsupported text-only models. Wraps `apply_chat_template` and accepts `tool_parser` / `reasoning_parser` (vLLM convention) plus arbitrary Jinja kwargs via `DefaultRendererConfig`'s `extra="allow"`. Explicit `thinking_retention` is rejected: `bridge_to_next_turn` returns `None` because the template's close is unknown, so multi-turn rollouts fall back to full re-render. Implementing a hand-coded renderer is a few hundred lines of Python (`render_ids` + `parse_response` + `bridge_to_next_turn`) and is the only path that closes the failure modes above by construction.
@@ -181,6 +287,30 @@ uv run pytest
181
287
 
182
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.
183
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
+
184
314
  ## License
185
315
 
186
316
  Licensed under the [Apache License, Version 2.0](LICENSE).
@@ -34,6 +34,7 @@ product in `tests/test_parity.py`.
34
34
  | Qwen3-VL | `Qwen3VLRendererConfig` | `add_vision_id` | `image_cache_max` |
35
35
  | Gemma 4 | `Gemma4RendererConfig` | `enable_thinking`, `preserve_thinking` | `image_cache_max` |
36
36
  | GLM-5 / 5.1 | `GLM5RendererConfig` / `GLM51RendererConfig` | `enable_thinking`, `clear_thinking` | - |
37
+ | GLM-5.3 | `GLM53RendererConfig` | `clear_thinking`, `reasoning_effort` | - |
37
38
  | GLM-4.5 | `GLM45RendererConfig` | `enable_thinking` | - |
38
39
  | gpt-oss | `GptOssRendererConfig` | `reasoning_effort`, `conversation_start_date` | `use_system_prompt`, `knowledge_cutoff`, `model_identity`, `auto_drop_analysis` |
39
40
  | Hy3 | `Hy3RendererConfig` | `reasoning_effort`, `preserved_thinking`, `is_training`, `raw_last_assistant`, `fallback_strategy` | - |
@@ -140,6 +141,7 @@ the knobs its template actually exposes:
140
141
  | Qwen3.8 | `preserve_thinking=True -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
141
142
  | Gemma 4 | `preserve_thinking=True -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
142
143
  | GLM-5 / 5.1 | `clear_thinking=False -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
144
+ | GLM-5.3 | `clear_thinking=False -> all`, else `tool_cycle` |
143
145
  | GLM-4.5 | `enable_thinking=False -> all`, else `tool_cycle` |
144
146
  | gpt-oss | `auto_drop_analysis=False -> all`, else `tool_cycle` |
145
147
  | Hy3 | `preserved_thinking=True -> all`, else `tool_cycle` |
@@ -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",
@@ -138,7 +138,7 @@ def main() -> None:
138
138
  )
139
139
  output1 = engine.generate(input_ids=prompt_ids, sampling_params=sampling)
140
140
  completion1 = completion_ids(output1, prompt_ids)
141
- parsed1 = renderer.parse_response(completion1)
141
+ parsed1 = renderer.parse_response(completion1, prompt_ids=prompt_ids)
142
142
  print_parsed(label, "turn 1", parsed1)
143
143
 
144
144
  assistant = {"role": "assistant", "content": parsed1.content}
@@ -197,7 +197,11 @@ def main() -> None:
197
197
 
198
198
  output2 = engine.generate(input_ids=bridged_ids, sampling_params=sampling)
199
199
  completion2 = completion_ids(output2, bridged_ids)
200
- print_parsed(label, "turn 2", renderer.parse_response(completion2))
200
+ print_parsed(
201
+ label,
202
+ "turn 2",
203
+ renderer.parse_response(completion2, prompt_ids=bridged_ids),
204
+ )
201
205
 
202
206
  engine.shutdown()
203
207
 
@@ -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",
@@ -161,7 +161,7 @@ async def run_one(
161
161
  max_new_tokens=max_new_tokens,
162
162
  )
163
163
  completion1 = completion_ids(output1, prompt_ids)
164
- parsed1 = renderer.parse_response(completion1)
164
+ parsed1 = renderer.parse_response(completion1, prompt_ids=prompt_ids)
165
165
  print_parsed(label, "turn 1", parsed1)
166
166
 
167
167
  assistant: dict[str, Any] = {"role": "assistant", "content": parsed1.content}
@@ -226,7 +226,9 @@ async def run_one(
226
226
  max_new_tokens=max_new_tokens,
227
227
  )
228
228
  completion2 = completion_ids(output2, bridged_ids)
229
- print_parsed(label, "turn 2", renderer.parse_response(completion2))
229
+ print_parsed(
230
+ label, "turn 2", renderer.parse_response(completion2, prompt_ids=bridged_ids)
231
+ )
230
232
 
231
233
 
232
234
  async def main() -> None:
@@ -128,7 +128,7 @@ async def main() -> None:
128
128
  sampling_params=sampling_params,
129
129
  )
130
130
  completion1 = list(output1.sequences[0].tokens)
131
- parsed1 = renderer.parse_response(completion1)
131
+ parsed1 = renderer.parse_response(completion1, prompt_ids=prompt_ids)
132
132
  print_parsed(label, "turn 1", parsed1)
133
133
 
134
134
  assistant = {"role": "assistant", "content": parsed1.content}
@@ -191,7 +191,11 @@ async def main() -> None:
191
191
  sampling_params=sampling_params,
192
192
  )
193
193
  completion2 = list(output2.sequences[0].tokens)
194
- print_parsed(label, "turn 2", renderer.parse_response(completion2))
194
+ print_parsed(
195
+ label,
196
+ "turn 2",
197
+ renderer.parse_response(completion2, prompt_ids=bridged_ids),
198
+ )
195
199
 
196
200
 
197
201
  if __name__ == "__main__":
@@ -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",
@@ -135,7 +135,7 @@ def main() -> None:
135
135
  pad_token_id=pad_token_id,
136
136
  )[0]
137
137
  completion1 = output1[input_ids.shape[-1] :].tolist()
138
- parsed1 = renderer.parse_response(completion1)
138
+ parsed1 = renderer.parse_response(completion1, prompt_ids=prompt_ids)
139
139
  print_parsed(label, "turn 1", parsed1)
140
140
 
141
141
  assistant = {"role": "assistant", "content": parsed1.content}
@@ -203,7 +203,11 @@ def main() -> None:
203
203
  pad_token_id=pad_token_id,
204
204
  )[0]
205
205
  completion2 = output2[bridged_input_ids.shape[-1] :].tolist()
206
- print_parsed(label, "turn 2", renderer.parse_response(completion2))
206
+ print_parsed(
207
+ label,
208
+ "turn 2",
209
+ renderer.parse_response(completion2, prompt_ids=bridged_ids),
210
+ )
207
211
 
208
212
  del hf_model
209
213
  gc.collect()
@@ -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",
@@ -131,7 +131,7 @@ def main() -> None:
131
131
  use_tqdm=False,
132
132
  )[0]
133
133
  completion1 = list(output1.outputs[0].token_ids)
134
- parsed1 = renderer.parse_response(completion1)
134
+ parsed1 = renderer.parse_response(completion1, prompt_ids=prompt_ids)
135
135
  print_parsed(label, "turn 1", parsed1)
136
136
 
137
137
  assistant = {"role": "assistant", "content": parsed1.content}
@@ -194,7 +194,11 @@ def main() -> None:
194
194
  use_tqdm=False,
195
195
  )[0]
196
196
  completion2 = list(output2.outputs[0].token_ids)
197
- print_parsed(label, "turn 2", renderer.parse_response(completion2))
197
+ print_parsed(
198
+ label,
199
+ "turn 2",
200
+ renderer.parse_response(completion2, prompt_ids=bridged_ids),
201
+ )
198
202
 
199
203
  del llm
200
204
  gc.collect()
@@ -13,7 +13,7 @@ description = "Chat template renderers — deterministic message-to-token conver
13
13
  readme = "README.md"
14
14
  license = "Apache-2.0"
15
15
  license-files = ["LICENSE"]
16
- requires-python = ">=3.10,<3.14"
16
+ requires-python = ">=3.11,<3.15"
17
17
  dependencies = [
18
18
  "numpy",
19
19
  "openai>=1.108.1",