@@ -77,6 +77,9 @@ class AssistantMaskBoundaryConfig:
7777 ``role_end_token_variants`` provides ordered fallback end sequences for
7878 formats whose final turn may omit a trailing delimiter such as a
7979 newline. The primary ``role_end_tokens`` sequence is always preferred.
80+ ``rendered_turn_end_roles`` covers formats such as GLM-5.2 whose
81+ assistant turn has no explicit end token. For those roles, the
82+ conversation-prefix render determines the exclusive content boundary.
8083
8184 Example::
8285
@@ -105,6 +108,7 @@ class AssistantMaskBoundaryConfig:
105108 trim_leading_token_ids : Sequence [int ] = ()
106109 trim_leading_token_sequences : Sequence [Sequence [int ]] = ()
107110 role_end_token_variants : Mapping [str , Sequence [Sequence [int ]]] = field (default_factory = dict )
111+ rendered_turn_end_roles : Sequence [str ] = ()
108112
109113
110114@dataclass (frozen = True )
@@ -491,7 +495,9 @@ def resolve_chat_template_kwargs(template_owner: Any, template_kwargs: Mapping[s
491495
492496 MBridge exposes ``truncate_history_thinking`` consistently. Templates that
493497 implement the inverse ``preserve_thinking`` control receive the equivalent
494- negated value only at the rendering boundary.
498+ negated value only at the rendering boundary, while GLM-style
499+ ``clear_thinking`` receives the same value. Custom tokenizers such as
500+ Kimi-K3 use ``thinking`` instead of ``enable_thinking``.
495501
496502 Args:
497503 template_owner: Processor or tokenizer that owns the chat template.
@@ -501,13 +507,29 @@ def resolve_chat_template_kwargs(template_owner: Any, template_kwargs: Mapping[s
501507 A copied mapping adapted to the selected template.
502508 """
503509 kwargs = dict (template_kwargs )
504- if "truncate_history_thinking" not in kwargs :
505- return kwargs
506510 tokenizer = get_processor_tokenizer (template_owner )
507511 template = _get_chat_template (template_owner , tokenizer )
508- if template is not None and "preserve_thinking" in template and "truncate_history_thinking" not in template :
509- truncate_history = kwargs .pop ("truncate_history_thinking" )
510- kwargs ["preserve_thinking" ] = not truncate_history
512+
513+ if "truncate_history_thinking" in kwargs and (template is None or "truncate_history_thinking" not in template ):
514+ truncate_history = kwargs ["truncate_history_thinking" ]
515+ if template is not None and "preserve_thinking" in template :
516+ kwargs .pop ("truncate_history_thinking" )
517+ kwargs ["preserve_thinking" ] = not truncate_history
518+ elif template is not None and "clear_thinking" in template :
519+ kwargs .pop ("truncate_history_thinking" )
520+ kwargs ["clear_thinking" ] = truncate_history
521+
522+ if "enable_thinking" in kwargs and (template is None or "enable_thinking" not in template ):
523+ parameters : set [str ] = set ()
524+ for owner in (template_owner , tokenizer ):
525+ apply_chat_template = _get_explicit_attribute (owner , "apply_chat_template" )
526+ try :
527+ parameters .update (inspect .signature (apply_chat_template ).parameters if apply_chat_template else {})
528+ except (TypeError , ValueError ):
529+ continue
530+ if "thinking" in parameters and "enable_thinking" not in parameters :
531+ enable_thinking = kwargs .pop ("enable_thinking" )
532+ kwargs .setdefault ("thinking" , enable_thinking )
511533 return kwargs
512534
513535
@@ -852,7 +874,12 @@ def tokenize_chat_example(
852874 )
853875
854876 chat_example : dict [str , Any ] = {"conversation" : conversation }
855- chat_example .update (template_kwargs )
877+ mask_template_kwargs = dict (template_kwargs )
878+ mask_tools = mask_template_kwargs .pop ("tools" , None )
879+ if mask_template_kwargs :
880+ chat_example ["chat_template_kwargs" ] = mask_template_kwargs
881+ if mask_tools is not None :
882+ chat_example ["tools" ] = mask_tools
856883 effective_boundary_config = boundary_config or infer_assistant_mask_boundary_config (processor )
857884 if returned_mask is not None and effective_boundary_config is None :
858885 assistant_mask = returned_mask
@@ -1037,9 +1064,42 @@ def infer_assistant_mask_boundary_config(processor: Any) -> AssistantMaskBoundar
10371064 """Infer assistant role boundaries from known HF chat-template formats."""
10381065 tokenizer = get_processor_tokenizer (processor )
10391066 template = _get_chat_template (tokenizer , processor )
1067+
10401068 if template is None :
1069+ # Kimi-K3 owns rendering in a custom tokenizer rather than a Jinja
1070+ # ``chat_template``. Detect its XTML structural vocabulary instead of a
1071+ # Python class name so local/remote-code wrappers retain the same behavior.
1072+ apply_chat_template = _get_explicit_attribute (tokenizer , "apply_chat_template" )
1073+ try :
1074+ template_parameters = inspect .signature (apply_chat_template ).parameters if apply_chat_template else {}
1075+ except (TypeError , ValueError ):
1076+ template_parameters = {}
1077+ if "thinking" in template_parameters :
1078+ get_vocab = _get_explicit_attribute (tokenizer , "get_vocab" )
1079+ try :
1080+ vocab = get_vocab () if callable (get_vocab ) else {}
1081+ except (AttributeError , TypeError , ValueError ):
1082+ vocab = {}
1083+ kimi_markers = ("<|open|>" , "<|close|>" , "<|sep|>" , "<|end_of_msg|>" )
1084+ if isinstance (vocab , Mapping ) and all (marker in vocab for marker in kimi_markers ):
1085+ return assistant_mask_boundary_config_from_markers (
1086+ processor ,
1087+ assistant_start = '<|open|>message role="assistant"<|sep|>' ,
1088+ assistant_end = "<|close|>message<|sep|><|end_of_msg|>" ,
1089+ )
10411090 return None
10421091
1092+ # GLM-5.2 does not terminate completed assistant turns with a structural
1093+ # token. Prefix rendering is therefore the only loss boundary that remains
1094+ # exact for both terminal and historical turns.
1095+ if all (marker in template for marker in ("<|assistant|>" , "clear_thinking" , "<think>" )):
1096+ return assistant_mask_boundary_config_from_markers (
1097+ processor ,
1098+ assistant_start = "<|assistant|>" ,
1099+ assistant_end = None ,
1100+ rendered_turn_end_roles = (CHATML_ASSISTANT_ROLE ,),
1101+ )
1102+
10431103 candidates : tuple [tuple [tuple [str , ...], str , str , tuple [str , ...], Mapping [str , str ]], ...] = (
10441104 (
10451105 ("<|im_start|>assistant" , "<|im_end|>" ),
@@ -1086,6 +1146,18 @@ def infer_assistant_mask_boundary_config(processor: Any) -> AssistantMaskBoundar
10861146 ("<|end|>" , "<|call|>" ),
10871147 {role : f"<|start|>{ role } " for role in ("system" , "developer" , "user" , "tool" )},
10881148 ),
1149+ (
1150+ ("]~!b[" , "]~b]" , "[e~[" , "<mm:think>" ),
1151+ "]~b]ai\n " ,
1152+ "[e~[\n " ,
1153+ (),
1154+ {
1155+ "system" : "]~!b[]~b]system\n " ,
1156+ "developer" : "]~b]developer\n " ,
1157+ "user" : "]~b]user\n " ,
1158+ "tool" : "]~b]tool" ,
1159+ },
1160+ ),
10891161 )
10901162 for required_markers , assistant_start , assistant_end , assistant_end_fallbacks , other_role_starts in candidates :
10911163 if not all (marker in template for marker in required_markers ):
@@ -1118,25 +1190,26 @@ def assistant_mask_boundary_config_from_markers(
11181190 processor : Any ,
11191191 * ,
11201192 assistant_start : str ,
1121- assistant_end : str ,
1193+ assistant_end : str | None ,
11221194 assistant_end_fallbacks : Sequence [str ] = (),
11231195 role_start_markers : Mapping [str , str ] | None = None ,
11241196 loss_roles : Sequence [str ] = (CHATML_ASSISTANT_ROLE ,),
11251197 include_start_tokens_for_roles : Sequence [str ] = (),
11261198 include_end_tokens_for_roles : Sequence [str ] = (CHATML_ASSISTANT_ROLE ,),
11271199 trim_leading_token_ids : Sequence [int ] = (),
11281200 trim_leading_token_sequences : Sequence [Sequence [int ]] = (),
1201+ rendered_turn_end_roles : Sequence [str ] = (),
11291202) -> AssistantMaskBoundaryConfig :
11301203 """Build explicit assistant boundary config from model-declared marker strings."""
11311204 tokenizer = get_processor_tokenizer (processor )
11321205 start_tokens = tokenize_text_without_special_tokens (tokenizer , assistant_start )
1133- end_tokens = tokenize_text_without_special_tokens (tokenizer , assistant_end )
1206+ end_tokens = tokenize_text_without_special_tokens (tokenizer , assistant_end ) if assistant_end is not None else []
11341207 end_token_variants = [
11351208 token_ids
11361209 for fallback in assistant_end_fallbacks
11371210 if (token_ids := tokenize_text_without_special_tokens (tokenizer , fallback ))
11381211 ]
1139- if not start_tokens or not end_tokens :
1212+ if not start_tokens or ( not end_tokens and CHATML_ASSISTANT_ROLE not in rendered_turn_end_roles ) :
11401213 raise ValueError (
11411214 "Unable to tokenize assistant loss-mask boundary markers. "
11421215 "Check the model collator's assistant_start and assistant_end markers."
@@ -1149,13 +1222,14 @@ def assistant_mask_boundary_config_from_markers(
11491222 role_start_tokens [role ] = marker_tokens
11501223 return AssistantMaskBoundaryConfig (
11511224 role_start_tokens = role_start_tokens ,
1152- role_end_tokens = {role : end_tokens for role in role_start_tokens },
1225+ role_end_tokens = {role : end_tokens for role in role_start_tokens if role not in rendered_turn_end_roles },
11531226 loss_roles = loss_roles ,
11541227 include_start_tokens_for_roles = include_start_tokens_for_roles ,
11551228 include_end_tokens_for_roles = include_end_tokens_for_roles ,
11561229 trim_leading_token_ids = trim_leading_token_ids ,
11571230 trim_leading_token_sequences = trim_leading_token_sequences ,
11581231 role_end_token_variants = {role : end_token_variants for role in role_start_tokens },
1232+ rendered_turn_end_roles = rendered_turn_end_roles ,
11591233 )
11601234
11611235
@@ -1308,13 +1382,14 @@ def _assistant_mask_from_conversation_turns(
13081382 }
13091383 include_start_roles = set (boundary_config .include_start_tokens_for_roles )
13101384 include_end_roles = set (boundary_config .include_end_tokens_for_roles )
1385+ rendered_turn_end_roles = set (boundary_config .rendered_turn_end_roles )
13111386 rendered_mask = torch .zeros (len (rendered_ids ), dtype = torch .float32 )
13121387
13131388 for turn_index , turn in loss_turns :
13141389 role = turn ["role" ]
13151390 start_tokens = role_start_tokens .get (role )
13161391 end_patterns = [role_end_tokens .get (role , []), * role_end_token_variants .get (role , [])]
1317- if not start_tokens or not any (end_patterns ):
1392+ if not start_tokens or ( not any (end_patterns ) and role not in rendered_turn_end_roles ):
13181393 return None
13191394
13201395 try :
@@ -1344,20 +1419,23 @@ def _assistant_mask_from_conversation_turns(
13441419 if role_start < 0 or content_start > turn_limit :
13451420 return None
13461421
1347- candidate_ends : list [tuple [int , int , int , list [int ]]] = []
1348- for priority , pattern in enumerate (end_patterns ):
1349- if not pattern :
1350- continue
1351- search_start = content_start
1352- while search_start < turn_limit :
1353- end_start , after_end = find_token_span (rendered_ids , pattern , search_start )
1354- if end_start < 0 or after_end > turn_limit :
1355- break
1356- candidate_ends .append ((end_start , - priority , after_end , pattern ))
1357- search_start = end_start + 1
1358- if not candidate_ends :
1359- return None
1360- content_end , _ , after_end , _ = max (candidate_ends )
1422+ if role in rendered_turn_end_roles :
1423+ content_end = after_end = turn_limit
1424+ else :
1425+ candidate_ends : list [tuple [int , int , int , list [int ]]] = []
1426+ for priority , pattern in enumerate (end_patterns ):
1427+ if not pattern :
1428+ continue
1429+ search_start = content_start
1430+ while search_start < turn_limit :
1431+ end_start , after_end = find_token_span (rendered_ids , pattern , search_start )
1432+ if end_start < 0 or after_end > turn_limit :
1433+ break
1434+ candidate_ends .append ((end_start , - priority , after_end , pattern ))
1435+ search_start = end_start + 1
1436+ if not candidate_ends :
1437+ return None
1438+ content_end , _ , after_end , _ = max (candidate_ends )
13611439
13621440 loss_start = _trim_leading_token_sequences (
13631441 rendered_ids ,
@@ -1375,7 +1453,7 @@ def _assistant_mask_from_conversation_turns(
13751453 rendered_mask [loss_start :content_end ] = 1.0
13761454 if role in include_start_roles :
13771455 rendered_mask [role_start : role_start + len (start_tokens )] = 1.0
1378- if role in include_end_roles :
1456+ if role in include_end_roles and role not in rendered_turn_end_roles :
13791457 rendered_mask [content_end :after_end ] = 1.0
13801458
13811459 aligned_mask = _align_assistant_mask_to_padded_ids (rendered_ids , rendered_mask .tolist (), ids , tokenizer )
@@ -1535,6 +1613,12 @@ def _assistant_mask_from_boundary_config(
15351613 for role , raw_variants in boundary_config .role_end_token_variants .items ()
15361614 }
15371615 loss_roles = set (boundary_config .loss_roles )
1616+ rendered_turn_end_roles = set (boundary_config .rendered_turn_end_roles )
1617+ if rendered_turn_end_roles :
1618+ # A raw token scan cannot distinguish the end of an unterminated turn
1619+ # from following non-loss roles. Only the conversation-prefix renderer
1620+ # can establish this boundary safely.
1621+ return None
15381622 if not role_start_tokens or not role_end_tokens or not loss_roles :
15391623 raise ValueError (
15401624 "AssistantMaskBoundaryConfig must provide non-empty role_start_tokens, role_end_tokens, and loss_roles."
@@ -1641,7 +1725,7 @@ def build_assistant_loss_mask(
16411725
16421726 mask = _assistant_mask_from_hf_chat_template (example_or_conversation , ids , processor , tokenizer )
16431727 if boundary_config is not None :
1644- boundary_scan_is_safe = (
1728+ boundary_scan_is_safe = not boundary_config . rendered_turn_end_roles and (
16451729 not conversation
16461730 or _conversation_contains_boundary_tokens (example_or_conversation , tokenizer , boundary_config ) is False
16471731 )
0 commit comments