renderers 0.1.8.dev40__tar.gz → 0.1.8.dev41__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 (67) hide show
  1. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/PKG-INFO +1 -1
  2. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/__init__.py +4 -0
  3. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/_version.py +2 -2
  4. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/base.py +10 -0
  5. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/configs.py +28 -0
  6. renderers-0.1.8.dev41/renderers/llama_3.py +516 -0
  7. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/parsing.py +67 -0
  8. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/conftest.py +32 -0
  9. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_bridge.py +1 -0
  10. renderers-0.1.8.dev41/tests/test_llama_3.py +407 -0
  11. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_preserve_thinking.py +6 -0
  12. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_roundtrip.py +7 -0
  13. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/.github/workflows/publish-dev.yml +0 -0
  14. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/.github/workflows/publish.yml +0 -0
  15. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/.github/workflows/style.yml +0 -0
  16. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/.github/workflows/test.yml +0 -0
  17. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/.gitignore +0 -0
  18. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/.pre-commit-config.yaml +0 -0
  19. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/LICENSE +0 -0
  20. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/README.md +0 -0
  21. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/docs/renderer-config.md +0 -0
  22. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/examples/README.md +0 -0
  23. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/examples/sglang/multiturn_generate_sglang.py +0 -0
  24. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/examples/sglang/online_multiturn_sglang.py +0 -0
  25. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/examples/tinker/multiturn_generate_tinker.py +0 -0
  26. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/examples/transformers/multiturn_generate_transformers.py +0 -0
  27. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/examples/vllm/multiturn_generate_vllm.py +0 -0
  28. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/pyproject.toml +0 -0
  29. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/client.py +0 -0
  30. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/deepseek_v3.py +0 -0
  31. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/default.py +0 -0
  32. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/glm45.py +0 -0
  33. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/glm5.py +0 -0
  34. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/gpt_oss.py +0 -0
  35. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/kimi_k2.py +0 -0
  36. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/kimi_k25.py +0 -0
  37. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/laguna_xs2.py +0 -0
  38. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/minimax_m2.py +0 -0
  39. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/nemotron3.py +0 -0
  40. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/parsers.py +0 -0
  41. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/qwen3.py +0 -0
  42. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/qwen35.py +0 -0
  43. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/qwen36.py +0 -0
  44. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/qwen3_vl.py +0 -0
  45. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_build_helpers.py +0 -0
  46. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_client.py +0 -0
  47. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_gpt_oss_harmony_parity.py +0 -0
  48. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_incremental.py +0 -0
  49. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_is_content.py +0 -0
  50. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_kimi_k25_tool_schema.py +0 -0
  51. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_load_tokenizer.py +0 -0
  52. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_load_tokenizer_fastokens.py +0 -0
  53. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_message_indices.py +0 -0
  54. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_message_tool_names.py +0 -0
  55. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_multimodal.py +0 -0
  56. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_nemotron3_ultra.py +0 -0
  57. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_parse_response.py +0 -0
  58. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_parse_response_robustness.py +0 -0
  59. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_parsers.py +0 -0
  60. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_qwen35_size_coverage.py +0 -0
  61. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_render_ids.py +0 -0
  62. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_renderer_config.py +0 -0
  63. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_renderer_config_parity.py +0 -0
  64. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_sampled_mask.py +0 -0
  65. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_tokens_per_message.py +0 -0
  66. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_tool_arg_type_preservation.py +0 -0
  67. {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: renderers
3
- Version: 0.1.8.dev40
3
+ Version: 0.1.8.dev41
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
@@ -52,6 +52,7 @@ from renderers.configs import (
52
52
  KimiK25RendererConfig,
53
53
  KimiK2RendererConfig,
54
54
  LagunaXS2RendererConfig,
55
+ Llama3RendererConfig,
55
56
  MiniMaxM2RendererConfig,
56
57
  Nemotron3RendererConfig,
57
58
  Qwen35RendererConfig,
@@ -82,6 +83,7 @@ _LAZY_RENDERERS: dict[str, str] = {
82
83
  "KimiK25Renderer": "renderers.kimi_k25",
83
84
  "KimiK2Renderer": "renderers.kimi_k2",
84
85
  "LagunaXS2Renderer": "renderers.laguna_xs2",
86
+ "Llama3Renderer": "renderers.llama_3",
85
87
  "MiniMaxM2Renderer": "renderers.minimax_m2",
86
88
  "Nemotron3Renderer": "renderers.nemotron3",
87
89
  "Qwen35Renderer": "renderers.qwen35",
@@ -130,6 +132,8 @@ __all__ = [
130
132
  "KimiK2RendererConfig",
131
133
  "LagunaXS2Renderer",
132
134
  "LagunaXS2RendererConfig",
135
+ "Llama3Renderer",
136
+ "Llama3RendererConfig",
133
137
  "MULTIMODAL_MODELS",
134
138
  "Message",
135
139
  "MiniMaxM2Renderer",
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.8.dev40'
22
- __version_tuple__ = version_tuple = (0, 1, 8, 'dev40')
21
+ __version__ = version = '0.1.8.dev41'
22
+ __version_tuple__ = version_tuple = (0, 1, 8, 'dev41')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -1045,6 +1045,14 @@ MODEL_RENDERER_MAP: dict[str, str] = {
1045
1045
  "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-BF16": "nemotron-3",
1046
1046
  "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16": "nemotron-3",
1047
1047
  "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-FP8": "nemotron-3",
1048
+ # Llama 3.2 (Instruct). Tested against the gated meta-llama repos and
1049
+ # the unrestricted unsloth/... mirror, which ships a byte-identical
1050
+ # chat template. ``Llama3Renderer`` defaults ``date_string`` to
1051
+ # "26 Jul 2024" — matching the chat template's strftime fallback —
1052
+ # so the renderer is reproducible. Pass ``date_string=...`` at
1053
+ # construction to pin a different date.
1054
+ "meta-llama/Llama-3.2-1B-Instruct": "llama-3",
1055
+ "meta-llama/Llama-3.2-3B-Instruct": "llama-3",
1048
1056
  # Poolside Laguna.
1049
1057
  "poolside/Laguna-XS.2": "laguna-xs.2",
1050
1058
  # GPT-OSS.
@@ -1334,6 +1342,7 @@ def _populate_registry():
1334
1342
  from renderers.kimi_k2 import KimiK2Renderer
1335
1343
  from renderers.kimi_k25 import KimiK25Renderer
1336
1344
  from renderers.laguna_xs2 import LagunaXS2Renderer
1345
+ from renderers.llama_3 import Llama3Renderer
1337
1346
  from renderers.minimax_m2 import MiniMaxM2Renderer
1338
1347
  from renderers.nemotron3 import Nemotron3Renderer
1339
1348
  from renderers.qwen3 import Qwen3Renderer
@@ -1356,6 +1365,7 @@ def _populate_registry():
1356
1365
  "kimi-k2": KimiK2Renderer,
1357
1366
  "kimi-k2.5": KimiK25Renderer,
1358
1367
  "laguna-xs.2": LagunaXS2Renderer,
1368
+ "llama-3": Llama3Renderer,
1359
1369
  "nemotron-3": Nemotron3Renderer,
1360
1370
  "gpt-oss": GptOssRenderer,
1361
1371
  }
@@ -318,6 +318,31 @@ class LagunaXS2RendererConfig(BaseRendererConfig):
318
318
  chat template's ``render_assistant_messages_raw`` gate."""
319
319
 
320
320
 
321
+ class Llama3RendererConfig(BaseRendererConfig):
322
+ """Llama-3.x Instruct renderer config.
323
+
324
+ Llama-3 ships no reasoning channel, so the base ``preserve_*_thinking``
325
+ flags don't apply: ``Llama3Renderer`` raises ``NotImplementedError``
326
+ if either is set (matching ``DefaultRenderer``'s contract for the
327
+ same case). Both fields below mirror real ``apply_chat_template``
328
+ kwargs.
329
+ """
330
+
331
+ name: Literal["llama-3"] = "llama-3"
332
+
333
+ date_string: str = "26 Jul 2024"
334
+ """``Today Date`` value injected into the system preamble. Pinned to
335
+ the chat template's ``strftime`` fallback by default so output stays
336
+ deterministic; override per instance for production runs that want
337
+ today's date. Mirrors the chat template's ``date_string`` kwarg."""
338
+
339
+ tools_in_user_message: bool = True
340
+ """When ``True`` (default), tool descriptions + JSON signatures inject
341
+ into the first user message; ``False`` routes them into the system
342
+ block instead. Mirrors the chat template's ``tools_in_user_message``
343
+ kwarg."""
344
+
345
+
321
346
  class MiniMaxM2RendererConfig(BaseRendererConfig):
322
347
  """MiniMax M2 / M2.5 renderer config."""
323
348
 
@@ -410,6 +435,7 @@ RendererConfig = Annotated[
410
435
  KimiK2RendererConfig,
411
436
  KimiK25RendererConfig,
412
437
  LagunaXS2RendererConfig,
438
+ Llama3RendererConfig,
413
439
  MiniMaxM2RendererConfig,
414
440
  Nemotron3RendererConfig,
415
441
  DeepSeekV3RendererConfig,
@@ -444,6 +470,7 @@ _CONFIG_BY_NAME: dict[str, type[BaseRendererConfig]] = {
444
470
  "kimi-k2": KimiK2RendererConfig,
445
471
  "kimi-k2.5": KimiK25RendererConfig,
446
472
  "laguna-xs.2": LagunaXS2RendererConfig,
473
+ "llama-3": Llama3RendererConfig,
447
474
  "minimax-m2": MiniMaxM2RendererConfig,
448
475
  "nemotron-3": Nemotron3RendererConfig,
449
476
  "deepseek-v3": DeepSeekV3RendererConfig,
@@ -486,6 +513,7 @@ __all__ = [
486
513
  "KimiK25RendererConfig",
487
514
  "KimiK2RendererConfig",
488
515
  "LagunaXS2RendererConfig",
516
+ "Llama3RendererConfig",
489
517
  "MiniMaxM2RendererConfig",
490
518
  "Nemotron3RendererConfig",
491
519
  "Qwen35RendererConfig",
@@ -0,0 +1,516 @@
1
+ """Llama-3 Renderer — hard-coded Python mirroring Meta's Llama-3 chat template.
2
+
3
+ Initial scope: Llama-3.2-1B-Instruct and Llama-3.2-3B-Instruct (and the
4
+ unrestricted ``unsloth/Llama-3.2-{1B,3B}-Instruct`` mirror, which ships a
5
+ byte-identical chat template). Other Llama-3.x sizes ship slightly
6
+ different templates and are NOT covered by this renderer until parity is
7
+ verified.
8
+
9
+ Notable differences from the Qwen / GLM family renderers:
10
+
11
+ * No ``<think>`` / reasoning channel — Llama-3 doesn't ship a
12
+ reasoning-content concept, so ``preserve_*_thinking`` flags don't
13
+ apply.
14
+ * ``<|begin_of_text|>`` (BOS) is emitted at the very start of every
15
+ render. The chat template never omits it.
16
+ * The system block is emitted **unconditionally** with a fixed
17
+ ``Cutting Knowledge Date: December 2023\\nToday Date: <date>\\n\\n``
18
+ preamble — even when no system message is supplied. Empty system
19
+ message → block ends with ``\\n\\n<|eot_id|>``.
20
+ * Tools default to "first-user-message" mode (matching the chat
21
+ template's default ``tools_in_user_message=True``): tool descriptions
22
+ + JSON signatures are injected into the first user message rather
23
+ than the system block. Set ``Llama3RendererConfig.tools_in_user_message
24
+ = False`` to flip to system-block mode.
25
+ * ``Llama3RendererConfig.date_string`` is pinned at ``"26 Jul 2024"`` by
26
+ default to match the chat template's ``strftime`` fallback (and keep
27
+ output deterministic). Override per instance for production runs that
28
+ want today's date.
29
+ * Tool calls: a single ``{"name": "...", "parameters": ...}`` JSON blob
30
+ inside the assistant body. The chat template explicitly raises if
31
+ ``message.tool_calls | length != 1``; this renderer matches that.
32
+ * Tool responses: rendered with role ``ipython`` regardless of whether
33
+ the source message used ``role: "tool"`` or ``role: "ipython"``. The
34
+ chat template runs ``content | tojson`` on any mapping/iterable
35
+ content — and Jinja considers strings iterable, so plain string
36
+ contents get JSON-quoted. We mirror that exactly.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ from typing import Any
43
+
44
+ from transformers.tokenization_utils import PreTrainedTokenizer
45
+
46
+ from renderers.base import (
47
+ Message,
48
+ ParsedResponse,
49
+ RenderedTokens,
50
+ ToolSpec,
51
+ attribute_text_segments,
52
+ extract_message_tool_names,
53
+ reject_assistant_in_extension,
54
+ trim_to_turn_close,
55
+ )
56
+ from renderers.configs import Llama3RendererConfig
57
+ from renderers.parsing import parse_llama_3
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Constants — must match the Jinja chat template's literal strings exactly.
61
+ # ---------------------------------------------------------------------------
62
+
63
+ _CUTTING_KNOWLEDGE_DATE = "December 2023"
64
+
65
+ # Tools-in-system intro: emitted into the system block when tools is set
66
+ # AND tools_in_user_message=False. Note: the chat template puts these
67
+ # three string literals back-to-back with NO newline between the second
68
+ # and third, so there's no space before "Do not use variables.".
69
+ _TOOLS_IN_SYSTEM_INTRO = (
70
+ "You have access to the following functions. To call a function, "
71
+ "please respond with JSON for a function call."
72
+ 'Respond in the format {"name": function name, "parameters": '
73
+ "dictionary of argument name and its value}."
74
+ "Do not use variables.\n\n"
75
+ )
76
+
77
+ # Tools-in-user intro: emitted into the first user message when tools is
78
+ # set AND tools_in_user_message=True (the default).
79
+ _TOOLS_IN_USER_INTRO = (
80
+ "Given the following functions, please respond with a JSON for a "
81
+ "function call with its proper arguments that best answers the given "
82
+ "prompt.\n\n"
83
+ 'Respond in the format {"name": function name, "parameters": '
84
+ "dictionary of argument name and its value}."
85
+ "Do not use variables.\n\n"
86
+ )
87
+
88
+
89
+ class Llama3Renderer:
90
+ """Deterministic message → token renderer for Llama-3.x Instruct models."""
91
+
92
+ def __init__(
93
+ self,
94
+ tokenizer: PreTrainedTokenizer,
95
+ config: Llama3RendererConfig | None = None,
96
+ ):
97
+ # ``preserve_*_thinking`` are accepted but no-ops: Llama-3 ships no
98
+ # reasoning_content channel, so there's never any past-assistant
99
+ # thinking to retain or drop. The flags are stored on ``self.config``
100
+ # for cross-renderer uniformity but never change the token stream —
101
+ # the same contract as Kimi-K2 / Qwen3-VL (see the never-preserves
102
+ # renderers in tests/test_preserve_thinking.py).
103
+ self._tokenizer = tokenizer
104
+ self.config = config or Llama3RendererConfig()
105
+
106
+ self._bos = self._token_id("<|begin_of_text|>")
107
+ self._start_header = self._token_id("<|start_header_id|>")
108
+ self._end_header = self._token_id("<|end_header_id|>")
109
+ self._eot = self._token_id("<|eot_id|>")
110
+ self._end_of_text = self._token_id("<|end_of_text|>")
111
+ # ``<|eom_id|>`` shows up in some Llama-3 tool-calling traces (the
112
+ # "ipython" / python-tag flow) but the standard 3.2 chat template
113
+ # closes turns with ``<|eot_id|>``. We still treat eom as a stop
114
+ # token so models that emit it terminate cleanly.
115
+ self._eom = self._token_id("<|eom_id|>")
116
+
117
+ def _token_id(self, token: str) -> int:
118
+ tid = self._tokenizer.convert_tokens_to_ids(token)
119
+ assert isinstance(tid, int) and tid != self._tokenizer.unk_token_id, (
120
+ f"Special token {token!r} not found in tokenizer vocabulary"
121
+ )
122
+ return tid
123
+
124
+ def _encode(self, text: str) -> list[int]:
125
+ if not text:
126
+ return []
127
+ return self._tokenizer.encode(text, add_special_tokens=False)
128
+
129
+ @staticmethod
130
+ def _content_str(content: Any) -> str:
131
+ """Render content to a plain string. Handles ``str``, list-of-text-parts,
132
+ and ``None``. Matches the chat template's ``message.content | trim``
133
+ callers, which expect a string in."""
134
+ if content is None:
135
+ return ""
136
+ if isinstance(content, str):
137
+ return content
138
+ if isinstance(content, list):
139
+ parts: list[str] = []
140
+ for item in content:
141
+ if isinstance(item, str):
142
+ parts.append(item)
143
+ elif isinstance(item, dict) and "text" in item:
144
+ parts.append(item["text"])
145
+ else:
146
+ raise ValueError(f"Unexpected content item: {item}")
147
+ return "".join(parts)
148
+ raise TypeError(f"Unexpected content type: {type(content)}")
149
+
150
+ @staticmethod
151
+ def _tool_response_str(content: Any) -> str:
152
+ """Mirror the chat template's tool-response branch:
153
+ ``{% if message.content is mapping or message.content is iterable %}
154
+ {{ message.content | tojson }} {% else %} {{ message.content }}``.
155
+
156
+ In Jinja, **strings are iterable** — so plain-string tool contents
157
+ also go through ``tojson`` (i.e. ``json.dumps``), wrapping them in
158
+ quotes and escaping. Non-iterable scalars (numbers, bools, None)
159
+ fall through to literal stringification.
160
+ """
161
+ if content is None:
162
+ return ""
163
+ if isinstance(content, (dict, list, str)):
164
+ return json.dumps(content, ensure_ascii=False)
165
+ return str(content)
166
+
167
+ # ------------------------------------------------------------------
168
+ # render
169
+ # ------------------------------------------------------------------
170
+
171
+ def render(
172
+ self,
173
+ messages: list[Message],
174
+ *,
175
+ tools: list[ToolSpec] | None = None,
176
+ add_generation_prompt: bool = False,
177
+ ) -> RenderedTokens:
178
+ if not messages:
179
+ raise ValueError("No messages provided.")
180
+
181
+ tokens: list[int] = []
182
+ indices: list[int] = []
183
+ sampled: list[bool] = []
184
+ content_mask: list[bool] = []
185
+
186
+ def emit_special(
187
+ token_id: int, msg_idx: int, *, is_sampled: bool, is_content: bool
188
+ ) -> None:
189
+ tokens.append(token_id)
190
+ indices.append(msg_idx)
191
+ sampled.append(is_sampled)
192
+ content_mask.append(is_content)
193
+
194
+ def emit_text(
195
+ text: str, msg_idx: int, *, is_sampled: bool, is_content: bool
196
+ ) -> None:
197
+ ids = self._encode(text)
198
+ tokens.extend(ids)
199
+ indices.extend([msg_idx] * len(ids))
200
+ sampled.extend([is_sampled] * len(ids))
201
+ content_mask.extend([is_content] * len(ids))
202
+
203
+ def emit_text_segments(
204
+ segments: list[tuple[str, bool]], msg_idx: int, *, is_sampled: bool
205
+ ) -> None:
206
+ """Tokenize concatenated wrap + body as one BPE pass; per-token
207
+ ``is_content`` follows each token's source segment. Lets the
208
+ scaffold/body split stay attributed without splitting the
209
+ encode call (which could shift BPE merges at the boundary)."""
210
+ for tok_id, is_content in attribute_text_segments(
211
+ self._tokenizer, segments
212
+ ):
213
+ tokens.append(tok_id)
214
+ indices.append(msg_idx)
215
+ sampled.append(is_sampled)
216
+ content_mask.append(is_content)
217
+
218
+ # ── 0. BOS ──────────────────────────────────────────────────
219
+ emit_special(self._bos, -1, is_sampled=False, is_content=False)
220
+
221
+ # ── 1. System block (always emitted) ────────────────────────
222
+ first_is_system = messages[0].get("role") == "system"
223
+ sys_idx = 0 if first_is_system else -1
224
+ sys_text = (
225
+ self._content_str(messages[0].get("content")).strip()
226
+ if first_is_system
227
+ else ""
228
+ )
229
+
230
+ emit_special(self._start_header, sys_idx, is_sampled=False, is_content=False)
231
+ emit_text("system", sys_idx, is_sampled=False, is_content=False)
232
+ emit_special(self._end_header, sys_idx, is_sampled=False, is_content=False)
233
+ # The Cutting Knowledge / Today Date preamble (and any tools-in-system
234
+ # block) is template scaffold; only the caller's system content is
235
+ # body. Route both through one BPE pass so the wrap/body boundary
236
+ # can't shift merges.
237
+ preamble = "\n\n"
238
+ if tools is not None:
239
+ preamble += "Environment: ipython\n"
240
+ preamble += f"Cutting Knowledge Date: {_CUTTING_KNOWLEDGE_DATE}\n"
241
+ preamble += f"Today Date: {self.config.date_string}\n\n"
242
+ if tools is not None and not self.config.tools_in_user_message:
243
+ preamble += _TOOLS_IN_SYSTEM_INTRO
244
+ for t in tools:
245
+ preamble += json.dumps(t, indent=4, ensure_ascii=False) + "\n\n"
246
+ sys_segments: list[tuple[str, bool]] = [(preamble, False)]
247
+ if sys_text:
248
+ sys_segments.append((sys_text, True))
249
+ emit_text_segments(sys_segments, sys_idx, is_sampled=False)
250
+ emit_special(self._eot, sys_idx, is_sampled=False, is_content=False)
251
+
252
+ # ── 2. Body messages ────────────────────────────────────────
253
+ body_messages = messages[1:] if first_is_system else messages
254
+ offset = 1 if first_is_system else 0
255
+
256
+ i = 0
257
+ # 2a. tools_in_user_message mode pulls the first user message
258
+ # into a special block with the tools description prepended.
259
+ if tools is not None and self.config.tools_in_user_message:
260
+ if i >= len(body_messages):
261
+ raise ValueError(
262
+ "Cannot place tools in the first user message — no user "
263
+ "message was provided."
264
+ )
265
+ first_user = body_messages[i]
266
+ if first_user.get("role") != "user":
267
+ raise ValueError(
268
+ "tools_in_user_message=True requires the first non-system "
269
+ f"message to be 'user'; got {first_user.get('role')!r}."
270
+ )
271
+ user_idx = i + offset
272
+ emit_special(
273
+ self._start_header, user_idx, is_sampled=False, is_content=False
274
+ )
275
+ emit_text("user", user_idx, is_sampled=False, is_content=False)
276
+ emit_special(self._end_header, user_idx, is_sampled=False, is_content=False)
277
+ user_preamble = "\n\n" + _TOOLS_IN_USER_INTRO
278
+ for t in tools:
279
+ user_preamble += json.dumps(t, indent=4, ensure_ascii=False) + "\n\n"
280
+ user_content = self._content_str(first_user.get("content")).strip()
281
+ user_segments: list[tuple[str, bool]] = [(user_preamble, False)]
282
+ if user_content:
283
+ user_segments.append((user_content, True))
284
+ emit_text_segments(user_segments, user_idx, is_sampled=False)
285
+ emit_special(self._eot, user_idx, is_sampled=False, is_content=False)
286
+ i += 1
287
+
288
+ # 2b. Remaining messages — plain user/assistant/tool/assistant-with-tool-calls.
289
+ for j in range(i, len(body_messages)):
290
+ msg = body_messages[j]
291
+ msg_idx = j + offset
292
+ role = msg.get("role")
293
+ tool_calls = msg.get("tool_calls")
294
+
295
+ if role in ("tool", "ipython"):
296
+ # Tool responses are conversation history the model never
297
+ # samples; the response body is caller content, the wrap is
298
+ # scaffold.
299
+ emit_special(
300
+ self._start_header, msg_idx, is_sampled=False, is_content=False
301
+ )
302
+ emit_text("ipython", msg_idx, is_sampled=False, is_content=False)
303
+ emit_special(
304
+ self._end_header, msg_idx, is_sampled=False, is_content=False
305
+ )
306
+ tool_body = self._tool_response_str(msg.get("content"))
307
+ tool_segments: list[tuple[str, bool]] = [("\n\n", False)]
308
+ if tool_body:
309
+ tool_segments.append((tool_body, True))
310
+ emit_text_segments(tool_segments, msg_idx, is_sampled=False)
311
+ emit_special(self._eot, msg_idx, is_sampled=False, is_content=False)
312
+ elif tool_calls:
313
+ if len(tool_calls) != 1:
314
+ raise ValueError(
315
+ "Llama-3 chat template only supports a single tool call "
316
+ "per assistant message."
317
+ )
318
+ tc = tool_calls[0]
319
+ func = tc.get("function") or tc
320
+ name = func.get("name", "")
321
+ arguments = func.get("arguments", {})
322
+ if isinstance(arguments, str):
323
+ args_str = arguments
324
+ else:
325
+ args_str = json.dumps(arguments, ensure_ascii=False)
326
+ emit_special(
327
+ self._start_header, msg_idx, is_sampled=False, is_content=False
328
+ )
329
+ emit_text("assistant", msg_idx, is_sampled=False, is_content=False)
330
+ emit_special(
331
+ self._end_header, msg_idx, is_sampled=False, is_content=False
332
+ )
333
+ # The ``\n\n`` after the header is gen-prompt scaffold the
334
+ # model never samples; the JSON tool-call body and the
335
+ # closing ``<|eot_id|>`` are the model's sampled emission.
336
+ emit_text("\n\n", msg_idx, is_sampled=False, is_content=False)
337
+ emit_text(
338
+ '{"name": "' + name + '", "parameters": ' + args_str + "}",
339
+ msg_idx,
340
+ is_sampled=True,
341
+ is_content=True,
342
+ )
343
+ emit_special(self._eot, msg_idx, is_sampled=True, is_content=True)
344
+ elif role == "assistant":
345
+ content = self._content_str(msg.get("content")).strip()
346
+ emit_special(
347
+ self._start_header, msg_idx, is_sampled=False, is_content=False
348
+ )
349
+ emit_text("assistant", msg_idx, is_sampled=False, is_content=False)
350
+ emit_special(
351
+ self._end_header, msg_idx, is_sampled=False, is_content=False
352
+ )
353
+ # ``\n\n`` separator is scaffold (it's the generation prompt);
354
+ # the body and the closing ``<|eot_id|>`` are model-sampled.
355
+ emit_text("\n\n", msg_idx, is_sampled=False, is_content=False)
356
+ if content:
357
+ emit_text(content, msg_idx, is_sampled=True, is_content=True)
358
+ emit_special(self._eot, msg_idx, is_sampled=True, is_content=True)
359
+ else:
360
+ # user / non-leading system: caller content, never sampled.
361
+ content = self._content_str(msg.get("content")).strip()
362
+ emit_special(
363
+ self._start_header, msg_idx, is_sampled=False, is_content=False
364
+ )
365
+ emit_text(role or "", msg_idx, is_sampled=False, is_content=False)
366
+ emit_special(
367
+ self._end_header, msg_idx, is_sampled=False, is_content=False
368
+ )
369
+ segments: list[tuple[str, bool]] = [("\n\n", False)]
370
+ if content:
371
+ segments.append((content, True))
372
+ emit_text_segments(segments, msg_idx, is_sampled=False)
373
+ emit_special(self._eot, msg_idx, is_sampled=False, is_content=False)
374
+
375
+ # ── 3. Generation prompt ────────────────────────────────────
376
+ if add_generation_prompt:
377
+ emit_special(self._start_header, -1, is_sampled=False, is_content=False)
378
+ emit_text("assistant", -1, is_sampled=False, is_content=False)
379
+ emit_special(self._end_header, -1, is_sampled=False, is_content=False)
380
+ emit_text("\n\n", -1, is_sampled=False, is_content=False)
381
+
382
+ return RenderedTokens(
383
+ token_ids=tokens,
384
+ message_indices=indices,
385
+ sampled_mask=sampled,
386
+ is_content=content_mask,
387
+ message_roles=[m.get("role") or "" for m in messages],
388
+ message_tool_names=extract_message_tool_names(messages),
389
+ )
390
+
391
+ def render_ids(
392
+ self,
393
+ messages: list[Message],
394
+ *,
395
+ tools: list[ToolSpec] | None = None,
396
+ add_generation_prompt: bool = False,
397
+ ) -> list[int]:
398
+ return self.render(
399
+ messages,
400
+ tools=tools,
401
+ add_generation_prompt=add_generation_prompt,
402
+ ).token_ids
403
+
404
+ def parse_response(
405
+ self,
406
+ token_ids: list[int],
407
+ *,
408
+ tools: list[ToolSpec] | None = None,
409
+ ) -> ParsedResponse:
410
+ return parse_llama_3(
411
+ self._tokenizer,
412
+ token_ids,
413
+ stop_ids={self._eot, self._end_of_text, self._eom},
414
+ )
415
+
416
+ def get_stop_token_ids(self) -> list[int]:
417
+ return [self._eot, self._end_of_text, self._eom]
418
+
419
+ # ------------------------------------------------------------------
420
+ # bridge_to_next_turn
421
+ # ------------------------------------------------------------------
422
+
423
+ def bridge_to_next_turn(
424
+ self,
425
+ previous_prompt_ids: list[int],
426
+ previous_completion_ids: list[int],
427
+ new_messages: list[Message],
428
+ *,
429
+ tools: list[ToolSpec] | None = None,
430
+ ) -> RenderedTokens | None:
431
+ if (
432
+ not previous_prompt_ids
433
+ or not new_messages
434
+ or reject_assistant_in_extension(new_messages)
435
+ ):
436
+ return None
437
+
438
+ previous_ids = trim_to_turn_close(
439
+ previous_prompt_ids,
440
+ previous_completion_ids,
441
+ {self._eot, self._end_of_text, self._eom},
442
+ synthesize_close=self._eot,
443
+ )
444
+ if previous_ids is None:
445
+ return None
446
+
447
+ ext: list[int] = []
448
+ ext_indices: list[int] = []
449
+ ext_content: list[bool] = []
450
+
451
+ # Every token the bridge emits is template scaffolding for the next
452
+ # prompt — none of it is model-sampled — so ``sampled_mask`` is
453
+ # uniformly ``False`` (applied over the whole sequence at return).
454
+ # ``is_content`` follows the same rules as :meth:`render` so a
455
+ # consumer can walk the trajectory and read each step's body mask.
456
+ def emit_special(token_id: int, msg_idx: int = -1) -> None:
457
+ ext.append(token_id)
458
+ ext_indices.append(msg_idx)
459
+ ext_content.append(False)
460
+
461
+ def emit_text(text: str, msg_idx: int = -1) -> None:
462
+ ids = self._encode(text)
463
+ ext.extend(ids)
464
+ ext_indices.extend([msg_idx] * len(ids))
465
+ ext_content.extend([False] * len(ids))
466
+
467
+ def emit_text_segments(
468
+ segments: list[tuple[str, bool]], msg_idx: int = -1
469
+ ) -> None:
470
+ for tok_id, is_content in attribute_text_segments(
471
+ self._tokenizer, segments
472
+ ):
473
+ ext.append(tok_id)
474
+ ext_indices.append(msg_idx)
475
+ ext_content.append(is_content)
476
+
477
+ for i, msg in enumerate(new_messages):
478
+ role = msg.get("role")
479
+ if role in ("system", "user"):
480
+ content = self._content_str(msg.get("content")).strip()
481
+ emit_special(self._start_header, i)
482
+ emit_text(role, i)
483
+ emit_special(self._end_header, i)
484
+ segs: list[tuple[str, bool]] = [("\n\n", False)]
485
+ if content:
486
+ segs.append((content, True))
487
+ emit_text_segments(segs, i)
488
+ emit_special(self._eot, i)
489
+ elif role in ("tool", "ipython"):
490
+ tool_body = self._tool_response_str(msg.get("content"))
491
+ emit_special(self._start_header, i)
492
+ emit_text("ipython", i)
493
+ emit_special(self._end_header, i)
494
+ tool_segs: list[tuple[str, bool]] = [("\n\n", False)]
495
+ if tool_body:
496
+ tool_segs.append((tool_body, True))
497
+ emit_text_segments(tool_segs, i)
498
+ emit_special(self._eot, i)
499
+ else:
500
+ return None
501
+
502
+ # Generation prompt — matches the gen-prompt branch of ``render()``.
503
+ emit_special(self._start_header, -1)
504
+ emit_text("assistant", -1)
505
+ emit_special(self._end_header, -1)
506
+ emit_text("\n\n", -1)
507
+
508
+ total_len = len(previous_ids) + len(ext)
509
+ return RenderedTokens(
510
+ token_ids=previous_ids + ext,
511
+ message_indices=[-1] * len(previous_ids) + ext_indices,
512
+ sampled_mask=[False] * total_len,
513
+ is_content=[False] * len(previous_ids) + ext_content,
514
+ message_roles=[m.get("role") or "" for m in new_messages],
515
+ message_tool_names=extract_message_tool_names(new_messages),
516
+ )