[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):