Skip to content

Commit a51af37

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 name is DeepSeek-V4, encode with encode_messages and split it into prompt and completion chunks. In thinking mode, turns whose reasoning the encoder dropped stay in the prompt. Encoded text left after the final assistant turn raises ValueError. - grain_data_processing: build the SFT tokenizer with add_bos=False and add_eos=False, as the HF and vision SFT paths do; with transformers 5.x, add_bos_token=True prepended BOS to every chunk. - 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 masks, name-only DeepSeek-V4 detection, thinking-mode history turns, Grain tokenizer flags, and model_name forwarding.
1 parent 161ad61 commit a51af37

17 files changed

Lines changed: 1150 additions & 7 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: 6 additions & 3 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

@@ -558,7 +558,9 @@ def sft_preprocessing_pipeline(
558558
"""Use grain pipeline to pre-process the dataset and return iterators for sft fine-tuning"""
559559
dataset = data_processing_utils.parse_and_keep_features(dataset, config, data_columns, tokenize)
560560

561-
tokenizer_model, pad_id = data_processing_utils.get_tokenizer_and_pad_id(config)
561+
# Chunks are tokenized one by one and the chat template already emits BOS/EOS, so the tokenizer
562+
# must not add them to every chunk (same as the HF SFT path and vision SFT below).
563+
tokenizer_model, pad_id = data_processing_utils.get_tokenizer_and_pad_id(config, add_bos=False, add_eos=False)
562564
base_tokenizer_model = tokenizer_model
563565

564566
tokenizer_model = getattr(tokenizer_model, "tokenizer", tokenizer_model)
@@ -572,6 +574,7 @@ def sft_preprocessing_pipeline(
572574
_format_chat_template_grain,
573575
data_columns=data_columns,
574576
tokenizer_model=tokenizer_model,
577+
model_name=getattr(config, "model_name", None),
575578
)
576579
)
577580

‎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: 74 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,76 @@ 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 DeepSeek-V4 by model or tokenizer name.
314+
315+
Vocabulary checks are not used: the DeepSeek-V3.2 tokenizer has the same special tokens and
316+
also ships without a chat template, but uses a different encoding.
317+
"""
318+
for name in (model_name, getattr(tokenizer_model, "model_name", None), getattr(tokenizer_model, "name_or_path", None)):
319+
if name and any(x in str(name).lower() for x in ("deepseek4", "deepseek-v4", "deepseek_v4")):
320+
return True
321+
return False
322+
323+
324+
def _format_conversations_dsv4(example, data_column_name):
325+
"""Formats DeepSeek-V4 conversational data using encoding_dsv4.encode_messages."""
326+
raw_messages = example[data_column_name]
327+
for idx, message in enumerate(raw_messages):
328+
if message.get("role") == "system" and idx != 0:
329+
raise ValueError(f"System message found at index {idx}. System messages must be at index 0.")
330+
331+
assistants = [m for m in raw_messages if m.get("role") == "assistant"]
332+
if not assistants:
333+
example["is_prompt"] = []
334+
example[data_column_name] = []
335+
return example
336+
337+
last_assistant_idx = max(i for i, m in enumerate(raw_messages) if m.get("role") == "assistant")
338+
messages_to_encode = raw_messages[: last_assistant_idx + 1]
339+
thinking_mode = "thinking" if any(m.get("reasoning_content") for m in messages_to_encode) else "chat"
340+
text = encoding_dsv4.encode_messages(messages_to_encode, thinking_mode=thinking_mode)
341+
matches = list(_DS4_TURN_RE.finditer(text))
342+
if len(matches) != len(assistants):
343+
raise ValueError(
344+
f"Mismatch between number of assistant messages ({len(assistants)}) "
345+
f"and encoded turns ({len(matches)}). Encoded text: {text!r}"
346+
)
347+
348+
messages = []
349+
is_prompt = []
350+
cursor = 0
351+
for match in matches:
352+
# In thinking mode, encode_messages drops the reasoning of assistant turns before the last user
353+
# message and renders them after `</think>`. Keep those turns in the prompt so that only turns
354+
# with their reasoning are trained.
355+
if thinking_mode == "thinking" and match.group(1) == encoding_dsv4.thinking_end_token:
356+
continue
357+
messages.extend([text[cursor : match.start(2)], match.group(2)])
358+
is_prompt.extend([True, False])
359+
cursor = match.end(2)
360+
361+
if cursor != len(text):
362+
raise ValueError(
363+
f"Encoded text length ({len(text)}) does not match completion boundary ({cursor}). "
364+
f"Trailing conversation content after the final assistant turn was dropped: {text[cursor:]!r}"
365+
)
366+
367+
example["is_prompt"] = is_prompt
368+
example[data_column_name] = messages
369+
return example
370+
371+
372+
def apply_chat_template(example, tokenizer_model, data_column_name, model_name=None):
302373
"""Formats conversational data by applying the tokenizer's chat template
303374
and identifying prompt/completion segments for SFT masking.
304375
@@ -319,6 +390,8 @@ def apply_chat_template(example, tokenizer_model, data_column_name):
319390
prompt (if applicable). `False` indicates the expected LLM
320391
completion, excluding the assistant's start tokens.
321392
"""
393+
if getattr(tokenizer_model, "chat_template", None) is None and _is_deepseek_v4(tokenizer_model, model_name):
394+
return _format_conversations_dsv4(example, data_column_name)
322395
messages = []
323396
is_prompt = []
324397
round_msgs = []

0 commit comments

Comments
 (0)