Repository navigation
Expand file tree
/
Copy pathconversation_processing.py
More file actions
1920 lines (1716 loc) · 83.3 KB
/
Copy pathconversation_processing.py
File metadata and controls
1920 lines (1716 loc) · 83.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Shared conversation rendering, masking, and multimodal normalization."""
from __future__ import annotations
import copy
import inspect
import json
import re
import warnings
from collections.abc import Mapping, MutableMapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Literal
import torch
from megatron.bridge.data.datasets.utils import GENERATION_REGEX, IGNORE_INDEX
from megatron.bridge.training.utils.visual_inputs import GenericVisualInputs
CHATML_ASSISTANT_ROLE = "assistant"
_LEGACY_CHAT_ROLE_ALIASES = {"human": "user", "gpt": CHATML_ASSISTANT_ROLE}
# Upper bound on the rendered form of a non-tokenizable payload that the boundary-token scan
# will tokenize. Sized to admit payloads that print themselves in full — a few hundred frame
# reprs, or a float array below numpy's summarization threshold — and reject the rest.
_MAX_SCANNED_REPR_CHARS = 65536
_PIPELINE_CONTROLLED_CHAT_TEMPLATE_KWARGS = frozenset(
{
"add_generation_prompt",
"chat_template",
"continue_final_message",
"max_length",
"padding",
"return_assistant_tokens_mask",
"return_dict",
"return_tensors",
"tokenize",
"tools",
"truncation",
}
)
@dataclass(frozen=True)
class AssistantMaskBoundaryConfig:
"""Token boundary fallback configuration for assistant loss masks.
Expected input format:
``role_start_tokens`` maps each chat role to the token-id sequence that
immediately precedes that role's content in already-rendered
``input_ids``. ``role_end_tokens`` maps each chat role to the token-id
sequence that terminates that role's turn. ``loss_roles`` names
top-level chat roles that should contribute to loss. This follows the
common SFT convention used by chat-template based pipelines: once a role
contributes to loss, the role's full rendered content contributes,
including formatted tool-call or reasoning spans inside that role.
``include_*_tokens_for_roles`` optionally include boundary tokens in the
loss. By default, assistant turn end tokens are included because they
are generated by the assistant and teach the model when to stop.
``trim_leading_token_ids`` removes repeated single-token delimiters from
the start of loss-role content. ``trim_leading_token_sequences`` removes
exact leading token sequences, for example an empty ``<think></think>``
wrapper without masking a non-empty ``<think>reasoning`` span.
``role_end_token_variants`` provides ordered fallback end sequences for
formats whose final turn may omit a trailing delimiter such as a
newline. The primary ``role_end_tokens`` sequence is always preferred.
``rendered_turn_end_roles`` covers formats such as GLM-5.2 whose
assistant turn has no explicit end token. For those roles, the
conversation-prefix render determines the exclusive content boundary.
Example::
AssistantMaskBoundaryConfig(
role_start_tokens={"user": [10], "assistant": [11]},
role_end_tokens={"user": [12], "assistant": [12]},
loss_roles=("assistant",),
include_end_tokens_for_roles=("assistant",),
trim_leading_token_ids=(198,),
trim_leading_token_sequences=([55, 56],),
)
Output format:
When passed to :func:`build_assistant_loss_mask`, the fallback scans
rendered ``input_ids`` for role boundaries and returns an
unshifted float mask with ``1.0`` only on role spans whose role appears
in ``loss_roles``. Boundary marker tokens are included only when
requested by the corresponding ``include_*`` fields.
"""
role_start_tokens: Mapping[str, Sequence[int]]
role_end_tokens: Mapping[str, Sequence[int]]
loss_roles: Sequence[str] = (CHATML_ASSISTANT_ROLE,)
include_start_tokens_for_roles: Sequence[str] = ()
include_end_tokens_for_roles: Sequence[str] = (CHATML_ASSISTANT_ROLE,)
trim_leading_token_ids: Sequence[int] = ()
trim_leading_token_sequences: Sequence[Sequence[int]] = ()
role_end_token_variants: Mapping[str, Sequence[Sequence[int]]] = field(default_factory=dict)
rendered_turn_end_roles: Sequence[str] = ()
@dataclass(frozen=True)
class NormalizedVLMSample:
"""Source-normalized VLM sample consumed by shared processing.
Expected input format:
Instances are produced by source adapters such as
:func:`normalize_energon_vlm_sample` and
:func:`normalize_hf_vlm_example`. ``conversation`` must be a structured
list of chat turns in HF processor format, for example::
[
{"role": "user", "content": "describe <image>"},
{"role": "assistant", "content": "a red car"},
]
``content`` may also be a multimodal content list accepted by
``processor.apply_chat_template``, for example::
[{"type": "image", "image": image_obj}, {"type": "text", "text": "describe"}]
``images`` and ``videos`` are optional processor-ready modality payloads.
``tools`` contains optional top-level tool definitions passed to the HF
chat template. Message-level ``tool_calls`` remain in ``conversation``.
Energon adapters convert WDS tensors to PIL objects before populating
these fields; HF adapters may leave them ``None`` when media already lives
inline in ``conversation`` content.
Output format:
Shared processing treats this as the single boundary contract before
model-specific tokenization and vision preprocessing. It does not
contain batched tensors; model-specific collators convert it into model
input tensors.
"""
conversation: list[dict[str, Any]]
images: list[Any] | None = None
videos: list[Any] | None = None
audio: Any | None = None
tools: list[dict[str, Any]] | None = None
@dataclass(frozen=True)
class TokenizedConversation:
"""Token ids and an unshifted assistant-only loss mask for one chat row."""
input_ids: torch.Tensor
assistant_mask: torch.Tensor
conversation: list[dict[str, Any]]
final_assistant_start: int | None = None
def normalize_energon_vlm_sample(sample: Any) -> NormalizedVLMSample:
"""Normalize an Energon ``ChatMLSample`` into the shared VLM sample contract.
Expected input format:
``sample`` is expected to expose the Energon ``ChatMLSample`` fields:
- ``conversation``: JSON string accepted by ``cook_chatml_sample``. The
JSON may use either ``{"role": ..., "content": ...}`` turns or
``{"from": ..., "value": ...}`` turns.
- ``imgs``: optional WDS decoded image tensor/list payload.
- ``videos``: optional WDS decoded video tensor/list payload.
- ``audio``: optional audio payload, passed through unchanged.
- ``tools``: optional top-level tool-definition list. Wrapped
``conversation`` JSON may also carry this field.
Output format:
Returns ``NormalizedVLMSample`` where ``conversation`` is a list of
``{"role": str, "content": str | list[dict]}`` turns, ``images`` are
PIL/list processor inputs or ``None``, ``videos`` are nested PIL/list
processor inputs or ``None``, and ``audio`` is copied from the source
sample when present.
"""
from megatron.bridge.data.energon.task_encoder_utils import (
_images_to_pil,
_videos_to_pil,
cook_chatml_sample,
extract_chatml_tools,
)
imgs = getattr(sample, "imgs", None)
videos = getattr(sample, "videos", None)
tools = getattr(sample, "tools", None)
if tools is None:
tools = extract_chatml_tools(sample.conversation)
if isinstance(tools, (str, bytes)):
tools = json.loads(tools)
if tools is not None and (not isinstance(tools, list) or not all(isinstance(tool, Mapping) for tool in tools)):
raise ValueError("Energon ChatML tools must be a list of tool-definition dictionaries.")
if isinstance(videos, (bytes, bytearray)):
normalized_videos = [videos]
else:
normalized_videos = _videos_to_pil(videos) if videos is not None and len(videos) > 0 else None
return NormalizedVLMSample(
conversation=cook_chatml_sample(sample.conversation),
tools=copy.deepcopy(tools),
images=_images_to_pil(imgs) if imgs is not None and len(imgs) > 0 else None,
videos=normalized_videos,
audio=getattr(sample, "audio", None),
)
def normalize_hf_vlm_example(example: Mapping[str, Any]) -> NormalizedVLMSample:
"""Normalize a HF-style VLM dataset example into the shared sample contract.
Expected input format:
``example`` must contain ``"conversation"`` as a structured list of chat
turns already produced by an HF schema adapter, for example::
{
"conversation": [
{"role": "user", "content": [{"type": "image", "image": img}, {"type": "text", "text": "Q"}]},
{"role": "assistant", "content": [{"type": "text", "text": "A"}]},
],
"audio": optional_audio,
}
Optional top-level ``images``/``image`` and ``videos``/``video`` fields
are accepted for adapter variants that do not embed media inline in the
conversation. Optional top-level ``tools`` are copied for chat-template
rendering.
Output format:
Returns ``NormalizedVLMSample`` with a deep-copied structured
``conversation`` list, optional list-valued ``images`` and ``videos``
payloads, and optional ``audio``. The adapter does not call
``cook_chatml_sample`` because HF adapters have already normalized the chat
schema.
Raises:
ValueError: If ``example["conversation"]`` is missing or is not a list.
"""
conversation = example.get("conversation")
if not isinstance(conversation, list):
raise ValueError("HF VLM examples must contain a list-valued 'conversation' field.")
images = example.get("images")
if images is None and "image" in example:
images = [example["image"]]
videos = example.get("videos")
if videos is None and "video" in example:
videos = [example["video"]]
return NormalizedVLMSample(
conversation=copy.deepcopy(conversation),
tools=copy.deepcopy(example.get("tools")),
images=images,
videos=videos,
audio=example.get("audio"),
)
def normalized_vlm_sample_to_hf_example(
sample: NormalizedVLMSample,
*,
media_first: bool = False,
) -> dict[str, Any]:
"""Convert a normalized VLM sample into the HF-style collate example schema.
Expected input format:
``sample`` follows ``NormalizedVLMSample``: ``conversation`` is a list of
chat turns, and optional ``images``/``videos`` contain processor-ready
media payloads such as PIL images or decoded video frame lists. Text
turns may contain literal ``<image>`` / ``<video>`` placeholders.
Output format:
Returns a dictionary suitable for VLM HF collate functions::
{
"conversation": [
{
"role": "user",
"content": [
{"type": "image", "image": image_obj},
{"type": "text", "text": "describe"},
],
},
{"role": "assistant", "content": [{"type": "text", "text": "answer"}]},
],
"images": [image_obj], # present when sample.images is not None
"videos": [video_obj], # present when sample.videos is not None
"audio": audio_obj, # present when sample.audio is not None
"tools": [...], # present when sample.tools is not None
}
Inline media parts are populated from ``sample.images`` and
``sample.videos`` in placeholder order. When ``media_first=True``,
media parts are moved before text parts within each turn to preserve
Qwen Energon's legacy media-before-text ordering while still using the
shared HF collate function.
"""
images = list(sample.images) if sample.images is not None else []
videos = list(sample.videos) if sample.videos is not None else []
image_idx = 0
video_idx = 0
def _media_part(media_type: str) -> dict[str, Any]:
nonlocal image_idx, video_idx
part: dict[str, Any] = {"type": media_type}
if media_type == "image":
if image_idx < len(images):
part["image"] = images[image_idx]
image_idx += 1
elif media_type == "video" and video_idx < len(videos):
part["video"] = videos[video_idx]
video_idx += 1
return part
def _normalize_content(content: Any) -> Any:
nonlocal image_idx, video_idx
if isinstance(content, list):
parts: list[dict[str, Any] | str] = []
for item in content:
if not isinstance(item, Mapping):
parts.append(item)
continue
item_copy = dict(item)
item_type = item_copy.get("type")
if item_type == "image" and "image" not in item_copy and image_idx < len(images):
item_copy["image"] = images[image_idx]
image_idx += 1
elif item_type == "video" and "video" not in item_copy and video_idx < len(videos):
item_copy["video"] = videos[video_idx]
video_idx += 1
parts.append(item_copy)
if media_first:
media_parts = [
part for part in parts if isinstance(part, Mapping) and part.get("type") in ("image", "video")
]
other_parts = [part for part in parts if part not in media_parts]
return media_parts + other_parts
return parts
if not isinstance(content, str) or ("<image>" not in content and "<video>" not in content):
return copy.deepcopy(content)
pieces = re.split(r"(<image>|<video>)", content)
parts = []
for piece in pieces:
if piece == "<image>":
parts.append(_media_part("image"))
elif piece == "<video>":
parts.append(_media_part("video"))
elif piece:
parts.append({"type": "text", "text": piece.strip(" ")})
if media_first:
media_parts = [part for part in parts if part.get("type") in ("image", "video")]
text_parts = [part for part in parts if part.get("type") not in ("image", "video")]
return media_parts + text_parts
return parts
conversation = []
for turn in sample.conversation:
turn_copy = dict(turn)
turn_copy["content"] = _normalize_content(turn_copy.get("content", ""))
conversation.append(turn_copy)
example: dict[str, Any] = {"conversation": conversation}
if sample.tools is not None:
example["tools"] = copy.deepcopy(sample.tools)
if sample.images is not None:
example["images"] = images
if sample.videos is not None:
example["videos"] = videos
if sample.audio is not None:
example["audio"] = sample.audio
return example
def _get_explicit_attribute(obj: Any, name: str) -> Any | None:
"""Read a declared attribute without triggering dynamic ``__getattr__`` fabrication."""
try:
inspect.getattr_static(obj, name)
except AttributeError:
# ``unittest.mock`` stores attributes that a test explicitly configured
# in ``_mock_children`` even though static inspection cannot see them.
# Reading only an existing child preserves those fixtures without ever
# probing an absent dynamic attribute.
try:
namespace = object.__getattribute__(obj, "__dict__")
except AttributeError:
namespace = {}
mock_children = namespace.get("_mock_children", {})
if name not in mock_children:
return None
try:
return getattr(obj, name)
except AttributeError:
return None
def get_processor_tokenizer(processor: Any) -> Any:
"""Return the HF-style tokenizer attached to a processor or Megatron tokenizer wrapper."""
current = processor
seen: set[int] = set()
while current is not None and id(current) not in seen:
seen.add(id(current))
tokenizer = _get_explicit_attribute(current, "tokenizer")
if tokenizer is not None and tokenizer is not current:
current = tokenizer
continue
# HF fast tokenizers also expose a private ``_tokenizer`` backend. Once
# an object already looks like the callable HF tokenizer, keep it.
if callable(current) and _get_explicit_attribute(current, "added_tokens_decoder") is not None:
return current
tokenizer = _get_explicit_attribute(current, "_tokenizer")
if tokenizer is not None and tokenizer is not current:
current = tokenizer
continue
break
return current
def _as_token_id_list(token_ids: Any) -> list[int]:
if token_ids is None:
return []
if isinstance(token_ids, torch.Tensor):
token_ids = token_ids.detach().cpu().tolist()
if isinstance(token_ids, (list, tuple)) and token_ids and isinstance(token_ids[0], torch.Tensor):
token_ids = [item.detach().cpu().tolist() for item in token_ids]
if isinstance(token_ids, (list, tuple)) and token_ids and isinstance(token_ids[0], (list, tuple)):
if len(token_ids) != 1:
return []
token_ids = token_ids[0]
return [int(token_id) for token_id in token_ids]
def _conversation_from_example(
example_or_conversation: Mapping[str, Any] | Sequence[Mapping[str, Any]],
) -> Sequence[Mapping[str, Any]]:
if isinstance(example_or_conversation, Mapping):
conversation = example_or_conversation.get("conversation", [])
return conversation if isinstance(conversation, Sequence) and not isinstance(conversation, str) else []
return example_or_conversation
def chat_template_kwargs_from_example(
example_or_conversation: Mapping[str, Any] | Sequence[Mapping[str, Any]],
) -> dict[str, Any]:
"""Return optional HF chat-template kwargs stored alongside a conversation.
Rows may provide model-template controls such as ``enable_thinking`` or
``truncate_history_thinking`` under ``chat_template_kwargs``. Rendering
and tokenization controls remain owned by the data pipeline, while tool
schemas use the established top-level ``tools`` field.
Raises:
ValueError: If ``chat_template_kwargs`` is not a string-keyed mapping or
attempts to override a pipeline-controlled argument.
"""
if not isinstance(example_or_conversation, Mapping):
return {}
raw_kwargs = example_or_conversation.get("chat_template_kwargs")
if raw_kwargs is None:
kwargs: dict[str, Any] = {}
elif not isinstance(raw_kwargs, Mapping) or not all(isinstance(key, str) for key in raw_kwargs):
raise ValueError("chat_template_kwargs must be a string-keyed mapping.")
else:
controlled_keys = sorted(_PIPELINE_CONTROLLED_CHAT_TEMPLATE_KWARGS.intersection(raw_kwargs))
if controlled_keys:
joined_keys = ", ".join(controlled_keys)
raise ValueError(f"chat_template_kwargs cannot override pipeline-controlled arguments: {joined_keys}.")
kwargs = dict(raw_kwargs)
truncate_history = kwargs.get("truncate_history_thinking")
if truncate_history is not None and not isinstance(truncate_history, bool):
raise ValueError("chat_template_kwargs.truncate_history_thinking must be a boolean.")
tools = example_or_conversation.get("tools")
if tools is not None:
kwargs["tools"] = tools
return kwargs
def resolve_chat_template_kwargs(template_owner: Any, template_kwargs: Mapping[str, Any]) -> dict[str, Any]:
"""Translate canonical chat controls to a template's native parameter names.
MBridge exposes ``truncate_history_thinking`` consistently. Templates that
implement the inverse ``preserve_thinking`` control receive the equivalent
negated value only at the rendering boundary, while GLM-style
``clear_thinking`` receives the same value. Custom tokenizers such as
Kimi-K3 use ``thinking`` instead of ``enable_thinking``.
Args:
template_owner: Processor or tokenizer that owns the chat template.
template_kwargs: Canonical chat-template keyword arguments.
Returns:
A copied mapping adapted to the selected template.
"""
kwargs = dict(template_kwargs)
tokenizer = get_processor_tokenizer(template_owner)
template = _get_chat_template(template_owner, tokenizer)
if "truncate_history_thinking" in kwargs and (template is None or "truncate_history_thinking" not in template):
truncate_history = kwargs["truncate_history_thinking"]
if template is not None and "preserve_thinking" in template:
kwargs.pop("truncate_history_thinking")
kwargs["preserve_thinking"] = not truncate_history
elif template is not None and "clear_thinking" in template:
kwargs.pop("truncate_history_thinking")
kwargs["clear_thinking"] = truncate_history
if "enable_thinking" in kwargs and (template is None or "enable_thinking" not in template):
parameters: set[str] = set()
for owner in (template_owner, tokenizer):
apply_chat_template = _get_explicit_attribute(owner, "apply_chat_template")
try:
parameters.update(inspect.signature(apply_chat_template).parameters if apply_chat_template else {})
except (TypeError, ValueError):
continue
if "thinking" in parameters and "enable_thinking" not in parameters:
enable_thinking = kwargs.pop("enable_thinking")
kwargs.setdefault("thinking", enable_thinking)
return kwargs
def shared_chat_template_kwargs_from_examples(examples: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
"""Return dataset-wide chat-template kwargs shared by a processor batch.
Processor-batched VLM collators accept one set of template kwargs for the
whole microbatch. Datasets using these collators must therefore keep those
controls and any tool registry identical across rows.
Raises:
ValueError: If rows in one processor batch use different template kwargs.
"""
if not examples:
return {}
kwargs = chat_template_kwargs_from_example(examples[0])
if any(chat_template_kwargs_from_example(example) != kwargs for example in examples[1:]):
raise ValueError("All examples in one processor batch must use the same chat-template kwargs and tools.")
return kwargs
def _normalize_assistant_tool_call_arguments(turn: dict[str, Any]) -> None:
"""Parse OpenAI JSON-string tool arguments for chat-template consumption."""
if turn.get("role") != CHATML_ASSISTANT_ROLE or not isinstance(turn.get("tool_calls"), list):
return
for tool_call in turn["tool_calls"]:
function = tool_call.get("function") if isinstance(tool_call, Mapping) else None
if not isinstance(function, MutableMapping):
continue
arguments = function.get("arguments")
if not isinstance(arguments, str):
continue
try:
parsed_arguments = json.loads(arguments)
except json.JSONDecodeError as exc:
raise ValueError("Assistant tool call function.arguments must be valid JSON.") from exc
if not isinstance(parsed_arguments, dict):
raise ValueError("Assistant tool call function.arguments must be a JSON object.")
function["arguments"] = parsed_arguments
def normalize_chat_conversation(
example_or_conversation: Mapping[str, Any] | Sequence[Mapping[str, Any]],
) -> list[dict[str, Any]]:
"""Normalize supported text-chat schemas to OpenAI-style messages.
Rows may use ``messages``, singular ``conversation``, or legacy plural
``conversations`` with ``from``/``value`` keys. Null OpenAI message content
is converted to an empty string before chat-template rendering.
"""
if isinstance(example_or_conversation, Mapping):
source = example_or_conversation
if source.get("messages") is not None:
conversation = source["messages"]
elif source.get("conversation") is not None:
conversation = source["conversation"]
elif source.get("conversations") is not None:
conversation = source["conversations"]
else:
raise ValueError("Chat rows must contain 'messages', 'conversation', or 'conversations'.")
else:
source = None
conversation = example_or_conversation
if not isinstance(conversation, Sequence) or isinstance(conversation, (str, bytes)):
raise ValueError("Chat conversation must be a sequence of message dictionaries.")
normalized: list[dict[str, Any]] = []
for raw_turn in copy.deepcopy(list(conversation)):
if not isinstance(raw_turn, Mapping):
raise ValueError("Chat conversation turns must be dictionaries.")
turn = dict(raw_turn)
if "role" in turn and "content" in turn:
if turn["content"] is None:
turn["content"] = ""
_normalize_assistant_tool_call_arguments(turn)
normalized.append(turn)
continue
if "from" in turn and "value" in turn:
role = str(turn["from"]).lower()
normalized.append({"role": _LEGACY_CHAT_ROLE_ALIASES.get(role, role), "content": turn["value"]})
continue
raise ValueError("Chat turns must contain role/content or legacy from/value fields.")
if source is not None and source.get("system") and not any(turn.get("role") == "system" for turn in normalized):
normalized.insert(0, {"role": "system", "content": source["system"]})
return normalized
def is_text_only_chat_example(example: Mapping[str, Any]) -> bool:
"""Return whether a supported chat row contains no media content parts."""
top_level_media_keys = (
"image",
"images",
"image_path",
"image_paths",
"video",
"videos",
"video_path",
"video_paths",
"audio",
"audio_path",
"audio_paths",
)
if any(example.get(key) is not None for key in top_level_media_keys):
return False
try:
conversation = normalize_chat_conversation(example)
except ValueError:
return False
for turn in conversation:
content = turn.get("content")
if content is None:
continue
if isinstance(content, str):
if any(marker in content for marker in ("<image>", "<video>", "<audio>")):
return False
continue
if not isinstance(content, Sequence) or isinstance(content, (str, bytes)):
return False
for part in content:
if isinstance(part, str):
continue
if not isinstance(part, Mapping) or part.get("type") != "text":
return False
return True
def _chat_template_input_ids(tokenized_chat: Any) -> list[int]:
input_ids = tokenized_chat.get("input_ids") if isinstance(tokenized_chat, Mapping) else tokenized_chat
return _as_token_id_list(input_ids)
def _apply_tokenized_chat_template(
template_owner: Any,
conversation: Sequence[Mapping[str, Any]],
tokenize_kwargs: Mapping[str, Any],
) -> Any:
"""Apply one chat template with compatibility fallbacks."""
apply_chat_template = _get_explicit_attribute(template_owner, "apply_chat_template")
if apply_chat_template is None:
raise ValueError("Chat preprocessing requires a processor or tokenizer with apply_chat_template.")
kwargs = resolve_chat_template_kwargs(template_owner, tokenize_kwargs)
try:
return apply_chat_template(conversation, **kwargs)
except TypeError:
kwargs.pop("add_generation_prompt", None)
try:
return apply_chat_template(conversation, **kwargs)
except TypeError:
kwargs.pop("return_dict", None)
return apply_chat_template(conversation, **kwargs)
def _common_token_prefix_length(left: Sequence[int], right: Sequence[int]) -> int:
"""Return the number of identical leading token IDs."""
return next(
(index for index, (left_id, right_id) in enumerate(zip(left, right)) if left_id != right_id),
min(len(left), len(right)),
)
def _rendered_window_start(full_ids: Sequence[int], rendered_ids: Sequence[int], template_owner: Any) -> int | None:
"""Locate a template-truncated token window in its untruncated rendering."""
if not rendered_ids:
return 0
if len(rendered_ids) > len(full_ids):
return None
tokenizer = get_processor_tokenizer(template_owner)
truncation_side = getattr(tokenizer, "truncation_side", "right")
window_start = len(full_ids) - len(rendered_ids) if truncation_side == "left" else 0
if all(full_ids[window_start + index] == token_id for index, token_id in enumerate(rendered_ids)):
return window_start
return None
def _infer_final_assistant_start(
template_owner: Any,
conversation: Sequence[Mapping[str, Any]],
tokenize_kwargs: Mapping[str, Any],
rendered_ids: Sequence[int],
*,
terminal_only: bool,
) -> int | None:
"""Map the final assistant-turn boundary into a possibly truncated render."""
if not conversation:
return None
if terminal_only and conversation[-1].get("role") != "assistant":
return len(rendered_ids)
final_assistant_index = next(
(index for index in range(len(conversation) - 1, -1, -1) if conversation[index].get("role") == "assistant"),
None,
)
if final_assistant_index is None:
return len(rendered_ids) if terminal_only else None
if final_assistant_index == 0:
return 0
untruncated_kwargs = dict(tokenize_kwargs)
untruncated_kwargs.pop("truncation", None)
untruncated_kwargs.pop("max_length", None)
if "truncation" in tokenize_kwargs:
full_chat = _apply_tokenized_chat_template(template_owner, conversation, untruncated_kwargs)
full_ids = _chat_template_input_ids(full_chat)
else:
full_ids = list(rendered_ids)
prefix_chat = _apply_tokenized_chat_template(
template_owner,
conversation[:final_assistant_index],
untruncated_kwargs,
)
prefix_ids = _chat_template_input_ids(prefix_chat)
untruncated_boundary = _common_token_prefix_length(prefix_ids, full_ids)
window_start = _rendered_window_start(full_ids, rendered_ids, template_owner)
if window_start is None:
# A template-specific truncation transform prevented exact alignment.
# Do not fall back to an earlier assistant turn.
return len(rendered_ids)
return min(max(untruncated_boundary - window_start, 0), len(rendered_ids))
def _infer_terminal_assistant_content_start(
template_owner: Any,
conversation: Sequence[Mapping[str, Any]],
tokenize_kwargs: Mapping[str, Any],
rendered_ids: Sequence[int],
) -> int | None:
"""Map terminal assistant content, excluding its rendered role header."""
if not conversation:
return None
if conversation[-1].get("role") != "assistant":
return len(rendered_ids)
untruncated_kwargs = dict(tokenize_kwargs)
untruncated_kwargs.pop("truncation", None)
untruncated_kwargs.pop("max_length", None)
if "truncation" in tokenize_kwargs:
full_chat = _apply_tokenized_chat_template(template_owner, conversation, untruncated_kwargs)
full_ids = _chat_template_input_ids(full_chat)
else:
full_ids = list(rendered_ids)
empty_conversation = [dict(turn) for turn in conversation]
empty_conversation[-1]["content"] = ""
empty_conversation[-1].pop("tool_calls", None)
empty_chat = _apply_tokenized_chat_template(template_owner, empty_conversation, untruncated_kwargs)
empty_ids = _chat_template_input_ids(empty_chat)
untruncated_content_start = _common_token_prefix_length(empty_ids, full_ids)
window_start = _rendered_window_start(full_ids, rendered_ids, template_owner)
if window_start is None:
return len(rendered_ids)
return min(max(untruncated_content_start - window_start, 0), len(rendered_ids))
def tokenize_chat_example(
example_or_conversation: Mapping[str, Any] | Sequence[Mapping[str, Any]],
processor: Any,
*,
tool_schemas: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None = None,
max_length: int | None = None,
skipped_tokens: torch.Tensor | None = None,
boundary_config: AssistantMaskBoundaryConfig | None = None,
warn_on_all_masked: bool = True,
loss_mode: Literal["assistant", "last_turn", "full"] = "assistant",
return_final_assistant_start: bool = False,
) -> TokenizedConversation:
"""Render, tokenize, and construct the assistant mask for one chat row.
This is the canonical preprocessing entry point shared by ``GPTSFTDataset``
and direct Hugging Face SFT. Padding, label shifting, and sequence packing
remain the responsibility of their respective dataset/collate layers.
GPT dataset callers may request the final assistant boundary for generation
metadata; ``last_turn`` loss infers it automatically.
"""
if loss_mode not in {"assistant", "last_turn", "full"}:
raise ValueError("Chat SFT loss_mode must be assistant, last_turn, or full.")
conversation = normalize_chat_conversation(example_or_conversation)
template_kwargs = chat_template_kwargs_from_example(example_or_conversation)
if not template_kwargs.get("tools") and tool_schemas is not None:
# Preserve both the historical single-schema dict form and the common
# list form. Per-row non-empty tools take precedence over this fallback.
template_kwargs["tools"] = tool_schemas
tokenizer = get_processor_tokenizer(processor)
tokenized_chat = None
selected_template_owner = None
selected_tokenize_kwargs = None
seen: set[int] = set()
for template_owner in (tokenizer, processor):
if template_owner is None or id(template_owner) in seen:
continue
seen.add(id(template_owner))
if _get_explicit_attribute(template_owner, "apply_chat_template") is None:
continue
tokenize_kwargs: dict[str, Any] = {
"tokenize": True,
"add_generation_prompt": False,
"return_dict": True,
**template_kwargs,
}
template = _get_chat_template(template_owner)
if template is not None and GENERATION_REGEX.search(template) is not None:
tokenize_kwargs["return_assistant_tokens_mask"] = True
if max_length is not None:
tokenize_kwargs.update({"truncation": True, "max_length": max_length})
tokenized_chat = _apply_tokenized_chat_template(template_owner, conversation, tokenize_kwargs)
selected_template_owner = template_owner
selected_tokenize_kwargs = tokenize_kwargs
break
if tokenized_chat is None:
raise ValueError("Chat preprocessing requires a processor or tokenizer with apply_chat_template.")
input_ids = torch.tensor(_chat_template_input_ids(tokenized_chat), dtype=torch.long)
generation_assistant_start = None
last_turn_start = None
if return_final_assistant_start and selected_template_owner is not None and selected_tokenize_kwargs is not None:
generation_assistant_start = _infer_terminal_assistant_content_start(
selected_template_owner,
conversation,
selected_tokenize_kwargs,
input_ids.tolist(),
)
if loss_mode == "last_turn" and selected_template_owner is not None and selected_tokenize_kwargs is not None:
last_turn_start = _infer_final_assistant_start(
selected_template_owner,
conversation,
selected_tokenize_kwargs,
input_ids.tolist(),
terminal_only=False,
)
returned_mask = None
if isinstance(tokenized_chat, Mapping) and tokenized_chat.get("assistant_masks") is not None:
candidate_mask = _as_token_id_list(tokenized_chat["assistant_masks"])
if len(candidate_mask) == input_ids.numel():
returned_mask = torch.tensor(candidate_mask, dtype=torch.bool)
if loss_mode == "full":
full_mask = torch.ones_like(input_ids, dtype=torch.bool)
if skipped_tokens is not None and skipped_tokens.numel() > 0:
full_mask &= ~torch.isin(input_ids, skipped_tokens.to(device=input_ids.device, dtype=torch.long))
return TokenizedConversation(
input_ids=input_ids,
assistant_mask=full_mask,
conversation=conversation,
final_assistant_start=generation_assistant_start,
)
chat_example: dict[str, Any] = {"conversation": conversation}
mask_template_kwargs = dict(template_kwargs)
mask_tools = mask_template_kwargs.pop("tools", None)
if mask_template_kwargs:
chat_example["chat_template_kwargs"] = mask_template_kwargs
if mask_tools is not None:
chat_example["tools"] = mask_tools
effective_boundary_config = boundary_config or infer_assistant_mask_boundary_config(processor)
if returned_mask is not None and effective_boundary_config is None:
assistant_mask = returned_mask
else:
try:
assistant_mask = build_assistant_loss_mask(
chat_example,
input_ids,
processor,
None,
boundary_config=effective_boundary_config,
warn_on_all_masked=False,
).to(dtype=torch.bool)
except ValueError:
if returned_mask is None:
raise
assistant_mask = returned_mask
if last_turn_start is not None:
positions = torch.arange(assistant_mask.numel(), device=assistant_mask.device)
last_turn_positions = torch.nonzero(
assistant_mask.to(dtype=torch.bool) & (positions >= last_turn_start),
as_tuple=False,
).flatten()
if last_turn_positions.numel() > 0:
last_turn_start = int(last_turn_positions[0].item())
elif "truncation" not in (selected_tokenize_kwargs or {}) and bool(assistant_mask.any()):
# Some compatibility tokenizers and simple test doubles ignore the
# conversation argument when rendering a prefix. Without actual
# truncation, retain the historical final visible assistant span.
boolean_mask = assistant_mask.to(dtype=torch.bool)
span_starts = boolean_mask & ~torch.cat(
[torch.zeros(1, dtype=torch.bool, device=boolean_mask.device), boolean_mask[:-1]]
)
last_turn_start = int(torch.nonzero(span_starts, as_tuple=False).flatten()[-1].item())
if (
generation_assistant_start == input_ids.numel()
and conversation
and conversation[-1].get("role") == "assistant"
and "truncation" not in (selected_tokenize_kwargs or {})
and bool(assistant_mask.any())
):
boolean_mask = assistant_mask.to(dtype=torch.bool)
span_starts = boolean_mask & ~torch.cat(
[torch.zeros(1, dtype=torch.bool, device=boolean_mask.device), boolean_mask[:-1]]
)
generation_assistant_start = int(torch.nonzero(span_starts, as_tuple=False).flatten()[-1].item())
assistant_mask = apply_chat_loss_mode(
assistant_mask,
input_ids,
loss_mode=loss_mode,
skipped_tokens=skipped_tokens,
last_turn_start=last_turn_start,
)
_warn_if_all_masked(
assistant_mask,
example_or_conversation,
warn_on_all_masked=warn_on_all_masked,
)
return TokenizedConversation(
input_ids=input_ids,
assistant_mask=assistant_mask,
conversation=conversation,
final_assistant_start=generation_assistant_start,
)
def apply_chat_loss_mode(
assistant_mask: torch.Tensor,
input_ids: torch.Tensor,
*,
loss_mode: Literal["assistant", "last_turn", "full"],
skipped_tokens: torch.Tensor | None = None,
last_turn_start: int | None = None,
) -> torch.Tensor:
"""Apply an explicit chat loss policy to one unpadded token sequence.
``last_turn`` retains assistant tokens after the final assistant-turn
boundary inferred from a prefix render. If a template cannot expose that
boundary, it falls back to the final contiguous assistant-mask span.
Args:
assistant_mask: Assistant-only mask produced by the chat template or
boundary inference.
input_ids: Rendered token IDs aligned with ``assistant_mask``.
loss_mode: Assistant-only, final-assistant-turn, or full-sequence loss.
skipped_tokens: Optional token IDs that must never contribute to loss.
last_turn_start: Optional token boundary inferred by rendering the
conversation prefix before its final assistant turn.
Returns:
Boolean loss mask aligned with ``input_ids``.
"""
if loss_mode == "assistant":
loss_mask = assistant_mask.to(dtype=torch.bool)
elif loss_mode == "full":
loss_mask = torch.ones_like(assistant_mask, dtype=torch.bool)
elif loss_mode == "last_turn":
assistant_mask = assistant_mask.to(dtype=torch.bool)
if last_turn_start is not None:
positions = torch.arange(assistant_mask.numel(), device=assistant_mask.device)
loss_mask = assistant_mask & (positions >= last_turn_start)
else:
span_starts = assistant_mask & ~torch.cat(
[torch.zeros(1, dtype=torch.bool, device=assistant_mask.device), assistant_mask[:-1]]
)
starts = torch.nonzero(span_starts, as_tuple=False).flatten()
if starts.numel() == 0:
loss_mask = assistant_mask
else:
positions = torch.arange(assistant_mask.numel(), device=assistant_mask.device)
loss_mask = assistant_mask & (positions >= starts[-1])
else:
raise ValueError("Chat SFT loss_mode must be assistant, last_turn, or full.")