[Bugfix] Attach request-level tools to existing system message in DeepSeek V4 Python renderer (#51856)
Signed-off-by: Jason Yao <wsyjh8@gmail.com> Co-authored-by: Chauncey <chaunceyjiang@gmail.com>
Jason committed
Sep 17, 2026 at 23:07 UTC
2909ad8fa472c04f7db8330f0e751ff43ae76965
3 files changed
+157
-4
tests/tokenizers_/fixtures/deepseek_v4/test_output_1.txt
+2
-2
@@ -1,4 +1,4 @@
1
-<|begin▁of▁sentence|>
1
+<|begin▁of▁sentence|>You are a helpful assistant.
2
3
## Tools
4
@@ -26,7 +26,7 @@ Otherwise, output directly after </think> with tool calls or final response.
26
{"name": "search", "description": "Search the web for information", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "Search query"}, "num_results": {"type": "integer", "description": "Number of results to return"}}, "required": ["query"]}}
27
28
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
29
-You are a helpful assistant.<|User|>What's the weather in Beijing?<|Assistant|><think>The user wants to know the weather in Beijing. I should use the get_weather tool.</think>
29
+<|User|>What's the weather in Beijing?<|Assistant|><think>The user wants to know the weather in Beijing. I should use the get_weather tool.</think>
30
31
<|DSML|tool_calls>
32
<|DSML|invoke name="get_weather">
tests/tokenizers_/test_deepseek_v4.py
+143
@@ -485,3 +485,146 @@ def test_deepseek_v4_image_sentinel_ids_match_tokenizer():
485
]
486
)
487
assert image_sentinel_mask(ids).tolist() == [False, True, True, False, False]
488
+
489
+
490
+def _request_tools():
491
+ return [
492
+ {
493
+ "type": "function",
494
+ "function": {
495
+ "name": "get_weather",
496
+ "description": "Get weather for a city",
497
+ "parameters": {
498
+ "type": "object",
499
+ "properties": {"city": {"type": "string"}},
500
+ "required": ["city"],
501
+ },
502
+ },
503
+ }
504
+ ]
505
+
506
+
507
+def _encode_reference(messages):
508
+ from vllm.tokenizers.deepseek_v4_encoding import encode_messages
509
+
510
+ return encode_messages(messages, thinking_mode="thinking", reasoning_effort="low")
511
+
512
+
513
+def test_deepseek_v4_attaches_request_tools_to_existing_system_message():
514
+ messages = [
515
+ {"role": "system", "content": "You are helpful."},
516
+ {"role": "user", "content": "Weather in Paris?"},
517
+ ]
518
+
519
+ prompt = _tokenizer().apply_chat_template(
520
+ messages,
521
+ tools=_request_tools(),
522
+ tokenize=False,
523
+ thinking=True,
524
+ reasoning_effort="low",
525
+ )
526
+
527
+ expected = _encode_reference(
528
+ [{**messages[0], "tools": _request_tools()}, messages[1]]
529
+ )
530
+ assert prompt == expected
531
+ assert prompt.startswith("<|begin▁of▁sentence|>You are helpful.\n\n## Tools")
532
+ assert prompt.count("## Tools") == 1
533
+
534
+
535
+def test_deepseek_v4_synthetic_system_only_when_no_system_message():
536
+ """Without a system message, request tools still get a synthetic leading
537
+ system entry."""
538
+ messages = [{"role": "user", "content": "Weather in Paris?"}]
539
+
540
+ prompt = _tokenizer().apply_chat_template(
541
+ messages,
542
+ tools=_request_tools(),
543
+ tokenize=False,
544
+ thinking=True,
545
+ reasoning_effort="low",
546
+ )
547
+
548
+ expected = _encode_reference(
549
+ [{"role": "system", "tools": _request_tools()}, messages[0]]
550
+ )
551
+ assert prompt == expected
552
+ assert prompt.startswith("<|begin▁of▁sentence|>\n\n## Tools")
553
+
554
+
555
+def test_deepseek_v4_request_tools_override_system_message_tools():
556
+ message_tools = [
557
+ {
558
+ "type": "function",
559
+ "function": {
560
+ "name": "message_level_tool",
561
+ "description": "Should not be rendered",
562
+ "parameters": {"type": "object", "properties": {}},
563
+ },
564
+ }
565
+ ]
566
+ system_message = {
567
+ "role": "system",
568
+ "content": "You are helpful.",
569
+ "tools": message_tools,
570
+ }
571
+ user_message = {"role": "user", "content": "Weather in Paris?"}
572
+
573
+ prompt = _tokenizer().apply_chat_template(
574
+ [system_message, user_message],
575
+ tools=_request_tools(),
576
+ tokenize=False,
577
+ thinking=True,
578
+ reasoning_effort="low",
579
+ )
580
+
581
+ expected = _encode_reference(
582
+ [{**system_message, "tools": _request_tools()}, user_message]
583
+ )
584
+ assert prompt == expected
585
+ assert '"name": "get_weather"' in prompt
586
+ assert "message_level_tool" not in prompt
587
+
588
+
589
+def test_deepseek_v4_attaches_request_tools_to_first_system_message_only():
590
+ """Only the first system message, at any index, gets the request tools."""
591
+ messages = [
592
+ {"role": "user", "content": "Hi"},
593
+ {"role": "system", "content": "First system."},
594
+ {"role": "system", "content": "Second system."},
595
+ {"role": "user", "content": "Weather in Paris?"},
596
+ ]
597
+
598
+ prompt = _tokenizer().apply_chat_template(
599
+ messages,
600
+ tools=_request_tools(),
601
+ tokenize=False,
602
+ thinking=True,
603
+ reasoning_effort="low",
604
+ )
605
+
606
+ expected = _encode_reference(
607
+ [
608
+ messages[0],
609
+ {**messages[1], "tools": _request_tools()},
610
+ messages[2],
611
+ messages[3],
612
+ ]
613
+ )
614
+ assert prompt == expected
615
+ assert "First system.\n\n## Tools" in prompt
616
+ assert prompt.count("## Tools") == 1
617
+
618
+
619
+def test_deepseek_v4_request_tools_do_not_mutate_caller_messages():
620
+ import copy
621
+
622
+ messages = [
623
+ {"role": "system", "content": "You are helpful."},
624
+ {"role": "user", "content": "Weather in Paris?"},
625
+ ]
626
+ snapshot = copy.deepcopy(messages)
627
+
628
+ _tokenizer().apply_chat_template(messages, tools=_request_tools(), tokenize=False)
629
+
630
+ assert messages == snapshot
vllm/tokenizers/deepseek_v4.py
+12
-2
@@ -35,8 +35,18 @@ def get_deepseek_v4_tokenizer(tokenizer: HfTokenizer) -> HfTokenizer:
35
conversation = kwargs.get("conversation", messages)
36
messages = conversation.copy()
37
if tools is not None and len(tools) > 0:
38
- messages.insert(0, {"role": "system"})
39
- messages[0]["tools"] = tools # type: ignore[typeddict-unknown-key]
38
+ # Match the Rust renderer: request tools attach to the first
39
+ # system message; synthesize one only when none exists.
40
+ system_idx = next(
41
+ (i for i, m in enumerate(messages) if m.get("role") == "system"),
42
+ None,
43
+ )
44
+ if system_idx is None:
45
+ messages.insert(0, {"role": "system"})
46
+ system_idx = 0
47
+ else:
48
+ messages[system_idx] = copy.copy(messages[system_idx])
49
+ messages[system_idx]["tools"] = tools # type: ignore[typeddict-unknown-key]
50
51
reasoning_effort = kwargs.get("reasoning_effort")
52
if not isinstance(reasoning_effort, str):