diff --git a/mlx_lm/tokenizer_utils.py b/mlx_lm/tokenizer_utils.py index a9e9412ae..4c57319cc 100644 --- a/mlx_lm/tokenizer_utils.py +++ b/mlx_lm/tokenizer_utils.py @@ -642,6 +642,8 @@ def _infer_tool_parser(tokenizer): return "qwen3_coder" elif "<|tool_calls_section_begin|>" in chat_template: return "kimi_k2" + elif '" in chat_template and "tool_call.name" in chat_template: diff --git a/mlx_lm/tool_parsers/minicpm5.py b/mlx_lm/tool_parsers/minicpm5.py new file mode 100644 index 000000000..21e3151c9 --- /dev/null +++ b/mlx_lm/tool_parsers/minicpm5.py @@ -0,0 +1,74 @@ +# Copyright © 2026 Apple Inc. + +""" +Tool call parser for OpenBMB MiniCPM5. + +The chat template asks the model for +``value`` with no +outer wrapper. Values containing ``<``, ``&`` or newlines are wrapped in a +CDATA block, and parallel calls are consecutive ```` blocks. +""" + +from typing import Any, Optional + +import regex as re + +from .minimax_m2 import ( + _convert_param_value_with_types, + _extract_name, + _get_param_types_from_config, +) + +tool_call_start = "" + +# The state machine strips both markers, so the server hands the parser +# ``"fn">...``; the leading ``\"[^\"]*\"|'[^']*'|[^\s\"'<>]+)\s*>" + r"(?P.*?)(?:|$)", + re.DOTALL, +) +_param_regex = re.compile( + r"\"[^\"]*\"|'[^']*'|[^\s\"'<>]+)\s*>" + r"(?P.*?)", + re.DOTALL, +) +_cdata_regex = re.compile(r"^\s*\s*$", re.DOTALL) + + +def _param_value(raw: str) -> str: + if (match := _cdata_regex.match(raw)) is not None: + return match.group(1) + return raw.strip() + + +def parse_tool_call(model_output: str, tools: Optional[Any] = None): + function_matches = list(_function_regex.finditer(model_output)) + if not function_matches: + raise ValueError("No function provided.") + + param_config_for = {} + for tool in tools or []: + if function := tool.get("function", False): + if params := function.get("parameters", False): + param_config_for[function["name"]] = params.get("properties", {}) + + calls = [] + for function_match in function_matches: + function_name = _extract_name(function_match.group("name")) + param_config = param_config_for.get(function_name, {}) + arguments = {} + for param_match in _param_regex.finditer(function_match.group("body")): + param_name = _extract_name(param_match.group("name")) + arguments[param_name] = _convert_param_value_with_types( + _param_value(param_match.group("value")), + _get_param_types_from_config(param_name, param_config), + ) + calls.append(dict(name=function_name, arguments=arguments)) + + if len(calls) == 1: + return calls[0] + return calls diff --git a/tests/test_tool_parsing.py b/tests/test_tool_parsing.py index 35f71aa64..903ce621c 100644 --- a/tests/test_tool_parsing.py +++ b/tests/test_tool_parsing.py @@ -10,6 +10,7 @@ kimi_k2, kimi_k3, longcat, + minicpm5, minimax_m2, mistral, pythonic, @@ -39,6 +40,10 @@ def test_parsers(self): '\n12234585\n48838483920\n', minimax_m2, ), + ( + '1223458548838483920', + minicpm5, + ), ( "\n\n12234585\n\n\n48838483920\n\n", qwen3_coder, @@ -109,6 +114,10 @@ def test_parsers(self): '\nLondon\n', minimax_m2, ), + ( + 'London', + minicpm5, + ), ( "\n\nLondon\n\n", qwen3_coder, @@ -577,6 +586,118 @@ def test_kimi_k3(self): '<|open|>call index="1"<|sep|><|close|>call<|sep|>', None ) + def test_minicpm5(self): + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"}, + "days": {"type": "integer"}, + "metric": {"type": "boolean"}, + }, + }, + }, + }, + { + "type": "function", + "function": { + "name": "write_file", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "content": {"type": "string"}, + }, + }, + }, + }, + ] + + # What the server passes: both markers stripped by the state machine. + tool_call = minicpm5.parse_tool_call( + '"get_weather">Paris' + '3true', + tools, + ) + self.assertEqual( + tool_call, + { + "name": "get_weather", + "arguments": {"city": "Paris", "days": 3, "metric": True}, + }, + ) + + # Single quotes and whitespace around the tags. + tool_call = minicpm5.parse_tool_call( + "\n Paris \n", + tools, + ) + self.assertEqual( + tool_call, {"name": "get_weather", "arguments": {"city": "Paris"}} + ) + + # CDATA keeps the value verbatim, including newlines and markup. + tool_call = minicpm5.parse_tool_call( + 'a.txt' + '&\n]]>', + tools, + ) + self.assertEqual( + tool_call, + { + "name": "write_file", + "arguments": {"path": "a.txt", "content": "line 1\n&\n"}, + }, + ) + + # No schema: values stay strings. + tool_call = minicpm5.parse_tool_call( + '3' + ) + self.assertEqual(tool_call, {"name": "get_weather", "arguments": {"days": "3"}}) + + # Parallel calls are consecutive blocks. + tool_calls = minicpm5.parse_tool_call( + 'Tokyo\n' + 'b.txt', + tools, + ) + self.assertEqual( + tool_calls, + [ + {"name": "get_weather", "arguments": {"city": "Tokyo"}}, + {"name": "write_file", "arguments": {"path": "b.txt"}}, + ], + ) + + # Truncated by max_tokens: no closing tag, the complete params survive. + tool_call = minicpm5.parse_tool_call( + '"get_weather">Paris3', + tools, + ) + self.assertEqual( + tool_call, {"name": "get_weather", "arguments": {"city": "Paris"}} + ) + + # No parameters, an empty value, and dotted/hyphenated names. + self.assertEqual( + minicpm5.parse_tool_call(''), + {"name": "get_weather", "arguments": {}}, + ) + self.assertEqual( + minicpm5.parse_tool_call( + '' + ), + {"name": "fs.read-file", "arguments": {"path": ""}}, + ) + + with self.assertRaises(ValueError): + minicpm5.parse_tool_call("no call here", tools) + def test_minimax_m2(self): test_case = ( '\n'