renderers 0.1.11.dev2__tar.gz → 0.1.11.dev4__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 (89) hide show
  1. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/PKG-INFO +9 -7
  2. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/README.md +8 -6
  3. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/docs/renderer-config.md +2 -0
  4. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/pyproject.toml +2 -2
  5. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/__init__.py +6 -0
  6. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/_version.py +2 -2
  7. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/base.py +146 -34
  8. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/configs.py +44 -0
  9. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/deepseek_v3.py +5 -2
  10. renderers-0.1.11.dev4/renderers/deepseek_v4.py +859 -0
  11. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/gemma4.py +3 -2
  12. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/glm45.py +5 -2
  13. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/glm5.py +5 -2
  14. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/gpt_oss.py +5 -2
  15. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/hy3.py +43 -11
  16. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/inkling.py +3 -2
  17. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/kimi_k2.py +5 -2
  18. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/kimi_k25.py +4 -3
  19. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/laguna_xs2.py +53 -7
  20. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/llama_3.py +5 -2
  21. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/minimax_m2.py +20 -6
  22. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/nemotron3.py +5 -2
  23. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/parsing.py +190 -0
  24. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/prime_qwen3.py +14 -4
  25. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/qwen3.py +5 -2
  26. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/qwen35.py +4 -3
  27. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/qwen3_vl.py +5 -4
  28. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/conftest.py +5 -0
  29. renderers-0.1.11.dev4/tests/reference_rendering.py +414 -0
  30. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_bridge.py +1 -0
  31. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_build_helpers.py +3 -14
  32. renderers-0.1.11.dev4/tests/test_deepseek_v4.py +565 -0
  33. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_load_tokenizer.py +16 -11
  34. renderers-0.1.11.dev4/tests/test_offsetless_tokenizers.py +223 -0
  35. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_render_ids.py +6 -15
  36. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_renderer_config_parity.py +22 -26
  37. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_roundtrip.py +1 -0
  38. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/.github/workflows/publish-dev.yml +0 -0
  39. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/.github/workflows/publish.yml +0 -0
  40. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/.github/workflows/style.yml +0 -0
  41. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/.github/workflows/test.yml +0 -0
  42. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/.gitignore +0 -0
  43. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/.pre-commit-config.yaml +0 -0
  44. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/LICENSE +0 -0
  45. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/examples/README.md +0 -0
  46. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/examples/sglang/multiturn_generate_sglang.py +0 -0
  47. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/examples/sglang/online_multiturn_sglang.py +0 -0
  48. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/examples/tinker/multiturn_generate_tinker.py +0 -0
  49. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/examples/transformers/multiturn_generate_transformers.py +0 -0
  50. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/examples/vllm/multiturn_generate_vllm.py +0 -0
  51. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/client.py +0 -0
  52. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/deepseek_r1.py +0 -0
  53. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/default.py +0 -0
  54. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/laguna_s21.py +0 -0
  55. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/parsers.py +0 -0
  56. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/qwen36.py +0 -0
  57. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/renderers/qwen38.py +0 -0
  58. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_client.py +0 -0
  59. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_deepseek_r1.py +0 -0
  60. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_disabled_thinking_stability.py +0 -0
  61. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_gemma4.py +0 -0
  62. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_glm_tool_name_validation.py +0 -0
  63. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_gpt_oss_harmony_parity.py +0 -0
  64. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_hy3.py +0 -0
  65. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_incremental.py +0 -0
  66. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_inkling.py +0 -0
  67. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_is_content.py +0 -0
  68. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_kimi_k25_tool_schema.py +0 -0
  69. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_laguna_m1.py +0 -0
  70. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_laguna_s21.py +0 -0
  71. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_laguna_xs21.py +0 -0
  72. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_llama_3.py +0 -0
  73. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_message_indices.py +0 -0
  74. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_message_tool_names.py +0 -0
  75. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_multimodal.py +0 -0
  76. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_nemotron3_parity.py +0 -0
  77. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_nemotron3_ultra.py +0 -0
  78. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_parse_response.py +0 -0
  79. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_parse_response_robustness.py +0 -0
  80. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_parsers.py +0 -0
  81. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_preserve_thinking.py +0 -0
  82. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_prime_qwen3_parity.py +0 -0
  83. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_qwen35_size_coverage.py +0 -0
  84. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_qwen38.py +0 -0
  85. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_renderer_config.py +0 -0
  86. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_sampled_mask.py +0 -0
  87. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_tokens_per_message.py +0 -0
  88. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/tests/test_tool_arg_type_preservation.py +0 -0
  89. {renderers-0.1.11.dev2 → renderers-0.1.11.dev4}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: renderers
3
- Version: 0.1.11.dev2
3
+ Version: 0.1.11.dev4
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
@@ -39,11 +39,13 @@ uv add 'renderers[transformers]'
39
39
  uv add 'renderers[multimodal]'
40
40
  ```
41
41
 
42
- A BYO tokenizer must expose `encode`, `decode`, `convert_tokens_to_ids`, token
43
- IDs such as `eos_token_id`, and `return_offsets_mapping=True` through its call
44
- interface. `DefaultRenderer` additionally requires `apply_chat_template`.
45
- This includes text-only Inkling training: `InklingRenderer` loads its
46
- Transformers processor only when image or audio content is actually rendered.
42
+ A BYO tokenizer must expose `encode`, `decode`, `convert_tokens_to_ids`, and
43
+ token IDs such as `eos_token_id`. Character offsets are optional: tokenizers
44
+ supporting `return_offsets_mapping=True` also receive precise per-token
45
+ `is_content` attribution; without offsets, renderers return `is_content=[]`.
46
+ `DefaultRenderer` additionally requires `apply_chat_template`. This includes
47
+ text-only Inkling training: `InklingRenderer` loads its Transformers processor
48
+ only when image or audio content is actually rendered.
47
49
 
48
50
  ## At a glance
49
51
 
@@ -75,7 +77,7 @@ next_prompt_ids = r.bridge_to_next_turn(
75
77
  )
76
78
  ```
77
79
 
78
- 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`, `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).
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).
79
81
 
80
82
  ## API
81
83
 
@@ -19,11 +19,13 @@ uv add 'renderers[transformers]'
19
19
  uv add 'renderers[multimodal]'
20
20
  ```
21
21
 
22
- A BYO tokenizer must expose `encode`, `decode`, `convert_tokens_to_ids`, token
23
- IDs such as `eos_token_id`, and `return_offsets_mapping=True` through its call
24
- interface. `DefaultRenderer` additionally requires `apply_chat_template`.
25
- This includes text-only Inkling training: `InklingRenderer` loads its
26
- Transformers processor only when image or audio content is actually rendered.
22
+ A BYO tokenizer must expose `encode`, `decode`, `convert_tokens_to_ids`, and
23
+ token IDs such as `eos_token_id`. Character offsets are optional: tokenizers
24
+ supporting `return_offsets_mapping=True` also receive precise per-token
25
+ `is_content` attribution; without offsets, renderers return `is_content=[]`.
26
+ `DefaultRenderer` additionally requires `apply_chat_template`. This includes
27
+ text-only Inkling training: `InklingRenderer` loads its Transformers processor
28
+ only when image or audio content is actually rendered.
27
29
 
28
30
  ## At a glance
29
31
 
@@ -55,7 +57,7 @@ next_prompt_ids = r.bridge_to_next_turn(
55
57
  )
56
58
  ```
57
59
 
58
- 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`, `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).
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).
59
61
 
60
62
  ## API
61
63
 
@@ -51,6 +51,7 @@ definition time. Template fields are covered by parity tests against
51
51
  | Nemotron-3.5 Lightning | `Nemotron35RendererConfig` | `enable_thinking`, `truncate_history_thinking` | - |
52
52
  | DeepSeek V3 | `DeepSeekV3RendererConfig` | - | - |
53
53
  | DeepSeek R1 | `DeepSeekR1RendererConfig` | - | - |
54
+ | DeepSeek V4 Flash 0731 | `DeepSeekV4RendererConfig` | `enable_thinking`, `drop_thinking`, `reasoning_effort` | - |
54
55
 
55
56
  Configs are frozen value objects. To override a field, construct a new instance
56
57
  or call `config.model_copy(update={...})`.
@@ -145,6 +146,7 @@ the knobs its template actually exposes:
145
146
  | Kimi K2.5 / 2.6 | `thinking=False -> all`, else `tool_cycle` |
146
147
  | Nemotron-3 / 3.5 | `truncate_history_thinking=False -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
147
148
  | DeepSeek R1 | `template` |
149
+ | DeepSeek V4 Flash 0731 | `enable_thinking=False` or `drop_thinking=False -> all`, else `tool_cycle` |
148
150
  | MiniMax M2 | `tool_cycle` |
149
151
  | DeepSeek V3, Qwen3-VL, Kimi K2, Laguna XS.2 / M.1 / XS-2.1 / S-2.1, Llama 3, Inkling | `all` |
150
152
  | PrimeIntellect Qwen3 | `all` |
@@ -39,8 +39,8 @@ dependencies = [
39
39
 
40
40
  [project.optional-dependencies]
41
41
  # Tokenizer loading uses Hugging Face. Text-only renderers can instead be
42
- # constructed with an offset-capable BYO tokenizer and do not import this
43
- # dependency.
42
+ # constructed with a compatible BYO tokenizer and do not import this
43
+ # dependency. Character offsets are optional.
44
44
  transformers = [
45
45
  # Keep this floor compatible with prime-rl's transformers pin. Inkling's
46
46
  # tokenizer and text-only renderer work on older releases; image/audio
@@ -15,6 +15,7 @@ from renderers.base import (
15
15
  Message,
16
16
  MultiModalData,
17
17
  MultimodalRenderer,
18
+ OffsetTokenizer,
18
19
  ParsedResponse,
19
20
  ParsedToolCall,
20
21
  PlaceholderRange,
@@ -47,6 +48,7 @@ from renderers.configs import (
47
48
  DefaultRendererConfig,
48
49
  DeepSeekR1RendererConfig,
49
50
  DeepSeekV3RendererConfig,
51
+ DeepSeekV4RendererConfig,
50
52
  GLM45RendererConfig,
51
53
  GLM51RendererConfig,
52
54
  GLM5RendererConfig,
@@ -84,6 +86,7 @@ from renderers.configs import (
84
86
  _LAZY_RENDERERS: dict[str, str] = {
85
87
  "DeepSeekR1Renderer": "renderers.deepseek_r1",
86
88
  "DeepSeekV3Renderer": "renderers.deepseek_v3",
89
+ "DeepSeekV4Renderer": "renderers.deepseek_v4",
87
90
  "DefaultRenderer": "renderers.default",
88
91
  "GLM45Renderer": "renderers.glm45",
89
92
  "GLM51Renderer": "renderers.glm5",
@@ -137,6 +140,8 @@ __all__ = [
137
140
  "DeepSeekR1RendererConfig",
138
141
  "DeepSeekV3Renderer",
139
142
  "DeepSeekV3RendererConfig",
143
+ "DeepSeekV4Renderer",
144
+ "DeepSeekV4RendererConfig",
140
145
  "DefaultRenderer",
141
146
  "DefaultRendererConfig",
142
147
  "GLM45Renderer",
@@ -181,6 +186,7 @@ __all__ = [
181
186
  "Nemotron3RendererConfig",
182
187
  "Nemotron3UltraRenderer",
183
188
  "Nemotron3UltraRendererConfig",
189
+ "OffsetTokenizer",
184
190
  "OverlongPromptError",
185
191
  "ParsedResponse",
186
192
  "ParsedToolCall",
@@ -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.11.dev2'
22
- __version_tuple__ = version_tuple = (0, 1, 11, 'dev2')
21
+ __version__ = version = '0.1.11.dev4'
22
+ __version_tuple__ = version_tuple = (0, 1, 11, 'dev4')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -11,6 +11,7 @@ from typing import (
11
11
  Literal,
12
12
  Protocol,
13
13
  TypedDict,
14
+ cast,
14
15
  runtime_checkable,
15
16
  )
16
17
 
@@ -243,7 +244,9 @@ class RenderedTokens:
243
244
  Empty ``sampled_mask`` (``[]``) means the renderer doesn't provide
244
245
  this signal — consumers should fall back to attribution-only
245
246
  masking. ``DefaultRenderer`` leaves it empty because the Jinja
246
- template is opaque; hand-coded renderers populate it.
247
+ template is opaque. Hand-coded renderers normally populate it; a
248
+ renderer whose sampled/scaffold boundary depends on character
249
+ attribution may leave it empty for an offsetless tokenizer.
247
250
 
248
251
  ``is_content`` is a per-token signal generalizing the "scaffold vs
249
252
  body" distinction across all roles: ``True`` iff the token was
@@ -269,7 +272,8 @@ class RenderedTokens:
269
272
 
270
273
  Empty ``is_content`` (``[]``) — like ``sampled_mask`` — means the
271
274
  renderer doesn't provide the signal. ``DefaultRenderer`` leaves it
272
- empty for the same reason.
275
+ empty because its Jinja template is opaque; all renderers leave it
276
+ empty when the supplied tokenizer cannot return character offsets.
273
277
 
274
278
  ``message_tool_names`` is the per-message tool function name list,
275
279
  parallel to ``message_roles`` (same length). For tool-role
@@ -476,7 +480,7 @@ class RenderedTokens:
476
480
 
477
481
  Returns an empty dict when :attr:`is_content` or
478
482
  :attr:`message_roles` is empty (renderer didn't populate the
479
- signal — e.g. ``DefaultRenderer``).
483
+ signal — e.g. ``DefaultRenderer`` or an offsetless tokenizer).
480
484
 
481
485
  Intended for selective loss masking: SFT on tool response
482
486
  bodies while RL acts only on assistant turns is the canonical
@@ -667,8 +671,8 @@ class Tokenizer(Protocol):
667
671
  Hugging Face tokenizers satisfy this protocol, as can lightweight BYO
668
672
  adapters around ``tokenizers.Tokenizer`` or another tokenizer backend.
669
673
  Keeping the renderer-facing contract here makes ``transformers`` optional
670
- for text rendering. Offset-capable ``__call__`` behavior is required by
671
- :func:`attribute_text_segments` to preserve BPE boundary attribution.
674
+ for text rendering. Character offsets are a separate optional capability;
675
+ see :class:`OffsetTokenizer`.
672
676
  """
673
677
 
674
678
  name_or_path: str
@@ -681,6 +685,17 @@ class Tokenizer(Protocol):
681
685
 
682
686
  def convert_tokens_to_ids(self, tokens: Any) -> Any: ...
683
687
 
688
+
689
+ @runtime_checkable
690
+ class OffsetTokenizer(Tokenizer, Protocol):
691
+ """Tokenizer that can return character offsets alongside token IDs.
692
+
693
+ Hand-coded renderers use this optional capability to distinguish caller
694
+ content from adjacent template scaffold without changing the underlying
695
+ BPE pass. A basic :class:`Tokenizer` remains sufficient for rendering token
696
+ IDs; when offsets are unavailable, renderers leave ``is_content`` empty.
697
+ """
698
+
684
699
  def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
685
700
 
686
701
 
@@ -967,6 +982,9 @@ MODEL_RENDERER_MAP: dict[str, str] = {
967
982
  # DeepSeek R1 (reasoning).
968
983
  "deepseek-ai/DeepSeek-R1": "deepseek-r1",
969
984
  "deepseek-ai/DeepSeek-R1-0528": "deepseek-r1",
985
+ # DeepSeek V4 Flash 0731 uses the repository's Python DSML encoder (the
986
+ # tokenizer intentionally ships no Jinja chat_template).
987
+ "deepseek-ai/DeepSeek-V4-Flash-0731": "deepseek-v4",
970
988
  # Kimi K2 (K2.5 and K2.6 share the K2.5 template, distinct from K2).
971
989
  "moonshotai/Kimi-K2-Instruct": "kimi-k2",
972
990
  "moonshotai/Kimi-K2.5": "kimi-k2.5",
@@ -1064,7 +1082,7 @@ _TRANSFORMERS_INSTALL_HINT = (
1064
1082
  "Install the optional dependency with "
1065
1083
  "`pip install 'renderers[transformers]'` (or "
1066
1084
  "`uv add 'renderers[transformers]'`). Text-only renderers work without "
1067
- "it when constructed with an offset-capable tokenizer object."
1085
+ "it when constructed with a compatible tokenizer object."
1068
1086
  )
1069
1087
 
1070
1088
 
@@ -1284,6 +1302,7 @@ def _populate_registry():
1284
1302
  return
1285
1303
  from renderers.deepseek_r1 import DeepSeekR1Renderer
1286
1304
  from renderers.deepseek_v3 import DeepSeekV3Renderer
1305
+ from renderers.deepseek_v4 import DeepSeekV4Renderer
1287
1306
  from renderers.default import DefaultRenderer
1288
1307
  from renderers.glm5 import GLM5Renderer, GLM51Renderer
1289
1308
  from renderers.glm45 import GLM45Renderer
@@ -1329,6 +1348,7 @@ def _populate_registry():
1329
1348
  "minimax-m2": MiniMaxM2Renderer,
1330
1349
  "deepseek-v3": DeepSeekV3Renderer,
1331
1350
  "deepseek-r1": DeepSeekR1Renderer,
1351
+ "deepseek-v4": DeepSeekV4Renderer,
1332
1352
  "hy3": Hy3Renderer,
1333
1353
  "inkling": InklingRenderer,
1334
1354
  "kimi-k2": KimiK2Renderer,
@@ -1759,36 +1779,122 @@ def trim_to_turn_close(
1759
1779
  return previous_ids
1760
1780
 
1761
1781
 
1762
- def _get_offset_tokenizer(tokenizer):
1763
- """Assert ``tokenizer`` supports ``return_offsets_mapping=True``.
1782
+ class AttributedTextSegments(list[tuple[int, bool]]):
1783
+ """Token/content pairs with an explicit attribution-availability flag."""
1784
+
1785
+ def __init__(
1786
+ self,
1787
+ values=(),
1788
+ *,
1789
+ has_content_attribution: bool,
1790
+ ) -> None:
1791
+ super().__init__(values)
1792
+ self.has_content_attribution = has_content_attribution
1793
+
1794
+
1795
+ def _get_offset_tokenizer(tokenizer: Tokenizer) -> OffsetTokenizer | None:
1796
+ """Return ``tokenizer`` when it supports character offsets, else ``None``.
1764
1797
 
1765
1798
  Hand-coded renderers concatenate scaffold + body in one BPE pass to
1766
1799
  preserve cross-boundary merges, then attribute each resulting token
1767
1800
  back to its source segment via the fast tokenizer's
1768
- ``offset_mapping`` (see :func:`attribute_text_segments`). The
1769
- contract: every BYO tokenizer must be a fast tokenizer with offset
1770
- support. Tokenizers loaded via :func:`load_tokenizer` are
1771
- ``PreTrainedTokenizerFast`` instances that satisfy this trivially.
1801
+ ``offset_mapping`` (see :func:`attribute_text_segments`). Tokenizers
1802
+ loaded via :func:`load_tokenizer` are ``PreTrainedTokenizerFast``
1803
+ instances that satisfy this capability, but BYO tokenizers need not.
1772
1804
  """
1805
+ call = getattr(tokenizer, "__call__", None)
1806
+ if not callable(call):
1807
+ return None
1773
1808
  try:
1774
- tokenizer("a", add_special_tokens=False, return_offsets_mapping=True)
1775
- except (NotImplementedError, ValueError, TypeError) as exc:
1776
- raise RuntimeError(
1777
- "Hand-coded renderers require a fast tokenizer with "
1778
- "``return_offsets_mapping=True`` support for body/scaffold "
1779
- "attribution. Pass a tokenizer loaded via "
1780
- "``renderers.base.load_tokenizer``, or any "
1781
- "``transformers.PreTrainedTokenizerFast`` instance."
1782
- ) from exc
1783
- return tokenizer
1809
+ encoding = call("a", add_special_tokens=False, return_offsets_mapping=True)
1810
+ encoding["input_ids"]
1811
+ encoding["offset_mapping"]
1812
+ except (KeyError, NotImplementedError, TypeError, ValueError):
1813
+ return None
1814
+ return cast(OffsetTokenizer, tokenizer)
1815
+
1816
+
1817
+ def _infer_offsets_from_decode(
1818
+ tokenizer: Tokenizer,
1819
+ token_ids: list[int],
1820
+ text: str,
1821
+ ) -> list[tuple[int, int]] | None:
1822
+ """Recover token character spans from an exact decoder round-trip.
1823
+
1824
+ This is a narrow fallback for metadata that does not require exposing
1825
+ content attribution. Some renderers join text from multiple messages in a
1826
+ single BPE pass, so they still need to associate the resulting tokens with
1827
+ the right message when a BYO tokenizer has no native offset mapping.
1828
+
1829
+ Decoding individual tokens is linear and exact for the common BPE/SentencePiece
1830
+ backends. Byte-fallback tokenizers can require multiple tokens before text
1831
+ becomes valid, so a validated cumulative-prefix pass handles that case.
1832
+ If either strategy cannot reconstruct ``text`` exactly, callers must use a
1833
+ conservative renderer-specific message-index fallback. This helper never
1834
+ upgrades the tokenizer's content-attribution capability: ``is_content``
1835
+ remains unavailable without native offsets.
1836
+ """
1837
+
1838
+ def decode(ids: list[int]) -> str | None:
1839
+ variants = (
1840
+ {"skip_special_tokens": False, "clean_up_tokenization_spaces": False},
1841
+ {"skip_special_tokens": False},
1842
+ {},
1843
+ )
1844
+ for kwargs in variants:
1845
+ try:
1846
+ decoded = tokenizer.decode(ids, **kwargs)
1847
+ except TypeError:
1848
+ continue
1849
+ except (KeyError, NotImplementedError, UnicodeError, ValueError):
1850
+ return None
1851
+ return decoded if isinstance(decoded, str) else None
1852
+ return None
1853
+
1854
+ pieces: list[str] = []
1855
+ for token_id in token_ids:
1856
+ piece = decode([token_id])
1857
+ if piece is None:
1858
+ break
1859
+ pieces.append(piece)
1860
+ if len(pieces) == len(token_ids) and "".join(pieces) == text:
1861
+ offsets: list[tuple[int, int]] = []
1862
+ position = 0
1863
+ for piece in pieces:
1864
+ end = position + len(piece)
1865
+ offsets.append((position, end))
1866
+ position = end
1867
+ return offsets
1868
+
1869
+ offsets = []
1870
+ previous_end = 0
1871
+ for end_index in range(1, len(token_ids) + 1):
1872
+ prefix = decode(token_ids[:end_index])
1873
+ if prefix is None or len(prefix) < previous_end or not text.startswith(prefix):
1874
+ return None
1875
+ current_end = len(prefix)
1876
+ offsets.append((previous_end, current_end))
1877
+ previous_end = current_end
1878
+ if previous_end != len(text):
1879
+ return None
1880
+ return offsets
1881
+
1882
+
1883
+ def _content_mask_or_empty(
1884
+ tokenizer: Tokenizer, content_mask: list[bool]
1885
+ ) -> list[bool]:
1886
+ """Return exact content attribution, or the empty-list unavailable sentinel."""
1887
+ if _get_offset_tokenizer(tokenizer) is None:
1888
+ return []
1889
+ return content_mask
1784
1890
 
1785
1891
 
1786
1892
  def attribute_text_segments(
1787
- tokenizer,
1893
+ tokenizer: Tokenizer,
1788
1894
  segments: "list[tuple[str, bool]]",
1789
1895
  *,
1790
1896
  overlap_is_content: bool = False,
1791
- ) -> "list[tuple[int, bool]]":
1897
+ ) -> AttributedTextSegments:
1792
1898
  """Tokenize concatenated segments as a single BPE pass and return
1793
1899
  ``(token_id, is_content)`` pairs.
1794
1900
 
@@ -1816,23 +1922,29 @@ def attribute_text_segments(
1816
1922
  every body byte inside the ``is_content=True`` run at the cost of a
1817
1923
  few adjacent wrap bytes.
1818
1924
 
1819
- Requires a HuggingFace fast tokenizer with offset tracking. Every
1820
- model in ``MODEL_RENDERER_MAP`` ships one, so the offset lookup
1821
- always succeeds for tokenizers obtained via :func:`load_tokenizer`.
1822
- BYO tokenizers must be a ``PreTrainedTokenizerFast`` (or anything
1823
- else exposing ``return_offsets_mapping=True``); slow tokenizers
1824
- aren't supported BPE drift at the wrap/body boundary would
1825
- defeat the whole point.
1925
+ When ``tokenizer`` implements :class:`OffsetTokenizer`, the result's
1926
+ ``has_content_attribution`` flag is true and each bool is exact. For a
1927
+ basic :class:`Tokenizer`, the joined text is still encoded in one pass so
1928
+ token IDs remain identical, but the bools are placeholders and
1929
+ ``has_content_attribution`` is false. Renderers propagate that state as an
1930
+ empty ``RenderedTokens.is_content`` list rather than exposing a partial or
1931
+ inaccurate mask.
1826
1932
 
1827
1933
  Empty input or empty joined text returns an empty list.
1828
1934
  """
1829
1935
  if not segments:
1830
- return []
1936
+ return AttributedTextSegments([], has_content_attribution=True)
1831
1937
  full_text = "".join(text for text, _ in segments)
1832
1938
  if not full_text:
1833
- return []
1939
+ return AttributedTextSegments([], has_content_attribution=True)
1834
1940
 
1835
1941
  offset_tokenizer = _get_offset_tokenizer(tokenizer)
1942
+ if offset_tokenizer is None:
1943
+ token_ids = tokenizer.encode(full_text, add_special_tokens=False)
1944
+ return AttributedTextSegments(
1945
+ ((token_id, False) for token_id in token_ids),
1946
+ has_content_attribution=False,
1947
+ )
1836
1948
  encoding = offset_tokenizer(
1837
1949
  full_text,
1838
1950
  add_special_tokens=False,
@@ -1887,7 +1999,7 @@ def attribute_text_segments(
1887
1999
  # the last non-empty segment's bit.
1888
2000
  pass
1889
2001
  out.append((tok_id, is_content))
1890
- return out
2002
+ return AttributedTextSegments(out, has_content_attribution=True)
1891
2003
 
1892
2004
 
1893
2005
  def reject_assistant_in_extension(new_messages: list[Message]) -> bool:
@@ -930,6 +930,47 @@ class DeepSeekR1RendererConfig(BaseRendererConfig):
930
930
  _template_fields = frozenset()
931
931
 
932
932
 
933
+ class DeepSeekV4RendererConfig(BaseRendererConfig):
934
+ """DeepSeek-V4-Flash-0731 reference-encoder configuration.
935
+
936
+ The checkpoint ships a Python encoder rather than a Jinja template. These
937
+ fields mirror its public controls: chat vs thinking mode, historical
938
+ reasoning dropping, and the opt-in thinking-effort prefix.
939
+ """
940
+
941
+ name: Literal["deepseek-v4"] = "deepseek-v4"
942
+ _template_fields = frozenset(
943
+ {"enable_thinking", "drop_thinking", "reasoning_effort"}
944
+ )
945
+
946
+ enable_thinking: bool = False
947
+ """Select thinking mode. ``False`` matches the official inference script."""
948
+
949
+ drop_thinking: bool = True
950
+ """Drop reasoning before the latest user query when no tools are present.
951
+
952
+ The reference encoder automatically preserves all reasoning whenever tools
953
+ are supplied, regardless of this value.
954
+ """
955
+
956
+ reasoning_effort: Literal["low", "high", "max"] = "low"
957
+ """Thinking-only effort prefix; ``low`` adds no text.
958
+
959
+ ``low`` is the checkpoint Python encoder's default. DeepSeek's hosted API
960
+ independently defaults its thinking effort to ``high``.
961
+ """
962
+
963
+ @model_validator(mode="after")
964
+ def _check_thinking_retention(self):
965
+ _reject_thinking_retention_conflict(
966
+ self,
967
+ "drop_thinking",
968
+ true_implies="tool_cycle",
969
+ false_implies="all",
970
+ )
971
+ return self
972
+
973
+
933
974
  RendererConfig = Annotated[
934
975
  Union[
935
976
  AutoRendererConfig,
@@ -960,6 +1001,7 @@ RendererConfig = Annotated[
960
1001
  Nemotron35RendererConfig,
961
1002
  DeepSeekV3RendererConfig,
962
1003
  DeepSeekR1RendererConfig,
1004
+ DeepSeekV4RendererConfig,
963
1005
  ],
964
1006
  Field(discriminator="name"),
965
1007
  ]
@@ -1006,6 +1048,7 @@ _CONFIG_BY_NAME: dict[str, type[BaseRendererConfig]] = {
1006
1048
  "nemotron-3.5": Nemotron35RendererConfig,
1007
1049
  "deepseek-v3": DeepSeekV3RendererConfig,
1008
1050
  "deepseek-r1": DeepSeekR1RendererConfig,
1051
+ "deepseek-v4": DeepSeekV4RendererConfig,
1009
1052
  }
1010
1053
 
1011
1054
 
@@ -1039,6 +1082,7 @@ __all__ = [
1039
1082
  "DefaultRendererConfig",
1040
1083
  "DeepSeekR1RendererConfig",
1041
1084
  "DeepSeekV3RendererConfig",
1085
+ "DeepSeekV4RendererConfig",
1042
1086
  "GLM45RendererConfig",
1043
1087
  "GLM51RendererConfig",
1044
1088
  "GLM5RendererConfig",
@@ -20,6 +20,7 @@ from renderers.base import (
20
20
  RenderedTokens,
21
21
  ToolSpec,
22
22
  Tokenizer,
23
+ _content_mask_or_empty,
23
24
  attribute_text_segments,
24
25
  extract_message_tool_names,
25
26
  reject_assistant_in_extension,
@@ -259,7 +260,7 @@ class DeepSeekV3Renderer:
259
260
  token_ids=tokens,
260
261
  message_indices=indices,
261
262
  sampled_mask=sampled,
262
- is_content=content_mask,
263
+ is_content=_content_mask_or_empty(self._tokenizer, content_mask),
263
264
  message_roles=[m.get("role") or "" for m in messages],
264
265
  message_tool_names=extract_message_tool_names(messages),
265
266
  )
@@ -408,7 +409,9 @@ class DeepSeekV3Renderer:
408
409
  token_ids=previous_ids + ext,
409
410
  message_indices=[-1] * len(previous_ids) + ext_indices,
410
411
  sampled_mask=[False] * total_len,
411
- is_content=[False] * len(previous_ids) + ext_content,
412
+ is_content=_content_mask_or_empty(
413
+ self._tokenizer, [False] * len(previous_ids) + ext_content
414
+ ),
412
415
  message_roles=[m.get("role") or "" for m in new_messages],
413
416
  message_tool_names=extract_message_tool_names(new_messages),
414
417
  )