Skip to content

Commit 2500ee9

Browse files
committed
Add DeepSeek-V4 chat encoding to SFT preprocessing
DeepSeek-V4 ships no Jinja chat_template, so apply_chat_template fails during SFT preprocessing. The official encoder was only vendored under benchmarks/api_server, which src/maxtext cannot import, and that copy had lost the blank lines in TOOLS_TEMPLATE and several docstrings, so its tool-calling prompts no longer matched the official encoder goldens. - Move benchmarks/api_server/encoding/encoding_dsv4.py to src/maxtext/input_pipeline/encoding_dsv4.py, restore the stripped blank lines, and import it from benchmarks/api_server/{maxtext_server,server_utils}.py. - input_pipeline_utils.apply_chat_template: when chat_template is None and the model or tokenizer is DeepSeek-V4, encode with encode_messages and split each assistant turn into prompt and completion; reject encoded text left after the final assistant turn. - grain_data_processing and hf_data_processing: forward model_name to apply_chat_template. - tests: golden parity with the 4 official encoder fixtures, Grain and HF SFT loss-mask checks (packed and unpacked), DeepSeek-V4 detection including unk_token_id fallbacks, and model_name forwarding in hf_data_processing.preprocessing_pipeline.
1 parent 161ad61 commit 2500ee9

17 files changed

Lines changed: 1122 additions & 6 deletions

‎benchmarks/api_server/maxtext_server.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@
5959
ChatMessage,
6060
)
6161
from benchmarks.api_server import server_utils
62-
from benchmarks.api_server.encoding import encoding_dsv32, encoding_dsv4
62+
from benchmarks.api_server.encoding import encoding_dsv32
63+
from maxtext.input_pipeline import encoding_dsv4
6364

6465
# ----------------------------
6566
# Init

‎benchmarks/api_server/server_utils.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232

3333
from benchmarks.api_server.maxtext_generator import MaxTextGenerator
3434
from benchmarks.api_server.server_models import LogProbsPayload
35-
from benchmarks.api_server.encoding import encoding_dsv32, encoding_dsv4
35+
from benchmarks.api_server.encoding import encoding_dsv32
36+
from maxtext.input_pipeline import encoding_dsv4
3637

3738
# ----------------------------
3839
# Debugging

benchmarks/api_server/encoding/encoding_dsv4.py renamed to src/maxtext/input_pipeline/encoding_dsv4.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@
8181
)
8282

8383
TOOLS_TEMPLATE = """## Tools
84+
8485
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml_token}tool_calls>" block like the following:
86+
8587
<{dsml_token}tool_calls>
8688
<{dsml_token}invoke name="$TOOL_NAME">
8789
<{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>
@@ -91,11 +93,17 @@
9193
...
9294
</{dsml_token}invoke>
9395
</{dsml_token}tool_calls>
96+
9497
String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
98+
9599
If thinking_mode is enabled (triggered by {thinking_start_token}), you MUST output your complete reasoning inside {thinking_start_token}...{thinking_end_token} BEFORE any tool calls or final response.
100+
96101
Otherwise, output directly after {thinking_end_token} with tool calls or final response.
102+
97103
### Available Tool Schemas
104+
98105
{tool_schemas}
106+
99107
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
100108
"""
101109

@@ -145,8 +153,10 @@ def tool_calls_to_openai_format(tool_calls):
145153
def encode_arguments_to_dsml(tool_call: Dict[str, str]) -> str:
146154
"""
147155
Encode tool call arguments into DSML parameter format.
156+
148157
Args:
149158
tool_call: Dict with "name" and "arguments" (JSON string) keys.
159+
150160
Returns:
151161
DSML-formatted parameter string.
152162
"""
@@ -173,9 +183,11 @@ def encode_arguments_to_dsml(tool_call: Dict[str, str]) -> str:
173183
def decode_dsml_to_arguments(tool_name: str, tool_args: Dict[str, Tuple[str, str]]) -> Dict[str, str]:
174184
"""
175185
Decode DSML parameters back to a tool call dict.
186+
176187
Args:
177188
tool_name: Name of the tool.
178189
tool_args: Dict mapping param_name -> (value, is_string_flag).
190+
179191
Returns:
180192
Dict with "name" and "arguments" (JSON string) keys.
181193
"""
@@ -192,8 +204,10 @@ def _decode_value(key: str, value: str, string: str):
192204
def render_tools(tools: List[Dict[str, Union[str, Dict[str, Any]]]]) -> str:
193205
"""
194206
Render tool schemas into the system prompt format.
207+
195208
Args:
196209
tools: List of tool schema dicts (each with name, description, parameters).
210+
197211
Returns:
198212
Formatted tools section string.
199213
"""
@@ -244,14 +258,17 @@ def render_message(
244258
) -> str:
245259
"""
246260
Render a single message at the given index into its encoded string form.
261+
247262
This is the core function that converts each message in the conversation
248263
into the DeepSeek-V4 format.
264+
249265
Args:
250266
index: Index of the message to render.
251267
messages: Full list of messages in the conversation.
252268
thinking_mode: Either "chat" or "thinking".
253269
drop_thinking: Whether to drop reasoning content from earlier turns.
254270
reasoning_effort: Optional reasoning effort level ("max", "high", or None).
271+
255272
Returns:
256273
Encoded string for this message.
257274
"""
@@ -408,13 +425,17 @@ def render_message(
408425
def merge_tool_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
409426
"""
410427
Merge tool messages into the preceding user message using content_blocks format.
428+
411429
DeepSeek-V4 does not have a standalone "tool" role; instead, tool results
412430
are encoded as <tool_result> blocks within user messages.
431+
413432
This function converts a standard OpenAI-format conversation (with separate
414433
"tool" role messages) into V4 format where tool results are merged into
415434
user messages.
435+
416436
Args:
417437
messages: List of message dicts in OpenAI format.
438+
418439
Returns:
419440
Processed message list with tool messages merged into user messages.
420441
"""
@@ -471,8 +492,10 @@ def sort_tool_results_by_call_order(messages: List[Dict[str, Any]]) -> List[Dict
471492
"""
472493
Sort tool_result blocks within user messages by the order of tool_calls
473494
in the preceding assistant message.
495+
474496
Args:
475497
messages: Preprocessed message list (after merge_tool_messages).
498+
476499
Returns:
477500
Message list with sorted tool result blocks.
478501
"""
@@ -519,11 +542,13 @@ def encode_messages(
519542
) -> str:
520543
"""
521544
Encode a list of messages into the DeepSeek-V4 prompt format.
545+
522546
This is the main entry point for encoding conversations. It handles:
523547
- BOS token insertion
524548
- Thinking mode with optional reasoning content dropping
525549
- Tool message merging into user messages
526550
- Multi-turn conversation context
551+
527552
Args:
528553
messages: List of message dicts to encode.
529554
thinking_mode: Either "chat" or "thinking".
@@ -532,6 +557,7 @@ def encode_messages(
532557
(only keep reasoning for messages after the last user message).
533558
add_default_bos_token: Whether to prepend BOS token at conversation start.
534559
reasoning_effort: Optional reasoning effort level ("max", "high", or None).
560+
535561
Returns:
536562
The encoded prompt string.
537563
"""
@@ -578,6 +604,7 @@ def encode_messages(
578604
def _drop_thinking_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
579605
"""
580606
Drop reasoning_content and non-essential messages before the last user message.
607+
581608
Behavior:
582609
- Messages with role in ["user", "system", "tool", "latest_reminder"] are always kept.
583610
- Messages at or after the last user index are always kept.
@@ -609,6 +636,7 @@ def _drop_thinking_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, An
609636
def _read_until_stop(index: int, text: str, stop: List[str]) -> Tuple[int, str, Optional[str]]:
610637
"""
611638
Read text from index until one of the stop strings is found.
639+
612640
Returns:
613641
Tuple of (new_index, content_before_stop, matched_stop_string_or_None).
614642
"""
@@ -632,9 +660,11 @@ def _read_until_stop(index: int, text: str, stop: List[str]) -> Tuple[int, str,
632660
def parse_tool_calls(index: int, text: str) -> Tuple[int, Optional[str], List[Dict[str, str]]]:
633661
"""
634662
Parse DSML tool calls from text starting at the given index.
663+
635664
Args:
636665
index: Starting position in text.
637666
text: The full text to parse.
667+
638668
Returns:
639669
Tuple of (new_index, last_stop_token, list_of_tool_call_dicts).
640670
Each tool call dict has "name" and "arguments" keys.
@@ -689,16 +719,20 @@ def parse_tool_calls(index: int, text: str) -> Tuple[int, Optional[str], List[Di
689719
def parse_message_from_completion_text(text: str, thinking_mode: str) -> Dict[str, Any]:
690720
"""
691721
Parse a model completion text into a structured assistant message.
722+
692723
This function takes the raw text output from the model (a single assistant turn)
693724
and extracts:
694725
- reasoning_content (thinking block)
695726
- content (summary/response)
696727
- tool_calls (if any)
728+
697729
NOTE: This function is designed to parse only correctly formatted strings and
698730
will raise ValueError for malformed output.
731+
699732
Args:
700733
text: The raw completion text (including EOS token).
701734
thinking_mode: Either "chat" or "thinking".
735+
702736
Returns:
703737
Dict with keys: "role", "content", "reasoning_content", "tool_calls".
704738
tool_calls are in OpenAI format.

‎src/maxtext/input_pipeline/grain_data_processing.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@ def dpo_preprocessing_pipeline(
509509
return dataset
510510

511511

512-
def _format_chat_template_grain(element, data_columns, tokenizer_model):
512+
def _format_chat_template_grain(element, data_columns, tokenizer_model, model_name=None):
513513
"""Grain-compatible mapping function to format raw columns into conversational messages."""
514514
# Convert raw columns to conversational messages
515515
if "messages" in data_columns:
@@ -536,7 +536,7 @@ def _format_chat_template_grain(element, data_columns, tokenizer_model):
536536
element[data_columns[0]] = messages
537537

538538
return input_pipeline_utils.apply_chat_template(
539-
element, tokenizer_model=tokenizer_model, data_column_name=data_columns[0]
539+
element, tokenizer_model=tokenizer_model, data_column_name=data_columns[0], model_name=model_name
540540
)
541541

542542

@@ -572,6 +572,7 @@ def sft_preprocessing_pipeline(
572572
_format_chat_template_grain,
573573
data_columns=data_columns,
574574
tokenizer_model=tokenizer_model,
575+
model_name=getattr(config, "model_name", None),
575576
)
576577
)
577578

‎src/maxtext/input_pipeline/hf_data_processing.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,11 @@ def preprocessing_pipeline(
398398
data_column_names = list(dataset.features.keys())
399399
dataset = dataset.map(
400400
input_pipeline_utils.apply_chat_template,
401-
fn_kwargs={"tokenizer_model": tokenizer, "data_column_name": data_column_names[0]},
401+
fn_kwargs={
402+
"tokenizer_model": tokenizer,
403+
"data_column_name": data_column_names[0],
404+
"model_name": getattr(config, "model_name", None),
405+
},
402406
)
403407

404408
pad_id = _get_pad_id(tokenizer)

‎src/maxtext/input_pipeline/input_pipeline_utils.py‎

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""Operations used by Grain"""
1616

1717
import dataclasses
18+
import re
1819
import warnings
1920
from threading import current_thread
2021
from typing import Any, Iterable, TYPE_CHECKING
@@ -29,6 +30,7 @@
2930
from grain.experimental import TFRecordIterDataset
3031
from maxtext.diffusion.block_diffusion import corruption as block_diffusion_corruption
3132
from maxtext.input_pipeline.protos import example_pb2
33+
from maxtext.input_pipeline import encoding_dsv4
3234
from maxtext.input_pipeline import tokenizer
3335
from maxtext.multimodal import processor as mm_processor
3436
from maxtext.multimodal import utils as mm_utils
@@ -298,7 +300,84 @@ def _get_completion_in_chat_template(tokenizer_model, round_msgs):
298300
return completion_str
299301

300302

301-
def apply_chat_template(example, tokenizer_model, data_column_name):
303+
_DS4_TURN_RE = re.compile(
304+
re.escape(encoding_dsv4.ASSISTANT_SP_TOKEN)
305+
+ f"({re.escape(encoding_dsv4.thinking_start_token)}|"
306+
+ f"{re.escape(encoding_dsv4.thinking_end_token)})"
307+
+ f"(.*?{re.escape(encoding_dsv4.eos_token)})",
308+
re.DOTALL,
309+
)
310+
311+
312+
def _is_deepseek_v4(tokenizer_model, model_name=None) -> bool:
313+
"""Detects whether the model or tokenizer is DeepSeek-V4."""
314+
for name in (model_name, getattr(tokenizer_model, "model_name", None), getattr(tokenizer_model, "name_or_path", None)):
315+
if name:
316+
name_lower = str(name).lower()
317+
if any(x in name_lower for x in ("deepseek4", "deepseek-v4", "deepseek_v4")):
318+
return True
319+
try:
320+
if hasattr(tokenizer_model, "get_vocab"):
321+
vocab = tokenizer_model.get_vocab()
322+
if "<|User|>" in vocab and "<|Assistant|>" in vocab and "|DSML|" in vocab:
323+
return True
324+
elif hasattr(tokenizer_model, "convert_tokens_to_ids"):
325+
unk_id = getattr(tokenizer_model, "unk_token_id", None)
326+
dsml_id = tokenizer_model.convert_tokens_to_ids("|DSML|")
327+
user_id = tokenizer_model.convert_tokens_to_ids("<|User|>")
328+
if dsml_id is not None and dsml_id != unk_id and user_id is not None and user_id != unk_id:
329+
return True
330+
except Exception: # pylint: disable=broad-exception-caught
331+
pass
332+
return False
333+
334+
335+
def _format_conversations_dsv4(example, data_column_name):
336+
"""Formats DeepSeek-V4 conversational data using encoding_dsv4.encode_messages."""
337+
raw_messages = example[data_column_name]
338+
for idx, message in enumerate(raw_messages):
339+
if message.get("role") == "system" and idx != 0:
340+
raise ValueError(f"System message found at index {idx}. System messages must be at index 0.")
341+
342+
assistants = [m for m in raw_messages if m.get("role") == "assistant"]
343+
if not assistants:
344+
example["is_prompt"] = []
345+
example[data_column_name] = []
346+
return example
347+
348+
last_assistant_idx = max(i for i, m in enumerate(raw_messages) if m.get("role") == "assistant")
349+
messages_to_encode = raw_messages[: last_assistant_idx + 1]
350+
thinking_mode = "thinking" if any(m.get("reasoning_content") for m in messages_to_encode) else "chat"
351+
text = encoding_dsv4.encode_messages(messages_to_encode, thinking_mode=thinking_mode)
352+
matches = list(_DS4_TURN_RE.finditer(text))
353+
if len(matches) != len(assistants):
354+
raise ValueError(
355+
f"Mismatch between number of assistant messages ({len(assistants)}) "
356+
f"and encoded turns ({len(matches)}). Encoded text: {text!r}"
357+
)
358+
359+
messages = []
360+
is_prompt = []
361+
cursor = 0
362+
for match in matches:
363+
completion = match.group(2)
364+
prompt = text[cursor : match.start(2)]
365+
messages.extend([prompt, completion])
366+
is_prompt.extend([True, False])
367+
cursor = match.end(2)
368+
369+
if cursor != len(text):
370+
raise ValueError(
371+
f"Encoded text length ({len(text)}) does not match completion boundary ({cursor}). "
372+
f"Trailing conversation content after the final assistant turn was dropped: {text[cursor:]!r}"
373+
)
374+
375+
example["is_prompt"] = is_prompt
376+
example[data_column_name] = messages
377+
return example
378+
379+
380+
def apply_chat_template(example, tokenizer_model, data_column_name, model_name=None):
302381
"""Formats conversational data by applying the tokenizer's chat template
303382
and identifying prompt/completion segments for SFT masking.
304383
@@ -319,6 +398,8 @@ def apply_chat_template(example, tokenizer_model, data_column_name):
319398
prompt (if applicable). `False` indicates the expected LLM
320399
completion, excluding the assistant's start tokens.
321400
"""
401+
if getattr(tokenizer_model, "chat_template", None) is None and _is_deepseek_v4(tokenizer_model, model_name):
402+
return _format_conversations_dsv4(example, data_column_name)
322403
messages = []
323404
is_prompt = []
324405
round_msgs = []

0 commit comments

Comments
 (0)