renderers 0.1.8.dev51__tar.gz → 0.1.8.dev52__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 (73) hide show
  1. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/PKG-INFO +21 -17
  2. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/README.md +20 -16
  3. renderers-0.1.8.dev52/docs/renderer-config.md +216 -0
  4. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/_version.py +2 -2
  5. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/base.py +145 -75
  6. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/configs.py +168 -41
  7. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/deepseek_r1.py +1 -0
  8. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/deepseek_v3.py +13 -1
  9. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/default.py +10 -5
  10. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/glm45.py +13 -10
  11. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/glm5.py +20 -16
  12. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/gpt_oss.py +36 -11
  13. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/kimi_k2.py +14 -3
  14. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/kimi_k25.py +13 -10
  15. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/laguna_xs2.py +13 -4
  16. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/llama_3.py +15 -4
  17. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/minimax_m2.py +13 -12
  18. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/nemotron3.py +23 -16
  19. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/qwen3.py +39 -21
  20. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/qwen35.py +33 -20
  21. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/qwen36.py +3 -6
  22. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/qwen3_vl.py +25 -3
  23. renderers-0.1.8.dev52/tests/test_bridge.py +344 -0
  24. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_llama_3.py +8 -6
  25. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_multimodal.py +23 -8
  26. renderers-0.1.8.dev52/tests/test_preserve_thinking.py +97 -0
  27. renderers-0.1.8.dev52/tests/test_renderer_config.py +324 -0
  28. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_renderer_config_parity.py +2 -0
  29. renderers-0.1.8.dev51/docs/renderer-config.md +0 -163
  30. renderers-0.1.8.dev51/tests/test_bridge.py +0 -192
  31. renderers-0.1.8.dev51/tests/test_preserve_thinking.py +0 -477
  32. renderers-0.1.8.dev51/tests/test_renderer_config.py +0 -116
  33. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/.github/workflows/publish-dev.yml +0 -0
  34. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/.github/workflows/publish.yml +0 -0
  35. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/.github/workflows/style.yml +0 -0
  36. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/.github/workflows/test.yml +0 -0
  37. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/.gitignore +0 -0
  38. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/.pre-commit-config.yaml +0 -0
  39. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/LICENSE +0 -0
  40. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/examples/README.md +0 -0
  41. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/examples/sglang/multiturn_generate_sglang.py +0 -0
  42. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/examples/sglang/online_multiturn_sglang.py +0 -0
  43. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/examples/tinker/multiturn_generate_tinker.py +0 -0
  44. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/examples/transformers/multiturn_generate_transformers.py +0 -0
  45. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/examples/vllm/multiturn_generate_vllm.py +0 -0
  46. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/pyproject.toml +0 -0
  47. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/__init__.py +0 -0
  48. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/client.py +0 -0
  49. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/parsers.py +0 -0
  50. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/renderers/parsing.py +0 -0
  51. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/conftest.py +0 -0
  52. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_build_helpers.py +0 -0
  53. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_client.py +0 -0
  54. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_deepseek_r1.py +0 -0
  55. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_gpt_oss_harmony_parity.py +0 -0
  56. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_incremental.py +0 -0
  57. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_is_content.py +0 -0
  58. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_kimi_k25_tool_schema.py +0 -0
  59. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_load_tokenizer.py +0 -0
  60. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_message_indices.py +0 -0
  61. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_message_tool_names.py +0 -0
  62. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_nemotron3_parity.py +0 -0
  63. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_nemotron3_ultra.py +0 -0
  64. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_parse_response.py +0 -0
  65. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_parse_response_robustness.py +0 -0
  66. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_parsers.py +0 -0
  67. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_qwen35_size_coverage.py +0 -0
  68. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_render_ids.py +0 -0
  69. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_roundtrip.py +0 -0
  70. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_sampled_mask.py +0 -0
  71. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_tokens_per_message.py +0 -0
  72. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/tests/test_tool_arg_type_preservation.py +0 -0
  73. {renderers-0.1.8.dev51 → renderers-0.1.8.dev52}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: renderers
3
- Version: 0.1.8.dev51
3
+ Version: 0.1.8.dev52
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
@@ -56,7 +56,7 @@ next_prompt_ids = r.bridge_to_next_turn(
56
56
  )
57
57
  ```
58
58
 
59
- Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `kimi-k2`, `kimi-k2.5`, `nemotron-3`, `nemotron-3-ultra`, `gpt-oss`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper.
59
+ Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `nemotron-3`, `nemotron-3-ultra`, `llama-3`, and `gpt-oss`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper.
60
60
 
61
61
  ## API
62
62
 
@@ -90,7 +90,7 @@ Each hand-coded bridge:
90
90
  r = create_renderer(tok) # AutoRendererConfig is the implicit default
91
91
  ```
92
92
 
93
- Auto-detect matches `tokenizer.name_or_path` against `MODEL_RENDERER_MAP` by **exact match**. Prefix matching is intentionally off — same architecture can ship different chat templates (base vs instruct, fine-tune renames). Fine-tunes must pass an explicit typed config (e.g. `Qwen3RendererConfig()`); unknown names fall back to `DefaultRenderer`.
93
+ Auto-detect matches `tokenizer.name_or_path` against `MODEL_RENDERER_MAP` by **exact match**. Prefix matching is intentionally off — same architecture can ship different chat templates (base vs instruct, fine-tune renames). Fine-tunes must pass an explicit typed config (e.g. `Qwen3RendererConfig()`). Unknown text-only names fall back to `DefaultRenderer`, unless `AutoRendererConfig(thinking_retention=...)` was set; the default renderer cannot implement that bridge policy.
94
94
 
95
95
  ### Pools
96
96
 
@@ -111,7 +111,6 @@ For RL the trainer must see the exact token ids the sampler saw. The standard al
111
111
  - **Boolean round-trip.** Engine emits `false`; client parses to Python `bool(False)`; `apply_chat_template` re-renders via `str(False)` → `"False"`. Capital F. Reproducible on Qwen3.5-35B-A3B + mini-swe-agent-plus at ~50% break rate per rollout.
112
112
  - **BPE retokenization drift.** The same substring tokenizes differently depending on neighbouring bytes. `json` + `p` + `enderer` (3 tokens) vs `jsonp` + `enderer` (2 tokens) when whitespace shifts by one character. Every subsequent token is shifted from there on.
113
113
  - **Tool-call XML drift.** The engine emits a no-arg call with a stylistic empty `</parameter>`; the Jinja re-render of the reconstructed dict drops it. Extension property broken at every such call.
114
- - **Thinking stripped from non-latest assistants.** Some templates strip `<think>…</think>` blocks from prior assistant turns when re-rendering. The recorded stream has the thinking; the next prompt does not.
115
114
  - **Max-seq-len truncation zeroing the anchor.** Client-side `max_seq_len` enforcement zeros `completion_ids` when `prompt_len > max_seq_len`. The bridge anchor is empty, falling back to full re-render — triggering every mode above.
116
115
  - **Scaffold-level history rewriting.** Some agent scaffolds (e.g. opencode's `experimental_repairToolCall`) rewrite tool calls before sending them back as history. The next turn's prompt contains a tool call the model never emitted. *A renderer cannot fix this — the drift happens before rendering.*
117
116
 
@@ -126,7 +125,7 @@ Each break fragments a rollout into multiple training samples — every fragment
126
125
 
127
126
  ## Typed renderer configs
128
127
 
129
- Each renderer accepts a typed pydantic config that pins its template-control kwargs at construction. `create_renderer` and `create_renderer_pool` take one positional `config` argument:
128
+ Each renderer accepts a typed pydantic config at construction. Some fields mirror chat-template kwargs; others configure renderer-only behavior such as image caching, parsers, or Harmony preamble construction. `create_renderer` and `create_renderer_pool` take one positional `config` argument and an optional keyword-only `chat_template_kwargs` mapping:
130
129
 
131
130
  ```python
132
131
  from renderers import (
@@ -137,13 +136,15 @@ from renderers import (
137
136
  DefaultRendererConfig,
138
137
  )
139
138
 
140
- # Auto-resolve renderer from the tokenizer's model name. Carries the
141
- # shared preserve_* flags; template kwargs require an explicit choice.
139
+ # Auto-resolve renderer from the tokenizer's model name.
142
140
  renderer = create_renderer(tokenizer)
143
- renderer = create_renderer(tokenizer, AutoRendererConfig(preserve_all_thinking=True))
141
+ renderer = create_renderer(tokenizer, AutoRendererConfig(thinking_retention="all"))
142
+ renderer = create_renderer(
143
+ tokenizer,
144
+ chat_template_kwargs={"enable_thinking": False},
145
+ )
144
146
 
145
- # Explicit choice — the typed config exposes exactly the fields that
146
- # renderer's chat template honours.
147
+ # Explicit choice — use the renderer-specific fields it exposes.
147
148
  renderer = create_renderer(tokenizer, Qwen3RendererConfig(enable_thinking=False))
148
149
  renderer = create_renderer(tokenizer, GLM5RendererConfig(clear_thinking=False))
149
150
 
@@ -158,21 +159,24 @@ renderer = create_renderer(
158
159
 
159
160
  Discriminated union: every per-renderer config is a variant of `RendererConfig`, dispatched on the `name` field. Bogus combinations (e.g. `add_vision_id` under `name="qwen3"`) error at construction with a `pydantic.ValidationError`. Downstream pydantic configs (prime-rl orchestrator, verifiers `ClientConfig`) hold a single field typed as `RendererConfig` and inherit the same strict-per-variant validation.
160
161
 
161
- Two shared behaviour flags live on every variant via `_BaseRendererConfig`:
162
+ When `chat_template_kwargs` is passed with `config=None` / `AutoRendererConfig`, renderers first resolves the concrete renderer from the model name, then validates those kwargs against that renderer's config. `Auto + unknown model + chat_template_kwargs` fails loudly; use an explicit typed config or explicit `DefaultRendererConfig` for opaque fallback templates.
163
+
164
+ One shared behaviour flag lives on typed renderer configs: `thinking_retention`, an optional bridge-policy override. Leave it unset to derive bridge behaviour from the chat template and its renderer-exposed kwargs.
162
165
 
163
- - `preserve_all_thinking=True` — every past assistant's `reasoning_content` is kept, even when the chat template would drop it.
164
- - `preserve_thinking_between_tool_calls=True` — reasoning is kept on assistants in the in-flight tool cycle (post-last-user A-T-…-A block when it contains a tool response). A new user turn closes the block and drops its thinking.
166
+ - `thinking_retention=None` (default) derive from the chat template / renderer kwargs.
167
+ - `thinking_retention="tool_cycle"` — bridge within the in-flight tool cycle; a new user query falls back to a full re-render.
168
+ - `thinking_retention="all"` — bridge across user-query boundaries when the bridge is otherwise structurally valid.
165
169
 
166
- These OR-compose with template-level toggles (e.g. GLM-5 `clear_thinking`, Nemotron-3 `truncate_history_thinking`): either flag saying "keep" wins. preserve_* can only ever *extend* retention never override a template kwarg into a "drop" decision. The canonical use case is **compaction**: injecting a `user` turn like *"summarize the work so far"* puts every prior assistant in a past cycle, and `preserve_all_thinking=True` keeps reasoning visible end-to-end.
170
+ 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).
167
171
 
168
172
  ## `DefaultRenderer`
169
173
 
170
- Fallback for unsupported models. Wraps `apply_chat_template` and accepts `tool_parser` / `reasoning_parser` (vLLM convention) plus arbitrary Jinja kwargs via `DefaultRendererConfig`'s `extra="allow"`. `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.
174
+ 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.
171
175
 
172
176
  ## Roadmap
173
177
 
174
- - **VLM support.** `ContentPart` is text-only today; `Qwen3VLRenderer` ships only because Qwen3-VL's text-only chat template differs from Qwen3's. Plan: add `ImagePart` / `VideoPart`, multimodal bridges, validate against a Qwen3-VL RL run.
175
- - **Patched chat templates.** Some shipped templates re-tokenize history, normalize JSON, or auto-strip thinking each breaks the extension property. Plan: a `use_patched` opt-in per renderer that renders the same surface form while avoiding known-bad patterns.
178
+ - **VLM expansion.** `ImagePart` support exists for Qwen3-VL and Qwen3.5-family multimodal templates. Remaining work: video support, broader VLM coverage, and more RL validation.
179
+ - **Patched chat templates.** Some shipped templates re-tokenize history or normalize JSON in ways that break token identity. Plan: a `use_patched` opt-in per renderer that renders the same surface form while avoiding known-bad patterns. (Auto-stripping thinking from past turns is *not* one of these — that's intended template behaviour the renderer reproduces; use `thinking_retention` to override it.)
176
180
 
177
181
  ## Testing
178
182
 
@@ -40,7 +40,7 @@ next_prompt_ids = r.bridge_to_next_turn(
40
40
  )
41
41
  ```
42
42
 
43
- Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `kimi-k2`, `kimi-k2.5`, `nemotron-3`, `nemotron-3-ultra`, `gpt-oss`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper.
43
+ Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `nemotron-3`, `nemotron-3-ultra`, `llama-3`, and `gpt-oss`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper.
44
44
 
45
45
  ## API
46
46
 
@@ -74,7 +74,7 @@ Each hand-coded bridge:
74
74
  r = create_renderer(tok) # AutoRendererConfig is the implicit default
75
75
  ```
76
76
 
77
- Auto-detect matches `tokenizer.name_or_path` against `MODEL_RENDERER_MAP` by **exact match**. Prefix matching is intentionally off — same architecture can ship different chat templates (base vs instruct, fine-tune renames). Fine-tunes must pass an explicit typed config (e.g. `Qwen3RendererConfig()`); unknown names fall back to `DefaultRenderer`.
77
+ Auto-detect matches `tokenizer.name_or_path` against `MODEL_RENDERER_MAP` by **exact match**. Prefix matching is intentionally off — same architecture can ship different chat templates (base vs instruct, fine-tune renames). Fine-tunes must pass an explicit typed config (e.g. `Qwen3RendererConfig()`). Unknown text-only names fall back to `DefaultRenderer`, unless `AutoRendererConfig(thinking_retention=...)` was set; the default renderer cannot implement that bridge policy.
78
78
 
79
79
  ### Pools
80
80
 
@@ -95,7 +95,6 @@ For RL the trainer must see the exact token ids the sampler saw. The standard al
95
95
  - **Boolean round-trip.** Engine emits `false`; client parses to Python `bool(False)`; `apply_chat_template` re-renders via `str(False)` → `"False"`. Capital F. Reproducible on Qwen3.5-35B-A3B + mini-swe-agent-plus at ~50% break rate per rollout.
96
96
  - **BPE retokenization drift.** The same substring tokenizes differently depending on neighbouring bytes. `json` + `p` + `enderer` (3 tokens) vs `jsonp` + `enderer` (2 tokens) when whitespace shifts by one character. Every subsequent token is shifted from there on.
97
97
  - **Tool-call XML drift.** The engine emits a no-arg call with a stylistic empty `</parameter>`; the Jinja re-render of the reconstructed dict drops it. Extension property broken at every such call.
98
- - **Thinking stripped from non-latest assistants.** Some templates strip `<think>…</think>` blocks from prior assistant turns when re-rendering. The recorded stream has the thinking; the next prompt does not.
99
98
  - **Max-seq-len truncation zeroing the anchor.** Client-side `max_seq_len` enforcement zeros `completion_ids` when `prompt_len > max_seq_len`. The bridge anchor is empty, falling back to full re-render — triggering every mode above.
100
99
  - **Scaffold-level history rewriting.** Some agent scaffolds (e.g. opencode's `experimental_repairToolCall`) rewrite tool calls before sending them back as history. The next turn's prompt contains a tool call the model never emitted. *A renderer cannot fix this — the drift happens before rendering.*
101
100
 
@@ -110,7 +109,7 @@ Each break fragments a rollout into multiple training samples — every fragment
110
109
 
111
110
  ## Typed renderer configs
112
111
 
113
- Each renderer accepts a typed pydantic config that pins its template-control kwargs at construction. `create_renderer` and `create_renderer_pool` take one positional `config` argument:
112
+ Each renderer accepts a typed pydantic config at construction. Some fields mirror chat-template kwargs; others configure renderer-only behavior such as image caching, parsers, or Harmony preamble construction. `create_renderer` and `create_renderer_pool` take one positional `config` argument and an optional keyword-only `chat_template_kwargs` mapping:
114
113
 
115
114
  ```python
116
115
  from renderers import (
@@ -121,13 +120,15 @@ from renderers import (
121
120
  DefaultRendererConfig,
122
121
  )
123
122
 
124
- # Auto-resolve renderer from the tokenizer's model name. Carries the
125
- # shared preserve_* flags; template kwargs require an explicit choice.
123
+ # Auto-resolve renderer from the tokenizer's model name.
126
124
  renderer = create_renderer(tokenizer)
127
- renderer = create_renderer(tokenizer, AutoRendererConfig(preserve_all_thinking=True))
125
+ renderer = create_renderer(tokenizer, AutoRendererConfig(thinking_retention="all"))
126
+ renderer = create_renderer(
127
+ tokenizer,
128
+ chat_template_kwargs={"enable_thinking": False},
129
+ )
128
130
 
129
- # Explicit choice — the typed config exposes exactly the fields that
130
- # renderer's chat template honours.
131
+ # Explicit choice — use the renderer-specific fields it exposes.
131
132
  renderer = create_renderer(tokenizer, Qwen3RendererConfig(enable_thinking=False))
132
133
  renderer = create_renderer(tokenizer, GLM5RendererConfig(clear_thinking=False))
133
134
 
@@ -142,21 +143,24 @@ renderer = create_renderer(
142
143
 
143
144
  Discriminated union: every per-renderer config is a variant of `RendererConfig`, dispatched on the `name` field. Bogus combinations (e.g. `add_vision_id` under `name="qwen3"`) error at construction with a `pydantic.ValidationError`. Downstream pydantic configs (prime-rl orchestrator, verifiers `ClientConfig`) hold a single field typed as `RendererConfig` and inherit the same strict-per-variant validation.
144
145
 
145
- Two shared behaviour flags live on every variant via `_BaseRendererConfig`:
146
+ When `chat_template_kwargs` is passed with `config=None` / `AutoRendererConfig`, renderers first resolves the concrete renderer from the model name, then validates those kwargs against that renderer's config. `Auto + unknown model + chat_template_kwargs` fails loudly; use an explicit typed config or explicit `DefaultRendererConfig` for opaque fallback templates.
147
+
148
+ One shared behaviour flag lives on typed renderer configs: `thinking_retention`, an optional bridge-policy override. Leave it unset to derive bridge behaviour from the chat template and its renderer-exposed kwargs.
146
149
 
147
- - `preserve_all_thinking=True` — every past assistant's `reasoning_content` is kept, even when the chat template would drop it.
148
- - `preserve_thinking_between_tool_calls=True` — reasoning is kept on assistants in the in-flight tool cycle (post-last-user A-T-…-A block when it contains a tool response). A new user turn closes the block and drops its thinking.
150
+ - `thinking_retention=None` (default) derive from the chat template / renderer kwargs.
151
+ - `thinking_retention="tool_cycle"` — bridge within the in-flight tool cycle; a new user query falls back to a full re-render.
152
+ - `thinking_retention="all"` — bridge across user-query boundaries when the bridge is otherwise structurally valid.
149
153
 
150
- These OR-compose with template-level toggles (e.g. GLM-5 `clear_thinking`, Nemotron-3 `truncate_history_thinking`): either flag saying "keep" wins. preserve_* can only ever *extend* retention never override a template kwarg into a "drop" decision. The canonical use case is **compaction**: injecting a `user` turn like *"summarize the work so far"* puts every prior assistant in a past cycle, and `preserve_all_thinking=True` keeps reasoning visible end-to-end.
154
+ 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).
151
155
 
152
156
  ## `DefaultRenderer`
153
157
 
154
- Fallback for unsupported models. Wraps `apply_chat_template` and accepts `tool_parser` / `reasoning_parser` (vLLM convention) plus arbitrary Jinja kwargs via `DefaultRendererConfig`'s `extra="allow"`. `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.
158
+ 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.
155
159
 
156
160
  ## Roadmap
157
161
 
158
- - **VLM support.** `ContentPart` is text-only today; `Qwen3VLRenderer` ships only because Qwen3-VL's text-only chat template differs from Qwen3's. Plan: add `ImagePart` / `VideoPart`, multimodal bridges, validate against a Qwen3-VL RL run.
159
- - **Patched chat templates.** Some shipped templates re-tokenize history, normalize JSON, or auto-strip thinking each breaks the extension property. Plan: a `use_patched` opt-in per renderer that renders the same surface form while avoiding known-bad patterns.
162
+ - **VLM expansion.** `ImagePart` support exists for Qwen3-VL and Qwen3.5-family multimodal templates. Remaining work: video support, broader VLM coverage, and more RL validation.
163
+ - **Patched chat templates.** Some shipped templates re-tokenize history or normalize JSON in ways that break token identity. Plan: a `use_patched` opt-in per renderer that renders the same surface form while avoiding known-bad patterns. (Auto-stripping thinking from past turns is *not* one of these — that's intended template behaviour the renderer reproduces; use `thinking_retention` to override it.)
160
164
 
161
165
  ## Testing
162
166
 
@@ -0,0 +1,216 @@
1
+ # Renderer config
2
+
3
+ `renderers.RendererConfig` is the typed input to `create_renderer` and
4
+ `create_renderer_pool`. It pins the renderer choice and its config at
5
+ construction time.
6
+
7
+ ```python
8
+ from renderers import create_renderer, Qwen35RendererConfig
9
+
10
+ r = create_renderer(tokenizer, Qwen35RendererConfig(enable_thinking=False))
11
+ r = create_renderer(tokenizer, chat_template_kwargs={"enable_thinking": False})
12
+ ```
13
+
14
+ `RendererConfig` is a pydantic discriminated union, one variant per renderer,
15
+ dispatched on the `name` field. Most variants reject unknown fields at
16
+ construction. A field can either mirror a chat-template kwarg or configure a
17
+ renderer-only behavior such as parsing, image caching, or Harmony preamble
18
+ construction.
19
+
20
+ ## Per-renderer configs
21
+
22
+ Use `type(config).template_field_names()` to inspect the fields that mirror
23
+ chat-template kwargs. Those fields are covered by parity tests against
24
+ `apply_chat_template` in `tests/test_renderer_config_parity.py`.
25
+
26
+ | Renderer | Config class | Template fields | Renderer-only fields |
27
+ | --- | --- | --- | --- |
28
+ | Qwen3 | `Qwen3RendererConfig` | `enable_thinking` | - |
29
+ | Qwen3.5 | `Qwen35RendererConfig` | `enable_thinking`, `add_vision_id` | `image_cache_max` |
30
+ | Qwen3.6 | `Qwen36RendererConfig` | `enable_thinking`, `add_vision_id`, `preserve_thinking` | `image_cache_max` |
31
+ | Qwen3-VL | `Qwen3VLRendererConfig` | `add_vision_id` | `image_cache_max` |
32
+ | GLM-5 / 5.1 | `GLM5RendererConfig` / `GLM51RendererConfig` | `enable_thinking`, `clear_thinking` | - |
33
+ | GLM-4.5 | `GLM45RendererConfig` | `enable_thinking` | - |
34
+ | gpt-oss | `GptOssRendererConfig` | `reasoning_effort`, `conversation_start_date` | `use_system_prompt`, `knowledge_cutoff`, `model_identity`, `auto_drop_analysis` |
35
+ | Kimi K2 | `KimiK2RendererConfig` | - | `enable_thinking` |
36
+ | Kimi K2.5 / 2.6 | `KimiK25RendererConfig` | `thinking` | `image_cache_max` |
37
+ | Laguna XS.2 | `LagunaXS2RendererConfig` | `enable_thinking`, `render_assistant_messages_raw` | - |
38
+ | Llama 3 | `Llama3RendererConfig` | `date_string`, `tools_in_user_message` | - |
39
+ | MiniMax M2 | `MiniMaxM2RendererConfig` | `model_identity` | - |
40
+ | Nemotron-3 Nano / Super | `Nemotron3RendererConfig` | `enable_thinking`, `truncate_history_thinking`, `low_effort` | - |
41
+ | Nemotron-3 Ultra | `Nemotron3UltraRendererConfig` | `enable_thinking`, `truncate_history_thinking`, `medium_effort` | - |
42
+ | DeepSeek V3 | `DeepSeekV3RendererConfig` | - | - |
43
+ | DeepSeek R1 | `DeepSeekR1RendererConfig` | - | - |
44
+
45
+ Configs are frozen value objects. To override a field, construct a new instance
46
+ or call `config.model_copy(update={...})`.
47
+
48
+ ## Auto-resolution
49
+
50
+ `create_renderer(tokenizer)` resolves the renderer from `tokenizer.name_or_path`
51
+ via `MODEL_RENDERER_MAP`:
52
+
53
+ ```python
54
+ from renderers import AutoRendererConfig, GLM5RendererConfig
55
+
56
+ r = create_renderer(tokenizer)
57
+ r = create_renderer(tokenizer, AutoRendererConfig(thinking_retention="all"))
58
+ r = create_renderer(tokenizer, GLM5RendererConfig(clear_thinking=False))
59
+ ```
60
+
61
+ `AutoRendererConfig` carries only the shared `thinking_retention` override.
62
+ Callers that receive run-scoped chat-template kwargs can pass them separately:
63
+
64
+ ```python
65
+ r = create_renderer(
66
+ tokenizer,
67
+ chat_template_kwargs={"enable_thinking": False},
68
+ )
69
+ pool = create_renderer_pool(
70
+ "Qwen/Qwen3-8B",
71
+ chat_template_kwargs={"enable_thinking": False},
72
+ )
73
+ ```
74
+
75
+ Renderers resolves auto configs before applying `chat_template_kwargs`, so the
76
+ kwargs validate against the concrete renderer config. Unknown kwargs, or kwargs
77
+ that conflict with an explicit `thinking_retention`, fail at construction.
78
+
79
+ Auto-resolution fails loudly for VLMs without an exact registered renderer.
80
+ Text-only unknown models fall back to `DefaultRenderer`, unless
81
+ `AutoRendererConfig(thinking_retention=...)` was set. The default renderer
82
+ cannot implement selective bridge retention, so that combination raises.
83
+ `AutoRendererConfig` with `chat_template_kwargs` also raises for unknown models,
84
+ because renderers cannot validate those kwargs without a concrete renderer.
85
+ Use an explicit model-specific config, or `DefaultRendererConfig(...)` when you
86
+ intentionally want opaque `apply_chat_template` kwargs.
87
+
88
+ ## `thinking_retention`
89
+
90
+ Every typed renderer config carries one shared optional bridge-policy override:
91
+
92
+ ```python
93
+ thinking_retention: Literal["tool_cycle", "all"] | None = None
94
+ ```
95
+
96
+ | Value | Meaning |
97
+ | --- | --- |
98
+ | `None` | Derive the effective bridge policy from the renderer's template knobs and defaults. |
99
+ | `"tool_cycle"` | Bridge within the current tool cycle; re-render when the extension opens a new user query. |
100
+ | `"all"` | Allow bridging across user-query boundaries when the bridge is otherwise structurally valid. |
101
+
102
+ `thinking_retention` affects `bridge_to_next_turn`, not full `render()`.
103
+ A full render always follows the Python chat-template implementation. Only real
104
+ template fields, such as `clear_thinking`, `preserve_thinking`, or
105
+ `truncate_history_thinking`, can change full-render historical thinking.
106
+
107
+ Internally, renderers resolve an `effective_thinking_retention` at construction:
108
+
109
+ | Internal policy | Bridge behavior |
110
+ | --- | --- |
111
+ | `"template"` | Decline bridging; caller falls back to a full re-render. |
112
+ | `"tool_cycle"` | Bridge unless `new_messages` introduces a user query. |
113
+ | `"all"` | Do not block bridging for thinking retention. |
114
+
115
+ `"template"` is not a public config value. Leave `thinking_retention` unset to
116
+ get template-derived behavior.
117
+
118
+ ## Derived retention defaults
119
+
120
+ When `thinking_retention` is unset, each renderer derives its bridge policy from
121
+ the knobs its template actually exposes:
122
+
123
+ | Renderer | Derived policy |
124
+ | --- | --- |
125
+ | Qwen3 | `enable_thinking=False -> all`, else `tool_cycle` |
126
+ | Qwen3.5 | `enable_thinking=False -> all`, else `tool_cycle` |
127
+ | Qwen3.6 | `preserve_thinking=True -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
128
+ | GLM-5 / 5.1 | `clear_thinking=False -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
129
+ | GLM-4.5 | `enable_thinking=False -> all`, else `tool_cycle` |
130
+ | gpt-oss | `auto_drop_analysis=False -> all`, else `tool_cycle` |
131
+ | Kimi K2.5 / 2.6 | `thinking=False -> all`, else `tool_cycle` |
132
+ | Nemotron-3 | `truncate_history_thinking=False -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
133
+ | DeepSeek R1 | `template` |
134
+ | MiniMax M2 | `tool_cycle` |
135
+ | DeepSeek V3, Qwen3-VL, Kimi K2, Laguna XS.2, Llama 3 | `all` |
136
+
137
+ Config construction raises when an explicit template knob directly contradicts
138
+ an explicit generic bridge policy. For example:
139
+
140
+ ```python
141
+ GLM5RendererConfig(clear_thinking=False, thinking_retention="tool_cycle")
142
+ # ValueError: clear_thinking=False implies thinking_retention="all"
143
+ ```
144
+
145
+ Generation-only no-thinking knobs, such as `enable_thinking=False`, do not
146
+ conflict with an explicit conservative `thinking_retention="tool_cycle"`. They
147
+ only change the derived default when `thinking_retention` is unset.
148
+
149
+ ## `DefaultRendererConfig`
150
+
151
+ `DefaultRenderer` wraps `tokenizer.apply_chat_template` for unsupported
152
+ text-only models. Its config sets `extra="allow"` so unknown fields are
153
+ forwarded as Jinja kwargs:
154
+
155
+ ```python
156
+ from renderers import create_renderer, DefaultRendererConfig
157
+
158
+ r = create_renderer(
159
+ tokenizer,
160
+ DefaultRendererConfig(
161
+ tool_parser="qwen3",
162
+ reasoning_parser="think",
163
+ enable_thinking=False,
164
+ custom_jinja_kwarg=True,
165
+ ),
166
+ )
167
+ ```
168
+
169
+ `tool_parser` and `reasoning_parser` configure `DefaultRenderer` itself. Every
170
+ other extra field lands in `model_extra` and is forwarded to
171
+ `apply_chat_template`.
172
+
173
+ `DefaultRenderer` rejects explicit `thinking_retention` and the removed
174
+ `preserve_*` flags. Its bridge always returns `None`, because the template's
175
+ turn-close structure is opaque to the renderer.
176
+
177
+ ## Downstream integration
178
+
179
+ Downstream pydantic configs can hold a single field typed as `RendererConfig`:
180
+
181
+ ```python
182
+ from pydantic import BaseModel, Field
183
+ from renderers import AutoRendererConfig, RendererConfig
184
+
185
+
186
+ class ClientConfig(BaseModel):
187
+ renderer: RendererConfig = Field(default_factory=AutoRendererConfig)
188
+ ```
189
+
190
+ In TOML or YAML, the `name` discriminator selects the variant:
191
+
192
+ ```toml
193
+ [client.renderer]
194
+ name = "qwen3.5"
195
+ enable_thinking = false
196
+ add_vision_id = true
197
+ thinking_retention = "all"
198
+ ```
199
+
200
+ Bogus combinations, such as `add_vision_id` under `name = "qwen3"`, raise at
201
+ config load with a pydantic validation error.
202
+
203
+ To construct a config from a renderer name string:
204
+
205
+ ```python
206
+ from renderers import config_from_name
207
+
208
+ cfg = config_from_name("glm-5") # GLM5RendererConfig()
209
+ cfg = config_from_name("auto") # None, the implicit auto form
210
+ ```
211
+
212
+ ## Renaming a renderer is a breaking change
213
+
214
+ The discriminator key is the renderer name string. Renaming `"qwen3.5"` to
215
+ something else would break downstream configs that reference it by name. Add
216
+ new renderers instead of renaming existing ones.
@@ -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.8.dev51'
22
- __version_tuple__ = version_tuple = (0, 1, 8, 'dev51')
21
+ __version__ = version = '0.1.8.dev52'
22
+ __version_tuple__ = version_tuple = (0, 1, 8, 'dev52')
23
23
 
24
24
  __commit_id__ = commit_id = None