renderers 0.1.12.dev16__tar.gz → 0.1.12.dev18__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 (90) hide show
  1. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/.github/workflows/test.yml +1 -1
  2. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/PKG-INFO +4 -3
  3. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/README.md +2 -1
  4. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/examples/sglang/multiturn_generate_sglang.py +1 -1
  5. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/examples/sglang/online_multiturn_sglang.py +1 -1
  6. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/examples/transformers/multiturn_generate_transformers.py +1 -1
  7. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/examples/vllm/multiturn_generate_vllm.py +1 -1
  8. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/pyproject.toml +1 -1
  9. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/_version.py +2 -2
  10. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/base.py +98 -50
  11. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/configs.py +16 -16
  12. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/glm45.py +11 -35
  13. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/glm5.py +21 -32
  14. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_build_helpers.py +104 -0
  15. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_message_indices.py +63 -0
  16. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_parsers.py +10 -0
  17. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_renderer_config.py +6 -0
  18. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_sampled_mask.py +2 -0
  19. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/uv.lock +5 -288
  20. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/.github/workflows/publish-dev.yml +0 -0
  21. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/.github/workflows/publish.yml +0 -0
  22. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/.github/workflows/style.yml +0 -0
  23. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/.gitignore +0 -0
  24. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/.pre-commit-config.yaml +0 -0
  25. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/LICENSE +0 -0
  26. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/docs/renderer-config.md +0 -0
  27. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/examples/README.md +0 -0
  28. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/examples/tinker/multiturn_generate_tinker.py +0 -0
  29. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/__init__.py +0 -0
  30. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/client.py +0 -0
  31. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/custom.py +0 -0
  32. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/deepseek_r1.py +0 -0
  33. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/deepseek_v3.py +0 -0
  34. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/deepseek_v4.py +0 -0
  35. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/default.py +0 -0
  36. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/gemma4.py +0 -0
  37. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/gpt_oss.py +0 -0
  38. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/hy3.py +0 -0
  39. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/inkling.py +0 -0
  40. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/kimi_k2.py +0 -0
  41. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/kimi_k25.py +0 -0
  42. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/laguna_s21.py +0 -0
  43. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/laguna_xs2.py +0 -0
  44. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/llama_3.py +0 -0
  45. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/minimax_m2.py +0 -0
  46. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/nemotron3.py +0 -0
  47. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/parsers.py +0 -0
  48. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/parsing.py +0 -0
  49. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/prime_qwen3.py +0 -0
  50. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/qwen3.py +0 -0
  51. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/qwen35.py +0 -0
  52. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/qwen36.py +0 -0
  53. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/qwen38.py +0 -0
  54. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/qwen3_vl.py +0 -0
  55. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/renderers/reasoning.py +0 -0
  56. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/conftest.py +0 -0
  57. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/parity.py +0 -0
  58. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/reference_rendering.py +0 -0
  59. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_bridge.py +0 -0
  60. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_client.py +0 -0
  61. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_custom.py +0 -0
  62. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_deepseek_r1.py +0 -0
  63. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_deepseek_v4.py +0 -0
  64. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_disabled_thinking_stability.py +0 -0
  65. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_gemma4.py +0 -0
  66. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_glm_tool_name_validation.py +0 -0
  67. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_hy3.py +0 -0
  68. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_incremental.py +0 -0
  69. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_inkling.py +0 -0
  70. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_is_content.py +0 -0
  71. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_kimi_k25_tool_schema.py +0 -0
  72. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_laguna_m1.py +0 -0
  73. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_laguna_xs21.py +0 -0
  74. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_llama_3.py +0 -0
  75. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_load_tokenizer.py +0 -0
  76. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_message_tool_names.py +0 -0
  77. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_multimodal.py +0 -0
  78. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_nemotron35_vlm.py +0 -0
  79. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_nemotron3_ultra.py +0 -0
  80. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_offsetless_tokenizers.py +0 -0
  81. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_parity.py +0 -0
  82. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_parse_response.py +0 -0
  83. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_parse_response_robustness.py +0 -0
  84. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_preserve_thinking.py +0 -0
  85. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_qwen35_size_coverage.py +0 -0
  86. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_qwen38.py +0 -0
  87. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_reasoning_boundaries.py +0 -0
  88. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_roundtrip.py +0 -0
  89. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_tokens_per_message.py +0 -0
  90. {renderers-0.1.12.dev16 → renderers-0.1.12.dev18}/tests/test_tool_arg_type_preservation.py +0 -0
@@ -21,7 +21,7 @@ jobs:
21
21
  strategy:
22
22
  fail-fast: false
23
23
  matrix:
24
- python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
24
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
25
25
 
26
26
  steps:
27
27
  - uses: actions/checkout@v4
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: renderers
3
- Version: 0.1.12.dev16
3
+ Version: 0.1.12.dev18
4
4
  Summary: Chat template renderers — deterministic message-to-token conversion for LLM training
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
7
- Requires-Python: <3.15,>=3.10
7
+ Requires-Python: <3.15,>=3.11
8
8
  Requires-Dist: jinja2
9
9
  Requires-Dist: numpy
10
10
  Requires-Dist: openai-harmony>=0.0.4
@@ -127,7 +127,8 @@ class Renderer(Protocol):
127
127
  def bridge_to_next_turn(prev_prompt_ids, prev_completion_ids, new_messages, *, tools=None) -> list[int] | None: ...
128
128
  ```
129
129
 
130
- - `RenderedTokens` carries `token_ids` **and** `message_indices` — one entry per token attributing each to its source message (`-1` for structural scaffolding). Lets `build_training_sample` build a per-token loss mask in one render.
130
+ - `RenderedTokens` carries `token_ids` **and** `message_indices` — one entry per token attributing each to its owning message (`-1` for structural scaffolding). Sampled closing markers belong to their assistant, including GLM markers emitted at the next user/tool boundary; user/tool bodies keep their own ownership.
131
+ - `build_training_sample(..., message_loss_mask=[0, 0, 0, 1])` keeps all messages as context but restricts loss to selected messages. Supply one boolean or integer `0`/`1` per message, or `None` for the normal mask. Excluding an assistant also excludes its closing token. Selection never enables targets disabled by the existing role/content settings or changes token IDs.
131
132
  - `ParsedResponse` is `(content, reasoning_content, tool_calls)`. It scans token ids for special-token boundaries (e.g. id `151657` for `<tool_call>` on Qwen3) — a literal `"<tool_call>"` in user content tokenizes to ordinary text ids and never matches.
132
133
  - Round-trip: rendering `[user, assistant(content, reasoning, tool_calls)]`, slicing the assistant completion, and feeding it through `parse_response` returns an equivalent structured message. Tested per-renderer in `tests/test_roundtrip.py`.
133
134
 
@@ -107,7 +107,8 @@ class Renderer(Protocol):
107
107
  def bridge_to_next_turn(prev_prompt_ids, prev_completion_ids, new_messages, *, tools=None) -> list[int] | None: ...
108
108
  ```
109
109
 
110
- - `RenderedTokens` carries `token_ids` **and** `message_indices` — one entry per token attributing each to its source message (`-1` for structural scaffolding). Lets `build_training_sample` build a per-token loss mask in one render.
110
+ - `RenderedTokens` carries `token_ids` **and** `message_indices` — one entry per token attributing each to its owning message (`-1` for structural scaffolding). Sampled closing markers belong to their assistant, including GLM markers emitted at the next user/tool boundary; user/tool bodies keep their own ownership.
111
+ - `build_training_sample(..., message_loss_mask=[0, 0, 0, 1])` keeps all messages as context but restricts loss to selected messages. Supply one boolean or integer `0`/`1` per message, or `None` for the normal mask. Excluding an assistant also excludes its closing token. Selection never enables targets disabled by the existing role/content settings or changes token IDs.
111
112
  - `ParsedResponse` is `(content, reasoning_content, tool_calls)`. It scans token ids for special-token boundaries (e.g. id `151657` for `<tool_call>` on Qwen3) — a literal `"<tool_call>"` in user content tokenizes to ordinary text ids and never matches.
112
113
  - Round-trip: rendering `[user, assistant(content, reasoning, tool_calls)]`, slicing the assistant completion, and feeding it through `parse_response` returns an equivalent structured message. Tested per-renderer in `tests/test_roundtrip.py`.
113
114
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env -S uv run --script
2
2
  # /// script
3
- # requires-python = ">=3.10,<3.14"
3
+ # requires-python = ">=3.11,<3.14"
4
4
  # dependencies = [
5
5
  # "renderers>=0.1.6",
6
6
  # "sglang==0.5.10.post1",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env -S uv run --script
2
2
  # /// script
3
- # requires-python = ">=3.10,<3.14"
3
+ # requires-python = ">=3.11,<3.14"
4
4
  # dependencies = [
5
5
  # "renderers>=0.1.6",
6
6
  # "transformers>=5.3.0",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env -S uv run --script
2
2
  # /// script
3
- # requires-python = ">=3.10,<3.14"
3
+ # requires-python = ">=3.11,<3.14"
4
4
  # dependencies = [
5
5
  # "renderers>=0.1.6",
6
6
  # "transformers>=4.50.0",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env -S uv run --script
2
2
  # /// script
3
- # requires-python = ">=3.10,<3.14"
3
+ # requires-python = ">=3.11,<3.14"
4
4
  # dependencies = [
5
5
  # "renderers>=0.1.6",
6
6
  # "vllm>=0.20",
@@ -13,7 +13,7 @@ description = "Chat template renderers — deterministic message-to-token conver
13
13
  readme = "README.md"
14
14
  license = "Apache-2.0"
15
15
  license-files = ["LICENSE"]
16
- requires-python = ">=3.10,<3.15"
16
+ requires-python = ">=3.11,<3.15"
17
17
  dependencies = [
18
18
  "numpy",
19
19
  "openai>=1.108.1",
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.12.dev16'
22
- __version_tuple__ = version_tuple = (0, 1, 12, 'dev16')
21
+ __version__ = version = '0.1.12.dev18'
22
+ __version_tuple__ = version_tuple = (0, 1, 12, 'dev18')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -1,15 +1,14 @@
1
- from __future__ import annotations
2
-
3
- import enum
4
1
  import logging
5
2
  from collections.abc import Mapping
6
3
  from dataclasses import dataclass, field
4
+ from enum import StrEnum
7
5
  from typing import (
8
6
  TYPE_CHECKING,
9
7
  Any,
10
8
  Callable,
11
9
  Literal,
12
10
  Protocol,
11
+ Required,
13
12
  TypedDict,
14
13
  cast,
15
14
  runtime_checkable,
@@ -110,8 +109,8 @@ class Message(TypedDict, total=False):
110
109
  Optional keys mirror the OpenAI chat format for tool calling.
111
110
  """
112
111
 
113
- role: str
114
- content: Content
112
+ role: Required[str]
113
+ content: Required[Content]
115
114
  tool_calls: list[ToolCall]
116
115
  tool_call_id: str
117
116
  name: str
@@ -224,8 +223,11 @@ class MultiModalData:
224
223
  class RenderedTokens:
225
224
  """Result of rendering messages to tokens.
226
225
 
227
- Each token carries an index into the original message list so callers can
228
- build per-token loss masks without re-rendering. Tokens from structural
226
+ Each token carries its owning message's index into the original message
227
+ list so callers can build per-token loss masks without re-rendering.
228
+ Sampled closing tokens belong to the assistant that generated them, even
229
+ when emitted while rendering the next message's role marker. User/tool
230
+ bodies retain their own message indices. Tokens from structural
229
231
  scaffolding the renderer adds outside any single message (e.g. the
230
232
  trailing generation prompt) carry index ``-1``.
231
233
 
@@ -383,7 +385,10 @@ class RenderedTokens:
383
385
  is not represented.
384
386
 
385
387
  Hand-coded renderers emit each message's tokens contiguously,
386
- so the span is well-defined. The implementation tolerates
388
+ so the span is well-defined. Spans follow input-message indices,
389
+ not necessarily token order: templates can reorder tool results.
390
+ Empty messages whose role marker closes the preceding assistant
391
+ may have no tokens of their own. The implementation tolerates
387
392
  non-contiguous attribution by returning the outer span
388
393
  ``(first_k, last_k + 1)``; if you suspect interleaving, slice
389
394
  ``message_indices`` yourself to verify.
@@ -556,7 +561,7 @@ class RenderedTokens:
556
561
  return mask
557
562
 
558
563
 
559
- class ToolCallParseStatus(str, enum.Enum):
564
+ class ToolCallParseStatus(StrEnum):
560
565
  """Per-attempt outcome of parsing a single ``<tool_call>`` block.
561
566
 
562
567
  The renderer parser's job is JSON-syntax → ``dict`` (the parser-level
@@ -1304,15 +1309,19 @@ def load_tokenizer(model_name_or_path: str):
1304
1309
  the requested Meta ID so auto-resolution still selects ``Llama3Renderer``.
1305
1310
  Requires the ``renderers[transformers]`` extra.
1306
1311
  """
1307
- _require_transformers("Loading a tokenizer")
1308
- load_name_or_path = _tokenizer_source_for(model_name_or_path)
1309
- kwargs = _tokenizer_load_kwargs(load_name_or_path)
1310
- tok = _load_tokenizer_via_auto(load_name_or_path, **kwargs)
1311
- return _preserve_requested_tokenizer_name(
1312
- tok,
1313
- requested_name_or_path=model_name_or_path,
1314
- loaded_name_or_path=load_name_or_path,
1315
- )
1312
+ try:
1313
+ _require_transformers("Loading a tokenizer")
1314
+ load_name_or_path = _tokenizer_source_for(model_name_or_path)
1315
+ kwargs = _tokenizer_load_kwargs(load_name_or_path)
1316
+ tok = _load_tokenizer_via_auto(load_name_or_path, **kwargs)
1317
+ return _preserve_requested_tokenizer_name(
1318
+ tok,
1319
+ requested_name_or_path=model_name_or_path,
1320
+ loaded_name_or_path=load_name_or_path,
1321
+ )
1322
+ except Exception as exc:
1323
+ exc.add_note(f"While loading tokenizer {model_name_or_path!r}.")
1324
+ raise
1316
1325
 
1317
1326
 
1318
1327
  def _populate_registry():
@@ -1387,7 +1396,7 @@ def _populate_registry():
1387
1396
 
1388
1397
  def create_renderer(
1389
1398
  tokenizer,
1390
- config: RendererConfig | None = None,
1399
+ config: "RendererConfig | None" = None,
1391
1400
  *,
1392
1401
  chat_template_kwargs: Mapping[str, Any] | None = None,
1393
1402
  ) -> Renderer:
@@ -1419,26 +1428,34 @@ def create_renderer(
1419
1428
  """
1420
1429
  _populate_registry()
1421
1430
 
1422
- config = _resolve_renderer_config(
1423
- tokenizer,
1424
- config,
1425
- chat_template_kwargs=chat_template_kwargs,
1426
- )
1427
- from renderers.configs import CustomRendererConfig
1428
- from renderers.custom import custom_renderer_config, load_custom_renderer
1429
-
1430
- if isinstance(config, CustomRendererConfig):
1431
- renderer_cls = load_custom_renderer(config.import_path)
1432
- return renderer_cls(tokenizer, custom_renderer_config(config))
1433
- cls = RENDERER_REGISTRY.get(config.name)
1434
- if cls is None:
1435
- raise ValueError(
1436
- f"Unknown renderer {config.name!r}. Available: {', '.join(sorted(RENDERER_REGISTRY))}"
1431
+ try:
1432
+ config = _resolve_renderer_config(
1433
+ tokenizer,
1434
+ config,
1435
+ chat_template_kwargs=chat_template_kwargs,
1436
+ )
1437
+ from renderers.configs import CustomRendererConfig
1438
+ from renderers.custom import custom_renderer_config, load_custom_renderer
1439
+
1440
+ if isinstance(config, CustomRendererConfig):
1441
+ renderer_cls = load_custom_renderer(config.import_path)
1442
+ return renderer_cls(tokenizer, custom_renderer_config(config))
1443
+ cls = RENDERER_REGISTRY.get(config.name)
1444
+ if cls is None:
1445
+ raise ValueError(
1446
+ f"Unknown renderer {config.name!r}. Available: {', '.join(sorted(RENDERER_REGISTRY))}"
1447
+ )
1448
+ return cls(tokenizer, config)
1449
+ except Exception as exc:
1450
+ model_name = getattr(tokenizer, "name_or_path", "<unnamed tokenizer>")
1451
+ renderer_name = config.name if config is not None else "auto"
1452
+ exc.add_note(
1453
+ f"While creating renderer {renderer_name!r} for tokenizer {model_name!r}."
1437
1454
  )
1438
- return cls(tokenizer, config)
1455
+ raise
1439
1456
 
1440
1457
 
1441
- def template_field_names(config: RendererConfig) -> frozenset[str]:
1458
+ def template_field_names(config: "RendererConfig") -> frozenset[str]:
1442
1459
  """Chat-template kwargs that ``config``'s renderer accepts, custom renderers included."""
1443
1460
  from renderers.configs import CustomRendererConfig
1444
1461
  from renderers.custom import load_custom_renderer
@@ -1451,9 +1468,9 @@ def template_field_names(config: RendererConfig) -> frozenset[str]:
1451
1468
 
1452
1469
 
1453
1470
  def merge_chat_template_kwargs(
1454
- config: RendererConfig,
1471
+ config: "RendererConfig",
1455
1472
  chat_template_kwargs: Mapping[str, Any] | None,
1456
- ) -> RendererConfig:
1473
+ ) -> "RendererConfig":
1457
1474
  """Return ``config`` with the template kwargs applied, validated against its allowlist."""
1458
1475
  if not chat_template_kwargs:
1459
1476
  return config
@@ -1498,10 +1515,10 @@ def merge_chat_template_kwargs(
1498
1515
 
1499
1516
  def _resolve_renderer_config(
1500
1517
  tokenizer,
1501
- config: RendererConfig | None,
1518
+ config: "RendererConfig | None",
1502
1519
  *,
1503
1520
  chat_template_kwargs: Mapping[str, Any] | None = None,
1504
- ) -> RendererConfig:
1521
+ ) -> "RendererConfig":
1505
1522
  """Resolve auto/default config and merge chat-template kwargs."""
1506
1523
  from renderers.configs import AutoRendererConfig
1507
1524
 
@@ -1520,10 +1537,10 @@ def _resolve_renderer_config(
1520
1537
 
1521
1538
  def _resolve_auto_config(
1522
1539
  tokenizer,
1523
- auto: AutoRendererConfig,
1540
+ auto: "AutoRendererConfig",
1524
1541
  *,
1525
1542
  chat_template_kwargs: Mapping[str, Any] | None = None,
1526
- ) -> RendererConfig:
1543
+ ) -> "RendererConfig":
1527
1544
  """Map ``AutoRendererConfig`` → concrete typed config via the
1528
1545
  tokenizer's ``name_or_path``.
1529
1546
 
@@ -1640,6 +1657,7 @@ def build_training_sample(
1640
1657
  tools: list[ToolSpec] | None = None,
1641
1658
  content_sft_roles: "set[str] | frozenset[str] | None" = None,
1642
1659
  ensure_final_stop: bool = False,
1660
+ message_loss_mask: list[bool | int] | None = None,
1643
1661
  ) -> RenderedTrainingSample:
1644
1662
  """Build a :class:`RenderedTrainingSample` for supervised training.
1645
1663
 
@@ -1656,11 +1674,8 @@ def build_training_sample(
1656
1674
  it's attributed to. This is the recommended default for renderer
1657
1675
  callers — the renderer owns the per-token "is this model output"
1658
1676
  signal, so role-level filtering becomes a downstream constraint
1659
- rather than a precondition. (Some role markers — e.g. GLM
1660
- ``<|user|>`` / ``<|observation|>`` after a tool-calling assistant
1661
- turn — *are* sampled by the model at inference and live inside the
1662
- next message's span; ``sampled_mask`` captures that, but a
1663
- naive role filter would mask them out.)
1677
+ rather than a precondition. Sampled closing markers, including GLM's
1678
+ ``<|user|>`` / ``<|observation|>``, are attributed to their assistant.
1664
1679
 
1665
1680
  When ``role_to_mask`` is provided, ``loss_mask`` is the AND of the
1666
1681
  role-based attribution and the sampled signal: only tokens the
@@ -1706,7 +1721,27 @@ def build_training_sample(
1706
1721
  fires, the output intentionally diverges from ``apply_chat_template``.
1707
1722
  Ignored for renderers without ``sampled_mask`` (``DefaultRenderer``) —
1708
1723
  the close of an opaque template can't be located reliably.
1724
+
1725
+ ``message_loss_mask`` optionally selects messages for supervision with
1726
+ one boolean or integer 0/1 per message. It only restricts the existing
1727
+ mask: selecting a message never enables targets disabled by the role,
1728
+ sampled, or content settings. Unselected messages remain context,
1729
+ including prefilled assistant turns at arbitrary positions. Sampled
1730
+ closing tokens follow their owning assistant, even when a template
1731
+ emits them at the next message boundary. A synthesized final stop
1732
+ follows the final message's selection. Token ids are unaffected.
1733
+ ``None`` preserves the existing mask.
1709
1734
  """
1735
+ if message_loss_mask is not None:
1736
+ if len(message_loss_mask) != len(messages):
1737
+ raise ValueError("message_loss_mask must have one entry per message")
1738
+ if any(
1739
+ not isinstance(value, (bool, int)) or value not in (0, 1)
1740
+ for value in message_loss_mask
1741
+ ):
1742
+ raise ValueError(
1743
+ "message_loss_mask entries must be booleans or integers 0/1"
1744
+ )
1710
1745
  rendered = renderer.render(messages, tools=tools)
1711
1746
  has_sampled_info = len(rendered.sampled_mask) == len(rendered.token_ids)
1712
1747
  has_content_info = len(rendered.is_content) == len(rendered.token_ids)
@@ -1765,6 +1800,19 @@ def build_training_sample(
1765
1800
  # stop is a training target, like any sampled token.
1766
1801
  loss_mask.append(True)
1767
1802
 
1803
+ if message_loss_mask is not None:
1804
+ for k in range(len(loss_mask)):
1805
+ # Any synthesized stop after the rendered sequence belongs to
1806
+ # the final assistant, just like an in-message closing token.
1807
+ msg_idx = (
1808
+ rendered.message_indices[k]
1809
+ if k < len(rendered.message_indices)
1810
+ else len(messages) - 1
1811
+ )
1812
+ loss_mask[k] = (
1813
+ loss_mask[k] and msg_idx >= 0 and bool(message_loss_mask[msg_idx])
1814
+ )
1815
+
1768
1816
  # Surface the multimodal payload for VLM renderers. ``None`` for text
1769
1817
  # renderers and for text-only samples (empty media) so downstream
1770
1818
  # ``multi_modal_data is not None`` is a reliable "has media" check.
@@ -2080,8 +2128,8 @@ def introduces_user_query(
2080
2128
 
2081
2129
  def resolve_thinking_retention(
2082
2130
  config: Any,
2083
- implied: ResolvedThinkingRetention,
2084
- ) -> ResolvedThinkingRetention:
2131
+ implied: "ResolvedThinkingRetention",
2132
+ ) -> "ResolvedThinkingRetention":
2085
2133
  """Resolve the effective bridge policy for a renderer instance.
2086
2134
 
2087
2135
  ``config.thinking_retention is None`` means "derive from template knobs";
@@ -2095,7 +2143,7 @@ def resolve_thinking_retention(
2095
2143
 
2096
2144
 
2097
2145
  def should_rerender_for_thinking_retention(
2098
- thinking_retention: ResolvedThinkingRetention,
2146
+ thinking_retention: "ResolvedThinkingRetention",
2099
2147
  new_messages: list[Message],
2100
2148
  *,
2101
2149
  is_user_query: Callable[[Message], bool] = _is_user_message,
@@ -21,7 +21,7 @@ keys its tokenizer's template will honour, so it can't enumerate them.
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
- from typing import Annotated, ClassVar, Literal, Union
24
+ from typing import Annotated, ClassVar, Literal, Self, Union
25
25
 
26
26
  from pydantic import ConfigDict, Field, model_validator
27
27
  from pydantic_config import BaseConfig
@@ -159,7 +159,7 @@ class DefaultRendererConfig(BaseRendererConfig):
159
159
  _allow_opaque_template_kwargs = True
160
160
 
161
161
  @model_validator(mode="after")
162
- def _reject_legacy_preserve_flags(self):
162
+ def _reject_legacy_preserve_flags(self) -> Self:
163
163
  # ``extra="allow"`` would otherwise swallow the removed ``preserve_*``
164
164
  # bools into ``model_extra`` and forward them to apply_chat_template,
165
165
  # silently dropping the user's intent (DefaultRenderer can't
@@ -261,7 +261,7 @@ class Qwen36RendererConfig(BaseRendererConfig):
261
261
  _internal_fields = frozenset({"image_cache_max"})
262
262
 
263
263
  @model_validator(mode="after")
264
- def _check_thinking_retention(self):
264
+ def _check_thinking_retention(self) -> Self:
265
265
  _reject_thinking_retention_conflict(
266
266
  self,
267
267
  "preserve_thinking",
@@ -307,7 +307,7 @@ class Qwen38RendererConfig(BaseRendererConfig):
307
307
  _internal_fields = frozenset({"image_cache_max"})
308
308
 
309
309
  @model_validator(mode="after")
310
- def _check_thinking_retention(self):
310
+ def _check_thinking_retention(self) -> Self:
311
311
  _reject_thinking_retention_conflict(
312
312
  self,
313
313
  "preserve_thinking",
@@ -350,7 +350,7 @@ class Gemma4RendererConfig(BaseRendererConfig):
350
350
  _internal_fields = frozenset({"image_cache_max"})
351
351
 
352
352
  @model_validator(mode="after")
353
- def _check_thinking_retention(self):
353
+ def _check_thinking_retention(self) -> Self:
354
354
  _reject_thinking_retention_conflict(
355
355
  self,
356
356
  "preserve_thinking",
@@ -377,7 +377,7 @@ class GLM5RendererConfig(BaseRendererConfig):
377
377
  to ``"all"``."""
378
378
 
379
379
  @model_validator(mode="after")
380
- def _check_thinking_retention(self):
380
+ def _check_thinking_retention(self) -> Self:
381
381
  _reject_thinking_retention_conflict(
382
382
  self,
383
383
  "clear_thinking",
@@ -401,7 +401,7 @@ class GLM51RendererConfig(BaseRendererConfig):
401
401
  """See :class:`GLM5RendererConfig.clear_thinking`."""
402
402
 
403
403
  @model_validator(mode="after")
404
- def _check_thinking_retention(self):
404
+ def _check_thinking_retention(self) -> Self:
405
405
  _reject_thinking_retention_conflict(
406
406
  self,
407
407
  "clear_thinking",
@@ -424,7 +424,7 @@ class GLM53RendererConfig(BaseRendererConfig):
424
424
  """Reasoning-effort system preamble emitted by the canonical template."""
425
425
 
426
426
  @model_validator(mode="after")
427
- def _check_thinking_retention(self):
427
+ def _check_thinking_retention(self) -> Self:
428
428
  _reject_thinking_retention_conflict(
429
429
  self,
430
430
  "clear_thinking",
@@ -506,7 +506,7 @@ class Hy3RendererConfig(BaseRendererConfig):
506
506
  and suppresses the generation prompt (``add_generation_prompt=False``)."""
507
507
 
508
508
  @model_validator(mode="after")
509
- def _check_thinking_retention(self):
509
+ def _check_thinking_retention(self) -> Self:
510
510
  if self.preserved_thinking is not None and self.thinking_retention is not None:
511
511
  implied = "all" if self.preserved_thinking else "tool_cycle"
512
512
  if self.thinking_retention != implied:
@@ -566,7 +566,7 @@ class InklingRendererConfig(BaseRendererConfig):
566
566
  _internal_fields = frozenset({"image_cache_max", "audio_cache_max"})
567
567
 
568
568
  @model_validator(mode="after")
569
- def _check_reasoning_effort(self):
569
+ def _check_reasoning_effort(self) -> Self:
570
570
  eff = self.reasoning_effort
571
571
  if isinstance(eff, str):
572
572
  if eff.strip() not in INKLING_EFFORT_MAP:
@@ -631,7 +631,7 @@ class GptOssRendererConfig(BaseRendererConfig):
631
631
  )
632
632
 
633
633
  @model_validator(mode="after")
634
- def _check_thinking_retention(self):
634
+ def _check_thinking_retention(self) -> Self:
635
635
  _reject_thinking_retention_conflict(
636
636
  self,
637
637
  "auto_drop_analysis",
@@ -836,7 +836,7 @@ class Nemotron3RendererConfig(BaseRendererConfig):
836
836
  policy to ``"all"``."""
837
837
 
838
838
  @model_validator(mode="after")
839
- def _check_thinking_retention(self):
839
+ def _check_thinking_retention(self) -> Self:
840
840
  _reject_thinking_retention_conflict(
841
841
  self,
842
842
  "truncate_history_thinking",
@@ -877,7 +877,7 @@ class Nemotron3UltraRendererConfig(BaseRendererConfig):
877
877
  """See :class:`Nemotron3RendererConfig.truncate_history_thinking`."""
878
878
 
879
879
  @model_validator(mode="after")
880
- def _check_thinking_retention(self):
880
+ def _check_thinking_retention(self) -> Self:
881
881
  _reject_thinking_retention_conflict(
882
882
  self,
883
883
  "truncate_history_thinking",
@@ -913,7 +913,7 @@ class Nemotron35RendererConfig(BaseRendererConfig):
913
913
  """See :class:`Nemotron3RendererConfig.truncate_history_thinking`."""
914
914
 
915
915
  @model_validator(mode="after")
916
- def _check_thinking_retention(self):
916
+ def _check_thinking_retention(self) -> Self:
917
917
  _reject_thinking_retention_conflict(
918
918
  self,
919
919
  "truncate_history_thinking",
@@ -984,7 +984,7 @@ class DeepSeekV4RendererConfig(BaseRendererConfig):
984
984
  """
985
985
 
986
986
  @model_validator(mode="after")
987
- def _check_thinking_retention(self):
987
+ def _check_thinking_retention(self) -> Self:
988
988
  _reject_thinking_retention_conflict(
989
989
  self,
990
990
  "drop_thinking",
@@ -1019,7 +1019,7 @@ class CustomRendererConfig(BaseRendererConfig):
1019
1019
  _internal_fields = frozenset({"import_path"})
1020
1020
 
1021
1021
  @model_validator(mode="after")
1022
- def _validate_renderer_fields(self):
1022
+ def _validate_renderer_fields(self) -> Self:
1023
1023
  # Fail at config load, not at the first render: the import path must
1024
1024
  # resolve and the renderer's config must accept the other fields.
1025
1025
  from renderers.custom import custom_renderer_config
@@ -192,20 +192,8 @@ class GLM45Renderer:
192
192
  role = msg["role"]
193
193
  content = self._visible_text(msg.get("content"))
194
194
 
195
- # When the previous message is an assistant, this message's
196
- # role-opening token (``<|user|>`` / ``<|observation|>``) is
197
- # the inference-time stop signal that closes the assistant's
198
- # turn (see ``get_stop_token_ids``). Mark it
199
- # ``is_sampled=True`` so the loss-mask pipeline trains the
200
- # model to emit it after ``</tool_call>`` (instead of
201
- # continuing with another ``<tool_call>`` block). The token
202
- # stays attributed to this message (msg_idx=i) and remains
203
- # ``is_content=False`` — it's a role-marker / scaffold, not
204
- # body bytes, so ``content_mask_for_roles({"tool"})`` and
205
- # ``content_token_spans_by_role()`` correctly exclude it
206
- # from "tool body" views. Byte stream is unchanged.
207
- # ``system`` only appears at the start of a GLM conversation,
208
- # so its opener is never the closer of an assistant turn.
195
+ # A user/tool role marker that closes generation belongs to
196
+ # the preceding assistant's sampled output, not the next body.
209
197
  closes_assistant_turn = i > 0 and messages[i - 1]["role"] == "assistant"
210
198
 
211
199
  if role == "system":
@@ -219,9 +207,9 @@ class GLM45Renderer:
219
207
  elif role == "user":
220
208
  emit_special(
221
209
  self._user,
222
- i,
210
+ i - 1 if closes_assistant_turn else i,
223
211
  is_sampled=closes_assistant_turn,
224
- is_content=False,
212
+ is_content=closes_assistant_turn,
225
213
  )
226
214
  # ``\n`` is scaffold; ``content`` is body; the optional
227
215
  # ``/nothink`` suffix is scaffold the renderer injects
@@ -508,15 +496,9 @@ class GLM45Renderer:
508
496
  # (think block + content + tool calls) is the model-sampled
509
497
  # portion.
510
498
  #
511
- # GLM-4.5 does NOT emit an explicit per-turn close token inside
512
- # the assistant message; the next message's role marker
513
- # (``<|user|>`` / ``<|observation|>`` / ``<|endoftext|>``) acts
514
- # as the stop signal at inference, and those tokens are
515
- # attributed to the *next* message (or are absent on the final
516
- # turn). So no sampled stop-signal token lives inside this
517
- # assistant span — content / think / tool_calls carry the
518
- # is_sampled=True signal.
519
- #
499
+ # The next user/tool role marker closes this assistant turn and
500
+ # is attributed here when that later message is rendered.
501
+
520
502
  # Invariant on assistant tokens: ``is_content == sampled_mask``.
521
503
  # Every scaffold token here gets ``is_sampled=False/is_content=False``;
522
504
  # every model-sampled emit gets both True.
@@ -598,23 +580,17 @@ class GLM45Renderer:
598
580
  emit_text,
599
581
  emit_text_segments,
600
582
  ) -> None:
601
- # Tool body bytes get ``is_content=True``; the wraps are
602
- # scaffold. The ``<|observation|>`` role tag is scaffold too
603
- # (``is_content=False`` so ``content_mask_for_roles({"tool"})``
604
- # excludes it). When the previous message is an assistant it
605
- # doubles as the inference stop signal for that assistant's
606
- # turn — mark it ``is_sampled=True`` so SFT trains the model to
607
- # emit it after ``</tool_call>``. The token stays attributed to
608
- # this tool message; byte stream is unchanged.
583
+ # Tool bodies keep their own attribution; a sampled observation
584
+ # marker belongs to the assistant that generated it.
609
585
  prev_role = messages[msg_idx - 1]["role"] if msg_idx > 0 else None
610
586
  closes_assistant_turn = prev_role == "assistant"
611
587
 
612
588
  if prev_role != "tool":
613
589
  emit_special(
614
590
  self._observation,
615
- msg_idx,
591
+ msg_idx - 1 if closes_assistant_turn else msg_idx,
616
592
  is_sampled=closes_assistant_turn,
617
- is_content=False,
593
+ is_content=closes_assistant_turn,
618
594
  )
619
595
 
620
596
  emit_text_segments(