Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions mlx_lm/tokenizer_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,8 @@ def _infer_tool_parser(chat_template):
"""Attempt to auto-infer a tool parser from the chat template."""
if not isinstance(chat_template, str):
return None
elif "<|DSML|tool_calls>" in chat_template:
return "deepseek_dsml"
elif "<minimax:tool_call>" in chat_template:
return "minimax_m2"
elif "<|tool_call>" in chat_template and "<tool_call|>" in chat_template:
Expand Down
79 changes: 79 additions & 0 deletions mlx_lm/tool_parsers/deepseek_dsml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Tool parser for DeepSeek-V4's native DSML tool-call format. Deploy into
# venv: mlx_lm/tool_parsers/deepseek_dsml.py, set tokenizer_config
# "tool_parser_type": "deepseek_dsml", and use the official DSML chat template.
#
# Format (multiple invokes per tool_calls block => native parallel calls):
# <|DSML|tool_calls>
# <|DSML|invoke name="get_weather">
# <|DSML|parameter name="city" string="true">Paris</|DSML|parameter>
# <|DSML|parameter name="days" string="false">3</|DSML|parameter>
# </|DSML|invoke>
# ...
# </|DSML|tool_calls>
#
# string="true" -> value is a literal string
# string="false" -> value is JSON (numbers, booleans, arrays, objects)
#
# Start/end markers drop the trailing ">": mlx-lm matches markers by token-id
# sequence and BPE merges ">" with the next byte ("...calls>\n" -> one token),
# so the precomputed full marker never matches. "<|DSML|tool_calls" (the
# <,|DSML|,tool,_c,alls tokens) is stable; the parser tolerates the leftover ">".
import json

import regex as re

tool_call_start = "<|DSML|tool_calls"
tool_call_end = "</|DSML|tool_calls"

_INVOKE = re.compile(r"<|DSML|invoke\s+name=(.*?)</|DSML|invoke>", re.DOTALL)
_PARAM = re.compile(
r'<|DSML|parameter\s+name=(?P<name>"[^"]*"|\'[^\']*\'|[^\s>]+)'
r'\s+string="(?P<is_str>true|false)"\s*>(?P<val>.*?)</|DSML|parameter>',
re.DOTALL,
)
_NAME_BODY = re.compile(r'\s*(?P<name>"[^"]*"|\'[^\']*\'|[^\s>]+)\s*>(?P<body>.*)', re.DOTALL)


def _unquote(s):
s = s.strip()
if len(s) >= 2 and s[0] in "\"'" and s[-1] == s[0]:
return s[1:-1]
return s


def _strip_one_newline(v):
if v.startswith("\n"):
v = v[1:]
if v.endswith("\n"):
v = v[:-1]
return v


def parse_tool_call(text, tools=None):
calls = []
for invoke in _INVOKE.findall(text):
m = _NAME_BODY.match(invoke)
if not m:
continue
name = _unquote(m.group("name"))
args = {}
for pm in _PARAM.finditer(m.group("body")):
pname = _unquote(pm.group("name"))
val = _strip_one_newline(pm.group("val"))
if pm.group("is_str") == "true":
args[pname] = val
else:
try:
args[pname] = json.loads(val)
except (json.JSONDecodeError, ValueError):
args[pname] = val
# template fallback: whole arg blob passed as a single "arguments" param
if set(args) == {"arguments"} and isinstance(args["arguments"], str):
try:
args = json.loads(args["arguments"])
except (json.JSONDecodeError, ValueError):
pass
calls.append({"name": name, "arguments": args})
if not calls:
raise ValueError("no DSML tool call found")
return calls[0] if len(calls) == 1 else calls
39 changes: 39 additions & 0 deletions tests/test_tool_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from pathlib import Path

from mlx_lm.tool_parsers import (
deepseek_dsml,
function_gemma,
gemma4,
glm47,
Expand Down Expand Up @@ -33,6 +34,10 @@ def test_parsers(self):
'{"name": "multiply", "arguments": {"a": 12234585, "b": 48838483920}}',
json_tools,
),
(
'>\n<|DSML|invoke name="multiply">\n<|DSML|parameter name="a" string="false">12234585</|DSML|parameter>\n<|DSML|parameter name="b" string="false">48838483920</|DSML|parameter>\n</|DSML|invoke>\n',
deepseek_dsml,
),
(
'<invoke name="multiply">\n<parameter name="a">12234585</parameter>\n<parameter name="b">48838483920</parameter>\n</invoke>',
minimax_m2,
Expand Down Expand Up @@ -103,6 +108,10 @@ def test_parsers(self):
'{"name": "get_current_temperature", "arguments": {"location": "London"}}',
json_tools,
),
(
'>\n<|DSML|invoke name="get_current_temperature">\n<|DSML|parameter name="location" string="true">London</|DSML|parameter>\n</|DSML|invoke>\n',
deepseek_dsml,
),
(
'<invoke name="get_current_temperature">\n<parameter name="location">London</parameter>\n</invoke>',
minimax_m2,
Expand Down Expand Up @@ -329,6 +338,36 @@ def test_minimax_m2(self):
tool_calls = minimax_m2.parse_tool_call(test_case, None)
self.assertEqual(expected, tool_calls)

def test_deepseek_dsml_parallel(self):
# DSML puts multiple <|DSML|invoke> in one <|DSML|tool_calls> block = native
# parallel calls. The leading ">" is the leftover the server captures after the
# "<|DSML|tool_calls" prefix start marker (which drops the ">" that BPE merges).
test_case = (
">\n"
'<|DSML|invoke name="get_weather">\n'
'<|DSML|parameter name="city" string="true">Tokyo</|DSML|parameter>\n'
"</|DSML|invoke>\n"
'<|DSML|invoke name="get_weather">\n'
'<|DSML|parameter name="city" string="true">Paris</|DSML|parameter>\n'
"</|DSML|invoke>\n"
)
expected = [
{"name": "get_weather", "arguments": {"city": "Tokyo"}},
{"name": "get_weather", "arguments": {"city": "Paris"}},
]
self.assertEqual(deepseek_dsml.parse_tool_call(test_case, None), expected)

# string="true" -> literal string; string="false" -> JSON-decoded value
test_case = (
'<|DSML|invoke name="calc">\n'
'<|DSML|parameter name="expr" string="true">2+2</|DSML|parameter>\n'
'<|DSML|parameter name="n" string="false">3</|DSML|parameter>\n'
'<|DSML|parameter name="tags" string="false">["a", "b"]</|DSML|parameter>\n'
"</|DSML|invoke>"
)
tc = deepseek_dsml.parse_tool_call(test_case, None)
self.assertEqual(tc["arguments"], {"expr": "2+2", "n": 3, "tags": ["a", "b"]})


if __name__ == "__main__":
unittest.main()