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.
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/PKG-INFO +1 -1
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/__init__.py +4 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/_version.py +2 -2
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/base.py +10 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/configs.py +28 -0
- renderers-0.1.8.dev41/renderers/llama_3.py +516 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/parsing.py +67 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/conftest.py +32 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_bridge.py +1 -0
- renderers-0.1.8.dev41/tests/test_llama_3.py +407 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_preserve_thinking.py +6 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_roundtrip.py +7 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/.github/workflows/publish-dev.yml +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/.github/workflows/publish.yml +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/.github/workflows/style.yml +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/.github/workflows/test.yml +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/.gitignore +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/.pre-commit-config.yaml +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/LICENSE +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/README.md +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/docs/renderer-config.md +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/examples/README.md +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/examples/sglang/multiturn_generate_sglang.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/examples/sglang/online_multiturn_sglang.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/examples/tinker/multiturn_generate_tinker.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/examples/transformers/multiturn_generate_transformers.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/examples/vllm/multiturn_generate_vllm.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/pyproject.toml +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/client.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/deepseek_v3.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/default.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/glm45.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/glm5.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/gpt_oss.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/kimi_k2.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/kimi_k25.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/laguna_xs2.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/minimax_m2.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/nemotron3.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/parsers.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/qwen3.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/qwen35.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/qwen36.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/renderers/qwen3_vl.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_build_helpers.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_client.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_gpt_oss_harmony_parity.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_incremental.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_is_content.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_kimi_k25_tool_schema.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_load_tokenizer.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_load_tokenizer_fastokens.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_message_indices.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_message_tool_names.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_multimodal.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_nemotron3_ultra.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_parse_response.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_parse_response_robustness.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_parsers.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_qwen35_size_coverage.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_render_ids.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_renderer_config.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_renderer_config_parity.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_sampled_mask.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_tokens_per_message.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/tests/test_tool_arg_type_preservation.py +0 -0
- {renderers-0.1.8.dev40 → renderers-0.1.8.dev41}/uv.lock +0 -0
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1, 8, '
|
|
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
|
+
)
|