renderers 0.1.11.dev2__tar.gz → 0.1.11.dev3__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 (86) hide show
  1. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/PKG-INFO +8 -6
  2. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/README.md +7 -5
  3. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/pyproject.toml +2 -2
  4. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/__init__.py +2 -0
  5. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/_version.py +2 -2
  6. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/base.py +141 -34
  7. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/deepseek_v3.py +5 -2
  8. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/gemma4.py +3 -2
  9. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/glm45.py +5 -2
  10. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/glm5.py +5 -2
  11. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/gpt_oss.py +5 -2
  12. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/hy3.py +43 -11
  13. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/inkling.py +3 -2
  14. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/kimi_k2.py +5 -2
  15. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/kimi_k25.py +4 -3
  16. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/laguna_xs2.py +53 -7
  17. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/llama_3.py +5 -2
  18. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/minimax_m2.py +20 -6
  19. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/nemotron3.py +5 -2
  20. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/prime_qwen3.py +14 -4
  21. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/qwen3.py +5 -2
  22. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/qwen35.py +4 -3
  23. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/qwen3_vl.py +5 -4
  24. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_load_tokenizer.py +16 -11
  25. renderers-0.1.11.dev3/tests/test_offsetless_tokenizers.py +223 -0
  26. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/.github/workflows/publish-dev.yml +0 -0
  27. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/.github/workflows/publish.yml +0 -0
  28. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/.github/workflows/style.yml +0 -0
  29. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/.github/workflows/test.yml +0 -0
  30. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/.gitignore +0 -0
  31. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/.pre-commit-config.yaml +0 -0
  32. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/LICENSE +0 -0
  33. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/docs/renderer-config.md +0 -0
  34. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/examples/README.md +0 -0
  35. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/examples/sglang/multiturn_generate_sglang.py +0 -0
  36. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/examples/sglang/online_multiturn_sglang.py +0 -0
  37. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/examples/tinker/multiturn_generate_tinker.py +0 -0
  38. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/examples/transformers/multiturn_generate_transformers.py +0 -0
  39. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/examples/vllm/multiturn_generate_vllm.py +0 -0
  40. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/client.py +0 -0
  41. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/configs.py +0 -0
  42. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/deepseek_r1.py +0 -0
  43. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/default.py +0 -0
  44. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/laguna_s21.py +0 -0
  45. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/parsers.py +0 -0
  46. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/parsing.py +0 -0
  47. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/qwen36.py +0 -0
  48. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/renderers/qwen38.py +0 -0
  49. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/conftest.py +0 -0
  50. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_bridge.py +0 -0
  51. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_build_helpers.py +0 -0
  52. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_client.py +0 -0
  53. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_deepseek_r1.py +0 -0
  54. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_disabled_thinking_stability.py +0 -0
  55. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_gemma4.py +0 -0
  56. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_glm_tool_name_validation.py +0 -0
  57. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_gpt_oss_harmony_parity.py +0 -0
  58. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_hy3.py +0 -0
  59. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_incremental.py +0 -0
  60. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_inkling.py +0 -0
  61. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_is_content.py +0 -0
  62. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_kimi_k25_tool_schema.py +0 -0
  63. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_laguna_m1.py +0 -0
  64. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_laguna_s21.py +0 -0
  65. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_laguna_xs21.py +0 -0
  66. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_llama_3.py +0 -0
  67. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_message_indices.py +0 -0
  68. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_message_tool_names.py +0 -0
  69. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_multimodal.py +0 -0
  70. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_nemotron3_parity.py +0 -0
  71. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_nemotron3_ultra.py +0 -0
  72. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_parse_response.py +0 -0
  73. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_parse_response_robustness.py +0 -0
  74. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_parsers.py +0 -0
  75. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_preserve_thinking.py +0 -0
  76. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_prime_qwen3_parity.py +0 -0
  77. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_qwen35_size_coverage.py +0 -0
  78. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_qwen38.py +0 -0
  79. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_render_ids.py +0 -0
  80. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_renderer_config.py +0 -0
  81. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_renderer_config_parity.py +0 -0
  82. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_roundtrip.py +0 -0
  83. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_sampled_mask.py +0 -0
  84. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_tokens_per_message.py +0 -0
  85. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/tests/test_tool_arg_type_preservation.py +0 -0
  86. {renderers-0.1.11.dev2 → renderers-0.1.11.dev3}/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.dev3
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
 
@@ -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
 
@@ -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,
@@ -181,6 +182,7 @@ __all__ = [
181
182
  "Nemotron3RendererConfig",
182
183
  "Nemotron3UltraRenderer",
183
184
  "Nemotron3UltraRendererConfig",
185
+ "OffsetTokenizer",
184
186
  "OverlongPromptError",
185
187
  "ParsedResponse",
186
188
  "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.dev3'
22
+ __version_tuple__ = version_tuple = (0, 1, 11, 'dev3')
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
 
@@ -1064,7 +1079,7 @@ _TRANSFORMERS_INSTALL_HINT = (
1064
1079
  "Install the optional dependency with "
1065
1080
  "`pip install 'renderers[transformers]'` (or "
1066
1081
  "`uv add 'renderers[transformers]'`). Text-only renderers work without "
1067
- "it when constructed with an offset-capable tokenizer object."
1082
+ "it when constructed with a compatible tokenizer object."
1068
1083
  )
1069
1084
 
1070
1085
 
@@ -1759,36 +1774,122 @@ def trim_to_turn_close(
1759
1774
  return previous_ids
1760
1775
 
1761
1776
 
1762
- def _get_offset_tokenizer(tokenizer):
1763
- """Assert ``tokenizer`` supports ``return_offsets_mapping=True``.
1777
+ class AttributedTextSegments(list[tuple[int, bool]]):
1778
+ """Token/content pairs with an explicit attribution-availability flag."""
1779
+
1780
+ def __init__(
1781
+ self,
1782
+ values=(),
1783
+ *,
1784
+ has_content_attribution: bool,
1785
+ ) -> None:
1786
+ super().__init__(values)
1787
+ self.has_content_attribution = has_content_attribution
1788
+
1789
+
1790
+ def _get_offset_tokenizer(tokenizer: Tokenizer) -> OffsetTokenizer | None:
1791
+ """Return ``tokenizer`` when it supports character offsets, else ``None``.
1764
1792
 
1765
1793
  Hand-coded renderers concatenate scaffold + body in one BPE pass to
1766
1794
  preserve cross-boundary merges, then attribute each resulting token
1767
1795
  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.
1796
+ ``offset_mapping`` (see :func:`attribute_text_segments`). Tokenizers
1797
+ loaded via :func:`load_tokenizer` are ``PreTrainedTokenizerFast``
1798
+ instances that satisfy this capability, but BYO tokenizers need not.
1772
1799
  """
1800
+ call = getattr(tokenizer, "__call__", None)
1801
+ if not callable(call):
1802
+ return None
1773
1803
  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
1804
+ encoding = call("a", add_special_tokens=False, return_offsets_mapping=True)
1805
+ encoding["input_ids"]
1806
+ encoding["offset_mapping"]
1807
+ except (KeyError, NotImplementedError, TypeError, ValueError):
1808
+ return None
1809
+ return cast(OffsetTokenizer, tokenizer)
1810
+
1811
+
1812
+ def _infer_offsets_from_decode(
1813
+ tokenizer: Tokenizer,
1814
+ token_ids: list[int],
1815
+ text: str,
1816
+ ) -> list[tuple[int, int]] | None:
1817
+ """Recover token character spans from an exact decoder round-trip.
1818
+
1819
+ This is a narrow fallback for metadata that does not require exposing
1820
+ content attribution. Some renderers join text from multiple messages in a
1821
+ single BPE pass, so they still need to associate the resulting tokens with
1822
+ the right message when a BYO tokenizer has no native offset mapping.
1823
+
1824
+ Decoding individual tokens is linear and exact for the common BPE/SentencePiece
1825
+ backends. Byte-fallback tokenizers can require multiple tokens before text
1826
+ becomes valid, so a validated cumulative-prefix pass handles that case.
1827
+ If either strategy cannot reconstruct ``text`` exactly, callers must use a
1828
+ conservative renderer-specific message-index fallback. This helper never
1829
+ upgrades the tokenizer's content-attribution capability: ``is_content``
1830
+ remains unavailable without native offsets.
1831
+ """
1832
+
1833
+ def decode(ids: list[int]) -> str | None:
1834
+ variants = (
1835
+ {"skip_special_tokens": False, "clean_up_tokenization_spaces": False},
1836
+ {"skip_special_tokens": False},
1837
+ {},
1838
+ )
1839
+ for kwargs in variants:
1840
+ try:
1841
+ decoded = tokenizer.decode(ids, **kwargs)
1842
+ except TypeError:
1843
+ continue
1844
+ except (KeyError, NotImplementedError, UnicodeError, ValueError):
1845
+ return None
1846
+ return decoded if isinstance(decoded, str) else None
1847
+ return None
1848
+
1849
+ pieces: list[str] = []
1850
+ for token_id in token_ids:
1851
+ piece = decode([token_id])
1852
+ if piece is None:
1853
+ break
1854
+ pieces.append(piece)
1855
+ if len(pieces) == len(token_ids) and "".join(pieces) == text:
1856
+ offsets: list[tuple[int, int]] = []
1857
+ position = 0
1858
+ for piece in pieces:
1859
+ end = position + len(piece)
1860
+ offsets.append((position, end))
1861
+ position = end
1862
+ return offsets
1863
+
1864
+ offsets = []
1865
+ previous_end = 0
1866
+ for end_index in range(1, len(token_ids) + 1):
1867
+ prefix = decode(token_ids[:end_index])
1868
+ if prefix is None or len(prefix) < previous_end or not text.startswith(prefix):
1869
+ return None
1870
+ current_end = len(prefix)
1871
+ offsets.append((previous_end, current_end))
1872
+ previous_end = current_end
1873
+ if previous_end != len(text):
1874
+ return None
1875
+ return offsets
1876
+
1877
+
1878
+ def _content_mask_or_empty(
1879
+ tokenizer: Tokenizer, content_mask: list[bool]
1880
+ ) -> list[bool]:
1881
+ """Return exact content attribution, or the empty-list unavailable sentinel."""
1882
+ if _get_offset_tokenizer(tokenizer) is None:
1883
+ return []
1884
+ return content_mask
1784
1885
 
1785
1886
 
1786
1887
  def attribute_text_segments(
1787
- tokenizer,
1888
+ tokenizer: Tokenizer,
1788
1889
  segments: "list[tuple[str, bool]]",
1789
1890
  *,
1790
1891
  overlap_is_content: bool = False,
1791
- ) -> "list[tuple[int, bool]]":
1892
+ ) -> AttributedTextSegments:
1792
1893
  """Tokenize concatenated segments as a single BPE pass and return
1793
1894
  ``(token_id, is_content)`` pairs.
1794
1895
 
@@ -1816,23 +1917,29 @@ def attribute_text_segments(
1816
1917
  every body byte inside the ``is_content=True`` run at the cost of a
1817
1918
  few adjacent wrap bytes.
1818
1919
 
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.
1920
+ When ``tokenizer`` implements :class:`OffsetTokenizer`, the result's
1921
+ ``has_content_attribution`` flag is true and each bool is exact. For a
1922
+ basic :class:`Tokenizer`, the joined text is still encoded in one pass so
1923
+ token IDs remain identical, but the bools are placeholders and
1924
+ ``has_content_attribution`` is false. Renderers propagate that state as an
1925
+ empty ``RenderedTokens.is_content`` list rather than exposing a partial or
1926
+ inaccurate mask.
1826
1927
 
1827
1928
  Empty input or empty joined text returns an empty list.
1828
1929
  """
1829
1930
  if not segments:
1830
- return []
1931
+ return AttributedTextSegments([], has_content_attribution=True)
1831
1932
  full_text = "".join(text for text, _ in segments)
1832
1933
  if not full_text:
1833
- return []
1934
+ return AttributedTextSegments([], has_content_attribution=True)
1834
1935
 
1835
1936
  offset_tokenizer = _get_offset_tokenizer(tokenizer)
1937
+ if offset_tokenizer is None:
1938
+ token_ids = tokenizer.encode(full_text, add_special_tokens=False)
1939
+ return AttributedTextSegments(
1940
+ ((token_id, False) for token_id in token_ids),
1941
+ has_content_attribution=False,
1942
+ )
1836
1943
  encoding = offset_tokenizer(
1837
1944
  full_text,
1838
1945
  add_special_tokens=False,
@@ -1887,7 +1994,7 @@ def attribute_text_segments(
1887
1994
  # the last non-empty segment's bit.
1888
1995
  pass
1889
1996
  out.append((tok_id, is_content))
1890
- return out
1997
+ return AttributedTextSegments(out, has_content_attribution=True)
1891
1998
 
1892
1999
 
1893
2000
  def reject_assistant_in_extension(new_messages: list[Message]) -> bool:
@@ -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
  )
@@ -34,6 +34,7 @@ from renderers.base import (
34
34
  ToolCallParseStatus,
35
35
  ToolSpec,
36
36
  Tokenizer,
37
+ _content_mask_or_empty,
37
38
  _require_transformers,
38
39
  attribute_text_segments,
39
40
  extract_message_tool_names,
@@ -1025,7 +1026,7 @@ class Gemma4Renderer:
1025
1026
  token_ids=em.token_ids,
1026
1027
  message_indices=em.message_indices,
1027
1028
  sampled_mask=em.sampled,
1028
- is_content=em.is_content,
1029
+ is_content=_content_mask_or_empty(self._tokenizer, em.is_content),
1029
1030
  message_roles=[m.get("role") or "" for m in messages],
1030
1031
  message_tool_names=extract_message_tool_names(messages),
1031
1032
  multi_modal_data=multi_modal_data,
@@ -1356,7 +1357,7 @@ class Gemma4Renderer:
1356
1357
  token_ids=em.token_ids,
1357
1358
  message_indices=em.message_indices,
1358
1359
  sampled_mask=em.sampled,
1359
- is_content=em.is_content,
1360
+ is_content=_content_mask_or_empty(self._tokenizer, em.is_content),
1360
1361
  message_roles=[m.get("role") or "" for m in new_messages],
1361
1362
  message_tool_names=extract_message_tool_names(new_messages),
1362
1363
  multi_modal_data=self._merge_multi_modal_data(
@@ -19,6 +19,7 @@ from renderers.base import (
19
19
  RenderedTokens,
20
20
  ToolSpec,
21
21
  Tokenizer,
22
+ _content_mask_or_empty,
22
23
  attribute_text_segments,
23
24
  extract_message_tool_names,
24
25
  reject_assistant_in_extension,
@@ -261,7 +262,7 @@ class GLM45Renderer:
261
262
  token_ids=tokens,
262
263
  message_indices=indices,
263
264
  sampled_mask=sampled,
264
- is_content=content_mask,
265
+ is_content=_content_mask_or_empty(self._tokenizer, content_mask),
265
266
  message_roles=[m.get("role") or "" for m in messages],
266
267
  message_tool_names=extract_message_tool_names(messages),
267
268
  )
@@ -448,7 +449,9 @@ class GLM45Renderer:
448
449
  token_ids=previous_ids + ext,
449
450
  message_indices=[-1] * len(previous_ids) + ext_indices,
450
451
  sampled_mask=[False] * total_len,
451
- is_content=[False] * len(previous_ids) + ext_content,
452
+ is_content=_content_mask_or_empty(
453
+ self._tokenizer, [False] * len(previous_ids) + ext_content
454
+ ),
452
455
  message_roles=[m.get("role") or "" for m in new_messages],
453
456
  message_tool_names=extract_message_tool_names(new_messages),
454
457
  )
@@ -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,
@@ -283,7 +284,7 @@ class GLM5Renderer:
283
284
  token_ids=tokens,
284
285
  message_indices=indices,
285
286
  sampled_mask=sampled,
286
- is_content=content_mask,
287
+ is_content=_content_mask_or_empty(self._tokenizer, content_mask),
287
288
  message_roles=[m.get("role") or "" for m in messages],
288
289
  message_tool_names=extract_message_tool_names(messages),
289
290
  )
@@ -465,7 +466,9 @@ class GLM5Renderer:
465
466
  token_ids=previous_ids + ext,
466
467
  message_indices=[-1] * len(previous_ids) + ext_indices,
467
468
  sampled_mask=[False] * total_len,
468
- is_content=[False] * len(previous_ids) + ext_content,
469
+ is_content=_content_mask_or_empty(
470
+ self._tokenizer, [False] * len(previous_ids) + ext_content
471
+ ),
469
472
  message_roles=[m.get("role") or "" for m in new_messages],
470
473
  message_tool_names=extract_message_tool_names(new_messages),
471
474
  )
@@ -55,6 +55,7 @@ from renderers.base import (
55
55
  RenderedTokens,
56
56
  ToolSpec,
57
57
  Tokenizer,
58
+ _content_mask_or_empty,
58
59
  extract_message_tool_names,
59
60
  reject_assistant_in_extension,
60
61
  resolve_thinking_retention,
@@ -461,7 +462,7 @@ class GptOssRenderer:
461
462
  token_ids=tokens,
462
463
  message_indices=indices,
463
464
  sampled_mask=sampled,
464
- is_content=content_mask,
465
+ is_content=_content_mask_or_empty(self._tokenizer, content_mask),
465
466
  message_roles=[m.get("role") or "" for m in messages],
466
467
  message_tool_names=extract_message_tool_names(messages),
467
468
  )
@@ -597,7 +598,9 @@ class GptOssRenderer:
597
598
  token_ids=previous_ids + ext,
598
599
  message_indices=[-1] * len(previous_ids) + ext_indices,
599
600
  sampled_mask=[False] * total_len,
600
- is_content=[False] * len(previous_ids) + ext_content,
601
+ is_content=_content_mask_or_empty(
602
+ self._tokenizer, [False] * len(previous_ids) + ext_content
603
+ ),
601
604
  message_roles=[m.get("role") or "" for m in new_messages],
602
605
  message_tool_names=extract_message_tool_names(new_messages),
603
606
  )
@@ -33,6 +33,9 @@ from renderers.base import (
33
33
  RenderedTokens,
34
34
  ToolSpec,
35
35
  Tokenizer,
36
+ _content_mask_or_empty,
37
+ _get_offset_tokenizer,
38
+ _infer_offsets_from_decode,
36
39
  attribute_text_segments,
37
40
  extract_message_tool_names,
38
41
  reject_assistant_in_extension,
@@ -238,13 +241,34 @@ class Hy3Renderer:
238
241
  if not segments:
239
242
  return []
240
243
  full_text = "".join(text for text, _, _ in segments)
241
- encoding = self._tokenizer(
242
- full_text,
243
- add_special_tokens=False,
244
- return_offsets_mapping=True,
245
- )
246
- token_ids = list(encoding["input_ids"])
247
- offsets = list(encoding["offset_mapping"])
244
+ offset_tokenizer = _get_offset_tokenizer(self._tokenizer)
245
+ if offset_tokenizer is None:
246
+ token_ids = self._encode(full_text)
247
+ offsets = _infer_offsets_from_decode(
248
+ self._tokenizer,
249
+ token_ids,
250
+ full_text,
251
+ )
252
+ if offsets is None:
253
+ # Token IDs remain exact even when a lossy decoder prevents
254
+ # reconstructing boundaries. Associate the opaque joined run
255
+ # with a contributing caller system message rather than
256
+ # silently classifying its body as global scaffold.
257
+ fallback_idx = next(
258
+ (msg_idx for text, _, msg_idx in segments if text and msg_idx >= 0),
259
+ -1,
260
+ )
261
+ return [(token_id, False, fallback_idx) for token_id in token_ids]
262
+ has_content_attribution = False
263
+ else:
264
+ encoding = offset_tokenizer(
265
+ full_text,
266
+ add_special_tokens=False,
267
+ return_offsets_mapping=True,
268
+ )
269
+ token_ids = list(encoding["input_ids"])
270
+ offsets = list(encoding["offset_mapping"])
271
+ has_content_attribution = True
248
272
 
249
273
  spans: list[tuple[int, int, bool, int]] = []
250
274
  pos = 0
@@ -254,13 +278,19 @@ class Hy3Renderer:
254
278
  total_len = pos
255
279
 
256
280
  out: list[tuple[int, bool, int]] = []
257
- last = (spans[-1][2], spans[-1][3])
281
+ last = (
282
+ spans[-1][2] if has_content_attribution else False,
283
+ spans[-1][3],
284
+ )
258
285
  for tok_id, (start, _end) in zip(token_ids, offsets):
259
286
  attr = last
260
287
  if start < total_len:
261
288
  for seg_start, seg_end, seg_is_content, seg_idx in spans:
262
289
  if seg_start <= start < seg_end:
263
- attr = (seg_is_content, seg_idx)
290
+ attr = (
291
+ seg_is_content if has_content_attribution else False,
292
+ seg_idx,
293
+ )
264
294
  break
265
295
  out.append((tok_id, attr[0], attr[1]))
266
296
  return out
@@ -431,7 +461,7 @@ class Hy3Renderer:
431
461
  token_ids=tokens,
432
462
  message_indices=indices,
433
463
  sampled_mask=sampled,
434
- is_content=content_mask,
464
+ is_content=_content_mask_or_empty(self._tokenizer, content_mask),
435
465
  message_roles=[m.get("role") or "" for m in messages],
436
466
  message_tool_names=extract_message_tool_names(messages),
437
467
  )
@@ -699,7 +729,9 @@ class Hy3Renderer:
699
729
  token_ids=previous_ids + ext,
700
730
  message_indices=[-1] * len(previous_ids) + ext_indices,
701
731
  sampled_mask=[False] * total_len,
702
- is_content=[False] * len(previous_ids) + ext_content,
732
+ is_content=_content_mask_or_empty(
733
+ self._tokenizer, [False] * len(previous_ids) + ext_content
734
+ ),
703
735
  message_roles=[m.get("role") or "" for m in new_messages],
704
736
  message_tool_names=extract_message_tool_names(new_messages),
705
737
  )
@@ -48,6 +48,7 @@ from renderers.base import (
48
48
  RenderedTokens,
49
49
  ToolSpec,
50
50
  Tokenizer,
51
+ _content_mask_or_empty,
51
52
  _require_transformers,
52
53
  extract_message_tool_names,
53
54
  reject_assistant_in_extension,
@@ -492,7 +493,7 @@ class InklingRenderer:
492
493
  token_ids=tokens,
493
494
  message_indices=indices,
494
495
  sampled_mask=sampled,
495
- is_content=content_mask,
496
+ is_content=_content_mask_or_empty(self._tokenizer, content_mask),
496
497
  message_roles=[m.get("role") or "" for m in messages],
497
498
  message_tool_names=tool_names,
498
499
  multi_modal_data=mm_data,
@@ -989,7 +990,7 @@ class InklingRenderer:
989
990
  token_ids=tokens,
990
991
  message_indices=indices,
991
992
  sampled_mask=sampled,
992
- is_content=content_mask,
993
+ is_content=_content_mask_or_empty(self._tokenizer, content_mask),
993
994
  message_roles=[m.get("role") or "" for m in new_messages],
994
995
  message_tool_names=tool_names,
995
996
  multi_modal_data=mm_data,
@@ -22,6 +22,7 @@ from renderers.base import (
22
22
  RenderedTokens,
23
23
  ToolSpec,
24
24
  Tokenizer,
25
+ _content_mask_or_empty,
25
26
  extract_message_tool_names,
26
27
  reject_assistant_in_extension,
27
28
  resolve_thinking_retention,
@@ -309,7 +310,7 @@ class KimiK2Renderer:
309
310
  token_ids=token_ids,
310
311
  message_indices=indices,
311
312
  sampled_mask=sampled,
312
- is_content=content_mask,
313
+ is_content=_content_mask_or_empty(self._tokenizer, content_mask),
313
314
  message_roles=[m.get("role") or "" for m in caller_messages],
314
315
  message_tool_names=extract_message_tool_names(caller_messages),
315
316
  )
@@ -464,7 +465,9 @@ class KimiK2Renderer:
464
465
  token_ids=previous_ids + ext,
465
466
  message_indices=[-1] * len(previous_ids) + ext_indices,
466
467
  sampled_mask=[False] * total_len,
467
- is_content=[False] * len(previous_ids) + ext_content,
468
+ is_content=_content_mask_or_empty(
469
+ self._tokenizer, [False] * len(previous_ids) + ext_content
470
+ ),
468
471
  message_roles=[m.get("role") or "" for m in new_messages],
469
472
  message_tool_names=extract_message_tool_names(new_messages),
470
473
  )