renderers 0.1.9.dev0__tar.gz → 0.1.9.dev2__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 (81) hide show
  1. renderers-0.1.9.dev2/.github/workflows/publish-dev.yml +67 -0
  2. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/.gitignore +3 -0
  3. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/PKG-INFO +49 -20
  4. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/README.md +46 -17
  5. renderers-0.1.9.dev2/docs/renderer-config.md +221 -0
  6. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/examples/sglang/multiturn_generate_sglang.py +29 -10
  7. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/examples/sglang/online_multiturn_sglang.py +29 -10
  8. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/examples/tinker/multiturn_generate_tinker.py +29 -10
  9. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/examples/transformers/multiturn_generate_transformers.py +28 -10
  10. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/examples/vllm/multiturn_generate_vllm.py +29 -10
  11. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/pyproject.toml +40 -7
  12. renderers-0.1.9.dev2/renderers/__init__.py +202 -0
  13. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/_version.py +2 -2
  14. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/base.py +879 -241
  15. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/client.py +177 -68
  16. renderers-0.1.9.dev2/renderers/configs.py +785 -0
  17. renderers-0.1.9.dev2/renderers/deepseek_r1.py +59 -0
  18. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/deepseek_v3.py +164 -71
  19. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/default.py +24 -24
  20. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/glm45.py +234 -81
  21. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/glm5.py +237 -78
  22. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/gpt_oss.py +247 -59
  23. renderers-0.1.9.dev2/renderers/hy3.py +706 -0
  24. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/kimi_k2.py +191 -83
  25. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/kimi_k25.py +238 -95
  26. renderers-0.1.9.dev2/renderers/laguna_xs2.py +964 -0
  27. renderers-0.1.9.dev2/renderers/llama_3.py +527 -0
  28. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/minimax_m2.py +246 -83
  29. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/nemotron3.py +357 -176
  30. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/parsing.py +396 -36
  31. renderers-0.1.9.dev2/renderers/prime_qwen3.py +694 -0
  32. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/qwen3.py +243 -96
  33. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/qwen35.py +399 -168
  34. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/qwen36.py +6 -6
  35. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/qwen3_vl.py +284 -117
  36. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/conftest.py +51 -2
  37. renderers-0.1.9.dev2/tests/test_bridge.py +346 -0
  38. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/test_build_helpers.py +46 -2
  39. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/test_client.py +227 -16
  40. renderers-0.1.9.dev2/tests/test_deepseek_r1.py +152 -0
  41. renderers-0.1.9.dev2/tests/test_disabled_thinking_stability.py +226 -0
  42. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/test_gpt_oss_harmony_parity.py +4 -1
  43. renderers-0.1.9.dev2/tests/test_hy3.py +486 -0
  44. renderers-0.1.9.dev2/tests/test_is_content.py +390 -0
  45. renderers-0.1.9.dev2/tests/test_kimi_k25_tool_schema.py +53 -0
  46. renderers-0.1.9.dev2/tests/test_laguna_xs21.py +376 -0
  47. renderers-0.1.9.dev2/tests/test_llama_3.py +417 -0
  48. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/test_load_tokenizer.py +49 -1
  49. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/test_message_indices.py +1 -1
  50. renderers-0.1.9.dev2/tests/test_message_tool_names.py +97 -0
  51. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/test_multimodal.py +279 -9
  52. renderers-0.1.9.dev2/tests/test_nemotron3_parity.py +676 -0
  53. renderers-0.1.9.dev2/tests/test_nemotron3_ultra.py +104 -0
  54. renderers-0.1.9.dev2/tests/test_parse_response.py +276 -0
  55. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/test_parse_response_robustness.py +1 -2
  56. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/test_parsers.py +5 -4
  57. renderers-0.1.9.dev2/tests/test_preserve_thinking.py +97 -0
  58. renderers-0.1.9.dev2/tests/test_prime_qwen3_parity.py +254 -0
  59. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/test_qwen35_size_coverage.py +37 -14
  60. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/test_render_ids.py +3 -3
  61. renderers-0.1.9.dev2/tests/test_renderer_config.py +324 -0
  62. renderers-0.1.9.dev2/tests/test_renderer_config_parity.py +588 -0
  63. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/test_roundtrip.py +19 -4
  64. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/test_tool_arg_type_preservation.py +77 -3
  65. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/uv.lock +22 -5
  66. renderers-0.1.9.dev0/renderers/__init__.py +0 -98
  67. renderers-0.1.9.dev0/renderers/laguna_xs2.py +0 -431
  68. renderers-0.1.9.dev0/tests/test_bridge.py +0 -190
  69. renderers-0.1.9.dev0/tests/test_load_tokenizer_fastokens.py +0 -172
  70. renderers-0.1.9.dev0/tests/test_parse_response.py +0 -137
  71. renderers-0.1.9.dev0/tests/test_preserve_thinking.py +0 -460
  72. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/.github/workflows/publish.yml +0 -0
  73. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/.github/workflows/style.yml +0 -0
  74. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/.github/workflows/test.yml +0 -0
  75. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/.pre-commit-config.yaml +0 -0
  76. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/LICENSE +0 -0
  77. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/examples/README.md +0 -0
  78. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/renderers/parsers.py +0 -0
  79. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/test_incremental.py +0 -0
  80. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/test_sampled_mask.py +0 -0
  81. {renderers-0.1.9.dev0 → renderers-0.1.9.dev2}/tests/test_tokens_per_message.py +0 -0
@@ -0,0 +1,67 @@
1
+ name: Publish Dev
2
+
3
+ # Every push to main publishes a pre-release wheel to PyPI. The version
4
+ # is derived at build time by hatch-vcs from ``git describe`` distance
5
+ # to the latest stable ``renderers-v<MAJOR>.<MINOR>.<PATCH>`` tag (see
6
+ # ``[tool.hatch.version.raw-options]`` in pyproject.toml) — no per-commit
7
+ # tag is created. ``local_scheme = "no-local-version"`` strips the
8
+ # ``+gHASH`` segment so PyPI accepts the wheel directly.
9
+
10
+ on:
11
+ push:
12
+ branches: [main]
13
+
14
+ concurrency:
15
+ group: publish-dev-${{ github.ref }}
16
+ cancel-in-progress: false
17
+
18
+ jobs:
19
+ # Build (no OIDC) → publish (OIDC only). The build job runs ``uv build``
20
+ # with ``contents: read`` only so a poisoned build-time dep cannot mint
21
+ # the OIDC token. The publish job has ``id-token: write`` and the
22
+ # pypi-prod environment but no source checkout — it only downloads the
23
+ # prebuilt artifact and runs the SHA-pinned pypa publish action.
24
+ build:
25
+ runs-on: ubuntu-latest
26
+ permissions:
27
+ contents: read
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+ with:
31
+ # hatch-vcs needs the full tag history to resolve the version
32
+ # from the latest stable tag's distance.
33
+ fetch-depth: 0
34
+
35
+ - uses: astral-sh/setup-uv@v7
36
+
37
+ - name: Build renderers
38
+ run: uv build
39
+
40
+ - name: Show derived version
41
+ run: ls -1 dist/
42
+
43
+ - name: Upload dist artifacts
44
+ uses: actions/upload-artifact@v4
45
+ with:
46
+ name: dist-dev
47
+ path: dist/
48
+ if-no-files-found: error
49
+ retention-days: 7
50
+
51
+ publish:
52
+ needs: build
53
+ runs-on: ubuntu-latest
54
+ environment: pypi-prod
55
+ permissions:
56
+ id-token: write
57
+ steps:
58
+ - name: Download dist artifacts
59
+ uses: actions/download-artifact@v4
60
+ with:
61
+ name: dist-dev
62
+ path: dist/
63
+
64
+ - name: Publish to PyPI
65
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
66
+ with:
67
+ skip-existing: true
@@ -31,3 +31,6 @@ coverage.xml
31
31
  .idea/
32
32
  .vscode/
33
33
  *.swp
34
+
35
+ # agent harness state
36
+ .claude/
@@ -1,15 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: renderers
3
- Version: 0.1.9.dev0
3
+ Version: 0.1.9.dev2
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
7
  Requires-Python: <3.14,>=3.10
8
- Requires-Dist: fastokens>=0.1.1
9
8
  Requires-Dist: jinja2
10
9
  Requires-Dist: numpy
11
- Requires-Dist: openai-harmony>=0.0.8
10
+ Requires-Dist: openai-harmony>=0.0.4
12
11
  Requires-Dist: openai>=1.108.1
12
+ Requires-Dist: prime-pydantic-config>=0.3.0.dev83
13
13
  Requires-Dist: tiktoken
14
14
  Requires-Dist: transformers>=4.50.0
15
15
  Description-Content-Type: text/markdown
@@ -33,7 +33,7 @@ from transformers import AutoTokenizer
33
33
  from renderers import create_renderer
34
34
 
35
35
  tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-8B")
36
- r = create_renderer(tok, renderer="auto") # → Qwen3Renderer
36
+ r = create_renderer(tok) # → Qwen3Renderer (auto-resolved)
37
37
 
38
38
  prompt_ids = r.render_ids(
39
39
  [{"role": "user", "content": "hi"}],
@@ -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`, `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`, `gpt-oss`, `hy3`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper.
60
60
 
61
61
  ## API
62
62
 
@@ -87,17 +87,17 @@ Each hand-coded bridge:
87
87
  ### Picking a renderer
88
88
 
89
89
  ```python
90
- r = create_renderer(tok, renderer="auto")
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 `renderer=<name>` explicitly; 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
 
97
97
  ```python
98
98
  from renderers import create_renderer_pool
99
99
 
100
- pool = create_renderer_pool("Qwen/Qwen3-8B", renderer="auto", size=16)
100
+ pool = create_renderer_pool("Qwen/Qwen3-8B", size=16)
101
101
  with pool.checkout() as r:
102
102
  ids = r.render_ids(messages)
103
103
  ```
@@ -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
 
@@ -124,30 +123,60 @@ Empirical delta on Qwen3.5-35B-A3B + mini-swe-agent-plus, step 0:
124
123
 
125
124
  Each break fragments a rollout into multiple training samples — every fragment re-encodes its prefix, inflating compute roughly linearly with the number of breaks.
126
125
 
127
- ## Compaction overrides
126
+ ## Typed renderer configs
128
127
 
129
- `create_renderer` and `create_renderer_pool` accept two constructor-only flags:
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
- preserve_all_thinking: bool = False
133
- preserve_thinking_between_tool_calls: bool = False
131
+ from renderers import (
132
+ create_renderer,
133
+ AutoRendererConfig,
134
+ Qwen3RendererConfig,
135
+ GLM5RendererConfig,
136
+ DefaultRendererConfig,
137
+ )
138
+
139
+ # Auto-resolve renderer from the tokenizer's model name.
140
+ renderer = create_renderer(tokenizer)
141
+ renderer = create_renderer(tokenizer, AutoRendererConfig(thinking_retention="all"))
142
+ renderer = create_renderer(
143
+ tokenizer,
144
+ chat_template_kwargs={"enable_thinking": False},
145
+ )
146
+
147
+ # Explicit choice — use the renderer-specific fields it exposes.
148
+ renderer = create_renderer(tokenizer, Qwen3RendererConfig(enable_thinking=False))
149
+ renderer = create_renderer(tokenizer, GLM5RendererConfig(clear_thinking=False))
150
+
151
+ # Default renderer (apply_chat_template fallback) — extra fields are
152
+ # captured via pydantic ``extra="allow"`` and forwarded to the Jinja
153
+ # template; tool / reasoning parsers are typed.
154
+ renderer = create_renderer(
155
+ tokenizer,
156
+ DefaultRendererConfig(tool_parser="qwen3", reasoning_parser="think"),
157
+ )
134
158
  ```
135
159
 
136
- Defaults preserve byte-identity with the model's chat template. Flipping a flag at construction restores `reasoning_content` the template would otherwise drop:
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.
161
+
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.
137
165
 
138
- - `preserve_all_thinking=True` — every past assistant's reasoning is kept.
139
- - `preserve_thinking_between_tool_calls=True` — reasoning is kept on assistants in the in-flight tool cycle (no-op for current renderers; reserved for future templates that drop it).
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.
140
169
 
141
- The canonical use case is **compaction**. Injecting a `user` turn like *"summarize the work so far"* puts every prior assistant in a "past cycle", so template-default rules drop their `reasoning_content` before the summarizer sees it. Build the renderer with `preserve_all_thinking=True` to keep reasoning visible end-to-end on those flows. Both flags only ever *add* tokens vs the template default.
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).
142
171
 
143
172
  ## `DefaultRenderer`
144
173
 
145
- Fallback for unsupported models. Wraps `apply_chat_template` and accepts `tool_parser` / `reasoning_parser` kwargs (vLLM convention). `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.
146
175
 
147
176
  ## Roadmap
148
177
 
149
- - **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.
150
- - **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.)
151
180
 
152
181
  ## Testing
153
182
 
@@ -17,7 +17,7 @@ from transformers import AutoTokenizer
17
17
  from renderers import create_renderer
18
18
 
19
19
  tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-8B")
20
- r = create_renderer(tok, renderer="auto") # → Qwen3Renderer
20
+ r = create_renderer(tok) # → Qwen3Renderer (auto-resolved)
21
21
 
22
22
  prompt_ids = r.render_ids(
23
23
  [{"role": "user", "content": "hi"}],
@@ -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`, `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`, `gpt-oss`, `hy3`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper.
44
44
 
45
45
  ## API
46
46
 
@@ -71,17 +71,17 @@ Each hand-coded bridge:
71
71
  ### Picking a renderer
72
72
 
73
73
  ```python
74
- r = create_renderer(tok, renderer="auto")
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 `renderer=<name>` explicitly; 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
 
81
81
  ```python
82
82
  from renderers import create_renderer_pool
83
83
 
84
- pool = create_renderer_pool("Qwen/Qwen3-8B", renderer="auto", size=16)
84
+ pool = create_renderer_pool("Qwen/Qwen3-8B", size=16)
85
85
  with pool.checkout() as r:
86
86
  ids = r.render_ids(messages)
87
87
  ```
@@ -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
 
@@ -108,30 +107,60 @@ Empirical delta on Qwen3.5-35B-A3B + mini-swe-agent-plus, step 0:
108
107
 
109
108
  Each break fragments a rollout into multiple training samples — every fragment re-encodes its prefix, inflating compute roughly linearly with the number of breaks.
110
109
 
111
- ## Compaction overrides
110
+ ## Typed renderer configs
112
111
 
113
- `create_renderer` and `create_renderer_pool` accept two constructor-only flags:
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
- preserve_all_thinking: bool = False
117
- preserve_thinking_between_tool_calls: bool = False
115
+ from renderers import (
116
+ create_renderer,
117
+ AutoRendererConfig,
118
+ Qwen3RendererConfig,
119
+ GLM5RendererConfig,
120
+ DefaultRendererConfig,
121
+ )
122
+
123
+ # Auto-resolve renderer from the tokenizer's model name.
124
+ renderer = create_renderer(tokenizer)
125
+ renderer = create_renderer(tokenizer, AutoRendererConfig(thinking_retention="all"))
126
+ renderer = create_renderer(
127
+ tokenizer,
128
+ chat_template_kwargs={"enable_thinking": False},
129
+ )
130
+
131
+ # Explicit choice — use the renderer-specific fields it exposes.
132
+ renderer = create_renderer(tokenizer, Qwen3RendererConfig(enable_thinking=False))
133
+ renderer = create_renderer(tokenizer, GLM5RendererConfig(clear_thinking=False))
134
+
135
+ # Default renderer (apply_chat_template fallback) — extra fields are
136
+ # captured via pydantic ``extra="allow"`` and forwarded to the Jinja
137
+ # template; tool / reasoning parsers are typed.
138
+ renderer = create_renderer(
139
+ tokenizer,
140
+ DefaultRendererConfig(tool_parser="qwen3", reasoning_parser="think"),
141
+ )
118
142
  ```
119
143
 
120
- Defaults preserve byte-identity with the model's chat template. Flipping a flag at construction restores `reasoning_content` the template would otherwise drop:
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.
145
+
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.
121
149
 
122
- - `preserve_all_thinking=True` — every past assistant's reasoning is kept.
123
- - `preserve_thinking_between_tool_calls=True` — reasoning is kept on assistants in the in-flight tool cycle (no-op for current renderers; reserved for future templates that drop it).
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.
124
153
 
125
- The canonical use case is **compaction**. Injecting a `user` turn like *"summarize the work so far"* puts every prior assistant in a "past cycle", so template-default rules drop their `reasoning_content` before the summarizer sees it. Build the renderer with `preserve_all_thinking=True` to keep reasoning visible end-to-end on those flows. Both flags only ever *add* tokens vs the template default.
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).
126
155
 
127
156
  ## `DefaultRenderer`
128
157
 
129
- Fallback for unsupported models. Wraps `apply_chat_template` and accepts `tool_parser` / `reasoning_parser` kwargs (vLLM convention). `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.
130
159
 
131
160
  ## Roadmap
132
161
 
133
- - **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.
134
- - **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.)
135
164
 
136
165
  ## Testing
137
166
 
@@ -0,0 +1,221 @@
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
+ | PrimeIntellect Qwen3 | `PrimeQwen3RendererConfig` | - | - |
30
+ | Qwen3.5 | `Qwen35RendererConfig` | `enable_thinking`, `add_vision_id` | `image_cache_max` |
31
+ | Qwen3.6 | `Qwen36RendererConfig` | `enable_thinking`, `add_vision_id`, `preserve_thinking` | `image_cache_max` |
32
+ | Qwen3-VL | `Qwen3VLRendererConfig` | `add_vision_id` | `image_cache_max` |
33
+ | GLM-5 / 5.1 | `GLM5RendererConfig` / `GLM51RendererConfig` | `enable_thinking`, `clear_thinking` | - |
34
+ | GLM-4.5 | `GLM45RendererConfig` | `enable_thinking` | - |
35
+ | gpt-oss | `GptOssRendererConfig` | `reasoning_effort`, `conversation_start_date` | `use_system_prompt`, `knowledge_cutoff`, `model_identity`, `auto_drop_analysis` |
36
+ | Hy3 | `Hy3RendererConfig` | `reasoning_effort`, `preserved_thinking`, `is_training`, `raw_last_assistant`, `fallback_strategy` | - |
37
+ | Kimi K2 | `KimiK2RendererConfig` | - | `enable_thinking` |
38
+ | Kimi K2.5 / 2.6 | `KimiK25RendererConfig` | `thinking` | `image_cache_max` |
39
+ | Laguna XS.2 | `LagunaXS2RendererConfig` | `enable_thinking`, `render_assistant_messages_raw` | - |
40
+ | Laguna XS-2.1 | `LagunaXS21RendererConfig` | `enable_thinking` | - |
41
+ | Llama 3 | `Llama3RendererConfig` | `date_string`, `tools_in_user_message` | - |
42
+ | MiniMax M2 | `MiniMaxM2RendererConfig` | `model_identity` | - |
43
+ | Nemotron-3 Nano / Super | `Nemotron3RendererConfig` | `enable_thinking`, `truncate_history_thinking`, `low_effort` | - |
44
+ | Nemotron-3 Ultra | `Nemotron3UltraRendererConfig` | `enable_thinking`, `truncate_history_thinking`, `medium_effort` | - |
45
+ | DeepSeek V3 | `DeepSeekV3RendererConfig` | - | - |
46
+ | DeepSeek R1 | `DeepSeekR1RendererConfig` | - | - |
47
+
48
+ Configs are frozen value objects. To override a field, construct a new instance
49
+ or call `config.model_copy(update={...})`.
50
+
51
+ ## Auto-resolution
52
+
53
+ `create_renderer(tokenizer)` resolves the renderer from `tokenizer.name_or_path`
54
+ via `MODEL_RENDERER_MAP`:
55
+
56
+ ```python
57
+ from renderers import AutoRendererConfig, GLM5RendererConfig
58
+
59
+ r = create_renderer(tokenizer)
60
+ r = create_renderer(tokenizer, AutoRendererConfig(thinking_retention="all"))
61
+ r = create_renderer(tokenizer, GLM5RendererConfig(clear_thinking=False))
62
+ ```
63
+
64
+ `AutoRendererConfig` carries only the shared `thinking_retention` override.
65
+ Callers that receive run-scoped chat-template kwargs can pass them separately:
66
+
67
+ ```python
68
+ r = create_renderer(
69
+ tokenizer,
70
+ chat_template_kwargs={"enable_thinking": False},
71
+ )
72
+ pool = create_renderer_pool(
73
+ "Qwen/Qwen3-8B",
74
+ chat_template_kwargs={"enable_thinking": False},
75
+ )
76
+ ```
77
+
78
+ Renderers resolves auto configs before applying `chat_template_kwargs`, so the
79
+ kwargs validate against the concrete renderer config. Unknown kwargs, or kwargs
80
+ that conflict with an explicit `thinking_retention`, fail at construction.
81
+
82
+ Auto-resolution fails loudly for VLMs without an exact registered renderer.
83
+ Text-only unknown models fall back to `DefaultRenderer`, unless
84
+ `AutoRendererConfig(thinking_retention=...)` was set. The default renderer
85
+ cannot implement selective bridge retention, so that combination raises.
86
+ `AutoRendererConfig` with `chat_template_kwargs` also raises for unknown models,
87
+ because renderers cannot validate those kwargs without a concrete renderer.
88
+ Use an explicit model-specific config, or `DefaultRendererConfig(...)` when you
89
+ intentionally want opaque `apply_chat_template` kwargs.
90
+
91
+ ## `thinking_retention`
92
+
93
+ Every typed renderer config carries one shared optional bridge-policy override:
94
+
95
+ ```python
96
+ thinking_retention: Literal["tool_cycle", "all"] | None = None
97
+ ```
98
+
99
+ | Value | Meaning |
100
+ | --- | --- |
101
+ | `None` | Derive the effective bridge policy from the renderer's template knobs and defaults. |
102
+ | `"tool_cycle"` | Bridge within the current tool cycle; re-render when the extension opens a new user query. |
103
+ | `"all"` | Allow bridging across user-query boundaries when the bridge is otherwise structurally valid. |
104
+
105
+ `thinking_retention` affects `bridge_to_next_turn`, not full `render()`.
106
+ A full render always follows the Python chat-template implementation. Only real
107
+ template fields, such as `clear_thinking`, `preserve_thinking`, or
108
+ `truncate_history_thinking`, can change full-render historical thinking.
109
+
110
+ Internally, renderers resolve an `effective_thinking_retention` at construction:
111
+
112
+ | Internal policy | Bridge behavior |
113
+ | --- | --- |
114
+ | `"template"` | Decline bridging; caller falls back to a full re-render. |
115
+ | `"tool_cycle"` | Bridge unless `new_messages` introduces a user query. |
116
+ | `"all"` | Do not block bridging for thinking retention. |
117
+
118
+ `"template"` is not a public config value. Leave `thinking_retention` unset to
119
+ get template-derived behavior.
120
+
121
+ ## Derived retention defaults
122
+
123
+ When `thinking_retention` is unset, each renderer derives its bridge policy from
124
+ the knobs its template actually exposes:
125
+
126
+ | Renderer | Derived policy |
127
+ | --- | --- |
128
+ | Qwen3 | `enable_thinking=False -> all`, else `tool_cycle` |
129
+ | Qwen3.5 | `enable_thinking=False -> all`, else `tool_cycle` |
130
+ | Qwen3.6 | `preserve_thinking=True -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
131
+ | GLM-5 / 5.1 | `clear_thinking=False -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
132
+ | GLM-4.5 | `enable_thinking=False -> all`, else `tool_cycle` |
133
+ | gpt-oss | `auto_drop_analysis=False -> all`, else `tool_cycle` |
134
+ | Hy3 | `preserved_thinking=True -> all`, else `tool_cycle` |
135
+ | Kimi K2.5 / 2.6 | `thinking=False -> all`, else `tool_cycle` |
136
+ | Nemotron-3 | `truncate_history_thinking=False -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
137
+ | DeepSeek R1 | `template` |
138
+ | MiniMax M2 | `tool_cycle` |
139
+ | DeepSeek V3, Qwen3-VL, Kimi K2, Laguna XS.2 / XS-2.1, Llama 3 | `all` |
140
+ | PrimeIntellect Qwen3 | `all` |
141
+
142
+ Config construction raises when an explicit template knob directly contradicts
143
+ an explicit generic bridge policy. For example:
144
+
145
+ ```python
146
+ GLM5RendererConfig(clear_thinking=False, thinking_retention="tool_cycle")
147
+ # ValueError: clear_thinking=False implies thinking_retention="all"
148
+ ```
149
+
150
+ Generation-only no-thinking knobs, such as `enable_thinking=False`, do not
151
+ conflict with an explicit conservative `thinking_retention="tool_cycle"`. They
152
+ only change the derived default when `thinking_retention` is unset.
153
+
154
+ ## `DefaultRendererConfig`
155
+
156
+ `DefaultRenderer` wraps `tokenizer.apply_chat_template` for unsupported
157
+ text-only models. Its config sets `extra="allow"` so unknown fields are
158
+ forwarded as Jinja kwargs:
159
+
160
+ ```python
161
+ from renderers import create_renderer, DefaultRendererConfig
162
+
163
+ r = create_renderer(
164
+ tokenizer,
165
+ DefaultRendererConfig(
166
+ tool_parser="qwen3",
167
+ reasoning_parser="think",
168
+ enable_thinking=False,
169
+ custom_jinja_kwarg=True,
170
+ ),
171
+ )
172
+ ```
173
+
174
+ `tool_parser` and `reasoning_parser` configure `DefaultRenderer` itself. Every
175
+ other extra field lands in `model_extra` and is forwarded to
176
+ `apply_chat_template`.
177
+
178
+ `DefaultRenderer` rejects explicit `thinking_retention` and the removed
179
+ `preserve_*` flags. Its bridge always returns `None`, because the template's
180
+ turn-close structure is opaque to the renderer.
181
+
182
+ ## Downstream integration
183
+
184
+ Downstream pydantic configs can hold a single field typed as `RendererConfig`:
185
+
186
+ ```python
187
+ from pydantic import BaseModel, Field
188
+ from renderers import AutoRendererConfig, RendererConfig
189
+
190
+
191
+ class ClientConfig(BaseModel):
192
+ renderer: RendererConfig = Field(default_factory=AutoRendererConfig)
193
+ ```
194
+
195
+ In TOML or YAML, the `name` discriminator selects the variant:
196
+
197
+ ```toml
198
+ [client.renderer]
199
+ name = "qwen3.5"
200
+ enable_thinking = false
201
+ add_vision_id = true
202
+ thinking_retention = "all"
203
+ ```
204
+
205
+ Bogus combinations, such as `add_vision_id` under `name = "qwen3"`, raise at
206
+ config load with a pydantic validation error.
207
+
208
+ To construct a config from a renderer name string:
209
+
210
+ ```python
211
+ from renderers import config_from_name
212
+
213
+ cfg = config_from_name("glm-5") # GLM5RendererConfig()
214
+ cfg = config_from_name("auto") # None, the implicit auto form
215
+ ```
216
+
217
+ ## Renaming a renderer is a breaking change
218
+
219
+ The discriminator key is the renderer name string. Renaming `"qwen3.5"` to
220
+ something else would break downstream configs that reference it by name. Add
221
+ new renderers instead of renaming existing ones.
@@ -22,6 +22,7 @@ import json
22
22
  import os
23
23
 
24
24
  import sglang as sgl
25
+ from renderers.configs import Qwen35RendererConfig
25
26
  from renderers.gpt_oss import GptOssRenderer
26
27
  from renderers.qwen35 import Qwen35Renderer
27
28
  from transformers import AutoTokenizer
@@ -52,7 +53,9 @@ TOOLS = [
52
53
  def make_renderer(model: str, enable_thinking: bool | None):
53
54
  tokenizer = AutoTokenizer.from_pretrained(model, trust_remote_code=False)
54
55
  if model.startswith("Qwen/Qwen3.5-"):
55
- return Qwen35Renderer(tokenizer, enable_thinking=enable_thinking)
56
+ return Qwen35Renderer(
57
+ tokenizer, Qwen35RendererConfig(enable_thinking=enable_thinking)
58
+ )
56
59
  if model == "openai/gpt-oss-20b":
57
60
  return GptOssRenderer(tokenizer)
58
61
  raise ValueError(f"unsupported demo model: {model}")
@@ -62,8 +65,9 @@ def print_parsed(label: str, turn: str, parsed) -> None:
62
65
  print(f"\n[{label}] {turn}")
63
66
  if parsed.reasoning_content:
64
67
  print(f"reasoning: {parsed.reasoning_content[:240]}")
65
- if parsed.tool_calls:
66
- print(f"tool_calls: {json.dumps(parsed.tool_calls, ensure_ascii=False)}")
68
+ for tc in parsed.tool_calls:
69
+ # ``parse_response`` returns ``ParsedToolCall`` dataclasses, not dicts.
70
+ print(f"tool_call: {tc.name}({tc.arguments}) [{tc.status.value}]")
67
71
  if parsed.content:
68
72
  print(f"content: {parsed.content}")
69
73
 
@@ -141,21 +145,33 @@ def main() -> None:
141
145
  if parsed1.reasoning_content:
142
146
  assistant["reasoning_content"] = parsed1.reasoning_content
143
147
  if parsed1.tool_calls:
144
- assistant["tool_calls"] = parsed1.tool_calls
148
+ # Convert the parsed dataclasses back to OpenAI-format tool_calls.
149
+ assistant["tool_calls"] = [
150
+ {
151
+ "id": tc.id or f"call_{idx}",
152
+ "type": "function",
153
+ "function": {
154
+ "name": tc.name,
155
+ "arguments": tc.arguments
156
+ if isinstance(tc.arguments, str)
157
+ else json.dumps(tc.arguments),
158
+ },
159
+ }
160
+ for idx, tc in enumerate(parsed1.tool_calls)
161
+ ]
145
162
  messages.append(assistant)
146
163
 
147
164
  if parsed1.tool_calls:
148
165
  new_messages = []
149
166
  for idx, tool_call in enumerate(parsed1.tool_calls):
150
- fn = tool_call.get("function") or tool_call
151
- tool_args = fn.get("arguments") or {}
167
+ tool_args = tool_call.arguments or {}
152
168
  if isinstance(tool_args, str):
153
169
  tool_args = json.loads(tool_args)
154
170
  new_messages.append(
155
171
  {
156
172
  "role": "tool",
157
- "tool_call_id": tool_call.get("id", f"call_{idx}"),
158
- "name": fn.get("name", "multiply"),
173
+ "tool_call_id": tool_call.id or f"call_{idx}",
174
+ "name": tool_call.name or "multiply",
159
175
  "content": json.dumps(
160
176
  {"result": int(tool_args["a"]) * int(tool_args["b"])}
161
177
  ),
@@ -167,11 +183,14 @@ def main() -> None:
167
183
  ]
168
184
 
169
185
  # Turn 2: bridge extends prompt_ids + completion1 exactly.
170
- bridged_ids = renderer.bridge_to_next_turn(
186
+ # ``bridge_to_next_turn`` returns a ``RenderedTokens`` (or None); the
187
+ # extended id stream is on ``.token_ids``.
188
+ bridged = renderer.bridge_to_next_turn(
171
189
  prompt_ids, completion1, new_messages, tools=TOOLS
172
190
  )
173
- if bridged_ids is None:
191
+ if bridged is None:
174
192
  raise RuntimeError("bridge_to_next_turn returned None")
193
+ bridged_ids = bridged.token_ids
175
194
  assert bridged_ids[: len(prompt_ids) + len(completion1)] == (
176
195
  prompt_ids + completion1
177
196
  )