diff --git a/tests/tokenizers_/fixtures/deepseek_v4/test_output_1.txt b/tests/tokenizers_/fixtures/deepseek_v4/test_output_1.txt
index dbd823476c..7e3c9bd5a3 100644
--- a/tests/tokenizers_/fixtures/deepseek_v4/test_output_1.txt
+++ b/tests/tokenizers_/fixtures/deepseek_v4/test_output_1.txt
@@ -1,4 +1,4 @@
-<|begin▁of▁sentence|>
+<|begin▁of▁sentence|>You are a helpful assistant.
## Tools
@@ -26,7 +26,7 @@ Otherwise, output directly after with tool calls or final response.
{"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"]}}
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
-You are a helpful assistant.<|User|>What's the weather in Beijing?<|Assistant|>The user wants to know the weather in Beijing. I should use the get_weather tool.
+<|User|>What's the weather in Beijing?<|Assistant|>The user wants to know the weather in Beijing. I should use the get_weather tool.
<|DSML|tool_calls>
<|DSML|invoke name="get_weather">
diff --git a/tests/tokenizers_/test_deepseek_v4.py b/tests/tokenizers_/test_deepseek_v4.py
index a55aa57666..f7bc5909ba 100644
--- a/tests/tokenizers_/test_deepseek_v4.py
+++ b/tests/tokenizers_/test_deepseek_v4.py
@@ -485,3 +485,146 @@ def test_deepseek_v4_image_sentinel_ids_match_tokenizer():
]
)
assert image_sentinel_mask(ids).tolist() == [False, True, True, False, False]
+
+
+def _request_tools():
+ return [
+ {
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "description": "Get weather for a city",
+ "parameters": {
+ "type": "object",
+ "properties": {"city": {"type": "string"}},
+ "required": ["city"],
+ },
+ },
+ }
+ ]
+
+
+def _encode_reference(messages):
+ from vllm.tokenizers.deepseek_v4_encoding import encode_messages
+
+ return encode_messages(messages, thinking_mode="thinking", reasoning_effort="low")
+
+
+def test_deepseek_v4_attaches_request_tools_to_existing_system_message():
+ messages = [
+ {"role": "system", "content": "You are helpful."},
+ {"role": "user", "content": "Weather in Paris?"},
+ ]
+
+ prompt = _tokenizer().apply_chat_template(
+ messages,
+ tools=_request_tools(),
+ tokenize=False,
+ thinking=True,
+ reasoning_effort="low",
+ )
+
+ expected = _encode_reference(
+ [{**messages[0], "tools": _request_tools()}, messages[1]]
+ )
+ assert prompt == expected
+ assert prompt.startswith("<|begin▁of▁sentence|>You are helpful.\n\n## Tools")
+ assert prompt.count("## Tools") == 1
+
+
+def test_deepseek_v4_synthetic_system_only_when_no_system_message():
+ """Without a system message, request tools still get a synthetic leading
+ system entry."""
+ messages = [{"role": "user", "content": "Weather in Paris?"}]
+
+ prompt = _tokenizer().apply_chat_template(
+ messages,
+ tools=_request_tools(),
+ tokenize=False,
+ thinking=True,
+ reasoning_effort="low",
+ )
+
+ expected = _encode_reference(
+ [{"role": "system", "tools": _request_tools()}, messages[0]]
+ )
+ assert prompt == expected
+ assert prompt.startswith("<|begin▁of▁sentence|>\n\n## Tools")
+
+
+def test_deepseek_v4_request_tools_override_system_message_tools():
+ message_tools = [
+ {
+ "type": "function",
+ "function": {
+ "name": "message_level_tool",
+ "description": "Should not be rendered",
+ "parameters": {"type": "object", "properties": {}},
+ },
+ }
+ ]
+ system_message = {
+ "role": "system",
+ "content": "You are helpful.",
+ "tools": message_tools,
+ }
+ user_message = {"role": "user", "content": "Weather in Paris?"}
+
+ prompt = _tokenizer().apply_chat_template(
+ [system_message, user_message],
+ tools=_request_tools(),
+ tokenize=False,
+ thinking=True,
+ reasoning_effort="low",
+ )
+
+ expected = _encode_reference(
+ [{**system_message, "tools": _request_tools()}, user_message]
+ )
+ assert prompt == expected
+ assert '"name": "get_weather"' in prompt
+ assert "message_level_tool" not in prompt
+
+
+def test_deepseek_v4_attaches_request_tools_to_first_system_message_only():
+ """Only the first system message, at any index, gets the request tools."""
+ messages = [
+ {"role": "user", "content": "Hi"},
+ {"role": "system", "content": "First system."},
+ {"role": "system", "content": "Second system."},
+ {"role": "user", "content": "Weather in Paris?"},
+ ]
+
+ prompt = _tokenizer().apply_chat_template(
+ messages,
+ tools=_request_tools(),
+ tokenize=False,
+ thinking=True,
+ reasoning_effort="low",
+ )
+
+ expected = _encode_reference(
+ [
+ messages[0],
+ {**messages[1], "tools": _request_tools()},
+ messages[2],
+ messages[3],
+ ]
+ )
+ assert prompt == expected
+ assert "First system.\n\n## Tools" in prompt
+ assert prompt.count("## Tools") == 1
+
+
+def test_deepseek_v4_request_tools_do_not_mutate_caller_messages():
+ import copy
+
+ messages = [
+ {"role": "system", "content": "You are helpful."},
+ {"role": "user", "content": "Weather in Paris?"},
+ ]
+ snapshot = copy.deepcopy(messages)
+
+ _tokenizer().apply_chat_template(messages, tools=_request_tools(), tokenize=False)
+
+ assert messages == snapshot
diff --git a/vllm/tokenizers/deepseek_v4.py b/vllm/tokenizers/deepseek_v4.py
index a5d5020e94..532ca0df7c 100644
--- a/vllm/tokenizers/deepseek_v4.py
+++ b/vllm/tokenizers/deepseek_v4.py
@@ -35,8 +35,18 @@ def get_deepseek_v4_tokenizer(tokenizer: HfTokenizer) -> HfTokenizer:
conversation = kwargs.get("conversation", messages)
messages = conversation.copy()
if tools is not None and len(tools) > 0:
- messages.insert(0, {"role": "system"})
- messages[0]["tools"] = tools # type: ignore[typeddict-unknown-key]
+ # Match the Rust renderer: request tools attach to the first
+ # system message; synthesize one only when none exists.
+ system_idx = next(
+ (i for i, m in enumerate(messages) if m.get("role") == "system"),
+ None,
+ )
+ if system_idx is None:
+ messages.insert(0, {"role": "system"})
+ system_idx = 0
+ else:
+ messages[system_idx] = copy.copy(messages[system_idx])
+ messages[system_idx]["tools"] = tools # type: ignore[typeddict-unknown-key]
reasoning_effort = kwargs.get("reasoning_effort")
if not isinstance(reasoning_effort, str):