diff --git a/examples/chat_template/tool_chat_template_deepseekv3.jinja b/examples/chat_template/tool_chat_template_deepseekv3.jinja index 46c1b8801..fdde62ee1 100644 --- a/examples/chat_template/tool_chat_template_deepseekv3.jinja +++ b/examples/chat_template/tool_chat_template_deepseekv3.jinja @@ -47,15 +47,16 @@ {%- set ns.is_tool = false -%} {%- set ns.is_output_first = true %} {%- for tool in message['tool_calls'] %} + {%- set formatted_args = tool['function']['arguments'] if tool['function']['arguments'] is string else tool['function']['arguments']|tojson %} {%- if not ns.is_first %} {%- if message['content'] is none %} - {{- '<|tool▁calls▁begin|><|tool▁call▁begin|>' + tool['type'] + '<|tool▁sep|>' + tool['function']['name'] + '\n' + '```json' + '\n' + tool['function']['arguments']|tojson + '\n' + '```' + '<|tool▁call▁end|>'}} + {{- '<|tool▁calls▁begin|><|tool▁call▁begin|>' + tool['type'] + '<|tool▁sep|>' + tool['function']['name'] + '\n' + '```json' + '\n' + formatted_args + '\n' + '```' + '<|tool▁call▁end|>'}} {%- else %} - {{- message['content'] + '<|tool▁calls▁begin|><|tool▁call▁begin|>' + tool['type'] + '<|tool▁sep|>' + tool['function']['name'] + '\n' + '```json' + '\n' + tool['function']['arguments']|tojson + '\n' + '```' + '<|tool▁call▁end|>'}} + {{- message['content'] + '<|tool▁calls▁begin|><|tool▁call▁begin|>' + tool['type'] + '<|tool▁sep|>' + tool['function']['name'] + '\n' + '```json' + '\n' + formatted_args + '\n' + '```' + '<|tool▁call▁end|>'}} {%- endif %} {%- set ns.is_first = true -%} {%- else %} - {{- '\n' + '<|tool▁call▁begin|>' + tool['type'] + '<|tool▁sep|>' + tool['function']['name'] + '\n' + '```json' + '\n' + tool['function']['arguments']|tojson + '\n' + '```' + '<|tool▁call▁end|>'}} + {{- '\n' + '<|tool▁call▁begin|>' + tool['type'] + '<|tool▁sep|>' + tool['function']['name'] + '\n' + '```json' + '\n' + formatted_args + '\n' + '```' + '<|tool▁call▁end|>'}} {%- endif %} {%- endfor %} {{- '<|tool▁calls▁end|><|end▁of▁sentence|>'}} diff --git a/examples/chat_template/tool_chat_template_deepseekv31.jinja b/examples/chat_template/tool_chat_template_deepseekv31.jinja index 08e93a30a..a97f011fa 100644 --- a/examples/chat_template/tool_chat_template_deepseekv31.jinja +++ b/examples/chat_template/tool_chat_template_deepseekv31.jinja @@ -41,15 +41,16 @@ {%- set ns.is_first = false %} {%- set ns.is_tool = false -%} {%- for tool in message['tool_calls'] %} + {%- set formatted_args = tool['function']['arguments'] if tool['function']['arguments'] is string else tool['function']['arguments']|tojson %} {%- if not ns.is_first %} {%- if message['content'] is none %} - {{'<|tool▁calls▁begin|><|tool▁call▁begin|>'+ tool['function']['name'] + '<|tool▁sep|>' + tool['function']['arguments']|tojson + '<|tool▁call▁end|>'}} + {{'<|tool▁calls▁begin|><|tool▁call▁begin|>'+ tool['function']['name'] + '<|tool▁sep|>' + formatted_args + '<|tool▁call▁end|>'}} {%- else %} - {{message['content'] + '<|tool▁calls▁begin|><|tool▁call▁begin|>' + tool['function']['name'] + '<|tool▁sep|>' + tool['function']['arguments']|tojson + '<|tool▁call▁end|>'}} + {{message['content'] + '<|tool▁calls▁begin|><|tool▁call▁begin|>' + tool['function']['name'] + '<|tool▁sep|>' + formatted_args + '<|tool▁call▁end|>'}} {%- endif %} {%- set ns.is_first = true -%} {%- else %} - {{'<|tool▁call▁begin|>'+ tool['function']['name'] + '<|tool▁sep|>' + tool['function']['arguments']|tojson + '<|tool▁call▁end|>'}} + {{'<|tool▁call▁begin|>'+ tool['function']['name'] + '<|tool▁sep|>' + formatted_args + '<|tool▁call▁end|>'}} {%- endif %} {%- endfor %} {{'<|tool▁calls▁end|><|end▁of▁sentence|>'}} diff --git a/examples/chat_template/tool_chat_template_deepseekv32.jinja b/examples/chat_template/tool_chat_template_deepseekv32.jinja index 66e3a337e..b6d239dce 100644 --- a/examples/chat_template/tool_chat_template_deepseekv32.jinja +++ b/examples/chat_template/tool_chat_template_deepseekv32.jinja @@ -42,15 +42,16 @@ {%- set ns.is_first = false %} {%- set ns.is_tool = false -%} {%- for tool in message['tool_calls'] %} + {%- set formatted_args = tool['function']['arguments'] if tool['function']['arguments'] is string else tool['function']['arguments']|tojson %} {%- if not ns.is_first %} {%- if message['content'] is none %} - {{'<|tool▁calls▁begin|><|tool▁call▁begin|>'+ tool['function']['name'] + '<|tool▁sep|>' + tool['function']['arguments']|tojson + '<|tool▁call▁end|>'}} + {{'<|tool▁calls▁begin|><|tool▁call▁begin|>'+ tool['function']['name'] + '<|tool▁sep|>' + formatted_args + '<|tool▁call▁end|>'}} {%- else %} - {{message['content'] + '<|tool▁calls▁begin|><|tool▁call▁begin|>' + tool['function']['name'] + '<|tool▁sep|>' + tool['function']['arguments']|tojson + '<|tool▁call▁end|>'}} + {{message['content'] + '<|tool▁calls▁begin|><|tool▁call▁begin|>' + tool['function']['name'] + '<|tool▁sep|>' + formatted_args + '<|tool▁call▁end|>'}} {%- endif %} {%- set ns.is_first = true -%} {%- else %} - {{'<|tool▁call▁begin|>'+ tool['function']['name'] + '<|tool▁sep|>' + tool['function']['arguments']|tojson + '<|tool▁call▁end|>'}} + {{'<|tool▁call▁begin|>'+ tool['function']['name'] + '<|tool▁sep|>' + formatted_args + '<|tool▁call▁end|>'}} {%- endif %} {%- endfor %} {{'<|tool▁calls▁end|><|end▁of▁sentence|>'}} diff --git a/test/srt/test_deepseek_chat_templates.py b/test/srt/test_deepseek_chat_templates.py new file mode 100644 index 000000000..6c56816c8 --- /dev/null +++ b/test/srt/test_deepseek_chat_templates.py @@ -0,0 +1,319 @@ +""" +Unit tests for DeepSeek chat template tool call handling. + +Tests verify that the DeepSeek chat templates (v3, v3.1, v3.2) correctly handle +both dict and string types for tool['function']['arguments'] without double-escaping, +addressing issue #11700. +""" + +import json +import os +import unittest + +from jinja2 import Template + + +class TestDeepSeekChatTemplateToolCalls(unittest.TestCase): + """Test DeepSeek chat templates handle tool calls correctly.""" + + @classmethod + def setUpClass(cls): + """Load all DeepSeek chat templates.""" + base_path = os.path.join( + os.path.dirname(__file__), "..", "..", "examples", "chat_template" + ) + + cls.templates = {} + template_files = { + "v3": "tool_chat_template_deepseekv3.jinja", + "v3.1": "tool_chat_template_deepseekv31.jinja", + "v3.2": "tool_chat_template_deepseekv32.jinja", + } + + for version, filename in template_files.items(): + template_path = os.path.join(base_path, filename) + with open(template_path, "r") as f: + template_content = f.read() + cls.templates[version] = Template(template_content) + + def _render_template( + self, version, messages, tools=None, add_generation_prompt=True + ): + """Helper method to render a template with given messages and tools.""" + template = self.templates[version] + + # Common template variables + context = { + "messages": messages, + "add_generation_prompt": add_generation_prompt, + "bos_token": "<|begin▁of▁sentence|>", + } + + if tools is not None: + context["tools"] = tools + + return template.render(**context) + + def test_tool_arguments_as_dict(self): + """Test that tool arguments as dict are properly JSON-encoded (normal case).""" + # This tests the normal case where arguments come from OpenAI API as dict + + for version in ["v3", "v3.1", "v3.2"]: + with self.subTest(version=version): + messages = [ + {"role": "user", "content": "What's the weather in NYC?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "type": "function", + "function": { + "name": "get_weather", + "arguments": { + "city": "New York", + "unit": "celsius", + }, # Dict + }, + } + ], + }, + ] + + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather information", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"}, + "unit": {"type": "string"}, + }, + }, + }, + } + ] + + output = self._render_template(version, messages, tools) + + # Should contain properly formatted JSON (not double-escaped) + self.assertIn('"city"', output, f"{version}: Should contain city key") + self.assertIn( + '"New York"', output, f"{version}: Should contain city value" + ) + + # Should NOT contain double-escaped quotes + self.assertNotIn( + '\\"city\\"', output, f"{version}: Should not double-escape" + ) + self.assertNotIn( + '\\\\"', output, f"{version}: Should not have escaped backslashes" + ) + + def test_tool_arguments_as_string(self): + """Test that tool arguments as string are used as-is (multi-round case).""" + # This tests the multi-round function calling case from issue #11700 + # where arguments might already be JSON strings from previous model output + + for version in ["v3", "v3.1", "v3.2"]: + with self.subTest(version=version): + messages = [ + {"role": "user", "content": "What's the stock price of NVDA?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "type": "function", + "function": { + "name": "get_stock_info", + "arguments": '{"symbol": "NVDA"}', # Already a JSON string + }, + } + ], + }, + ] + + tools = [ + { + "type": "function", + "function": { + "name": "get_stock_info", + "description": "Get stock information", + "parameters": { + "type": "object", + "properties": { + "symbol": {"type": "string"}, + }, + }, + }, + } + ] + + output = self._render_template(version, messages, tools) + + # Should contain the JSON string as-is + self.assertIn( + '{"symbol": "NVDA"}', + output, + f"{version}: Should contain JSON as-is", + ) + + # Should NOT double-escape (the bug from issue #11700) + # Bad output would look like: "{\"symbol\": \"NVDA\"}" or "{\\"symbol\\": \\"NVDA\\"}" + self.assertNotIn( + '{\\"symbol\\"', output, f"{version}: Should not double-escape" + ) + self.assertNotIn( + '"{\\"symbol', output, f"{version}: Should not wrap and escape" + ) + + # Verify it's not triple-quoted or escaped + self.assertNotIn( + '""{"', output, f"{version}: Should not have extra quotes" + ) + + def test_multiple_tool_calls_mixed_types(self): + """Test multiple tool calls with mixed dict and string argument types.""" + # This tests a complex scenario with multiple tools, some with dict args, some with string + + for version in ["v3", "v3.1", "v3.2"]: + with self.subTest(version=version): + messages = [ + {"role": "user", "content": "Get weather and stock info"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "type": "function", + "function": { + "name": "get_weather", + "arguments": {"city": "Boston"}, # Dict + }, + }, + { + "type": "function", + "function": { + "name": "get_stock_info", + "arguments": '{"symbol": "TSLA"}', # String + }, + }, + ], + }, + ] + + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_stock_info", + "description": "Get stock info", + "parameters": { + "type": "object", + "properties": {"symbol": {"type": "string"}}, + }, + }, + }, + ] + + output = self._render_template(version, messages, tools) + + # First tool (dict) should be properly JSON-encoded + self.assertIn( + '"city"', output, f"{version}: First tool should have city key" + ) + self.assertIn( + '"Boston"', + output, + f"{version}: First tool should have Boston value", + ) + + # Second tool (string) should be used as-is + self.assertIn( + '{"symbol": "TSLA"}', + output, + f"{version}: Second tool should use string as-is", + ) + + # Neither should be double-escaped + self.assertNotIn( + '\\"city\\"', + output, + f"{version}: First tool should not double-escape", + ) + self.assertNotIn( + '\\"symbol\\"', + output, + f"{version}: Second tool should not double-escape", + ) + + def test_tool_call_with_content(self): + """Test tool calls that also include content text.""" + # Some models include explanatory text along with tool calls + + for version in ["v3", "v3.1", "v3.2"]: + with self.subTest(version=version): + messages = [ + {"role": "user", "content": "What's the weather?"}, + { + "role": "assistant", + "content": "Let me check the weather for you.", + "tool_calls": [ + { + "type": "function", + "function": { + "name": "get_weather", + "arguments": {"city": "Seattle"}, + }, + } + ], + }, + ] + + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + }, + } + ] + + output = self._render_template(version, messages, tools) + + # Should contain both the content and the tool call + self.assertIn( + "Let me check the weather", + output, + f"{version}: Should include content", + ) + self.assertIn( + '"city"', output, f"{version}: Should include tool arguments" + ) + self.assertNotIn( + '\\"city\\"', output, f"{version}: Should not double-escape" + ) + + +if __name__ == "__main__": + unittest.main()