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