From 6f38e73b82a22493a4f796261774ca9c9d53d8ce Mon Sep 17 00:00:00 2001 From: zyk_computer <149297479+Caldalis@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:16:20 +0800 Subject: [PATCH] [data] add minicpm5 template with XML tool calling (#10801) Co-authored-by: Caldalis --- README.md | 2 +- README_zh.md | 2 +- src/llamafactory/data/template.py | 23 ++++++++ src/llamafactory/data/tool_utils.py | 78 ++++++++++++++++++++++++++++ src/llamafactory/extras/constants.py | 2 +- tests/data/test_formatter.py | 76 +++++++++++++++++++++++++++ 6 files changed, 180 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 753fdd49e..b551af8e4 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,7 @@ Read technical notes: | [LLaVA-NeXT](https://huggingface.co/llava-hf) | 7B/8B/13B/34B/72B/110B | llava_next | | [LLaVA-NeXT-Video](https://huggingface.co/llava-hf) | 7B/34B | llava_next_video | | [MiMo](https://huggingface.co/XiaomiMiMo) | 7B/309B | mimo/mimo_v2 | -| [MiniCPM 4/5](https://huggingface.co/openbmb) | 0.5B/1B/8B | cpm4/empty | +| [MiniCPM 4/5](https://huggingface.co/openbmb) | 0.5B/1B/8B | cpm4/minicpm5 | | [MiniCPM-o/MiniCPM-V 4.5](https://huggingface.co/openbmb) | 8B/9B | minicpm_o/minicpm_v | | [MiniCPM-V 4.6](https://huggingface.co/openbmb) | 3B/8B | minicpm_v_4_6 | | [MiniMax-M1/MiniMax-M2](https://huggingface.co/MiniMaxAI/models) | 229B/456B | minimax1/minimax2 | diff --git a/README_zh.md b/README_zh.md index 4fbe5e995..6f329e299 100644 --- a/README_zh.md +++ b/README_zh.md @@ -316,7 +316,7 @@ https://github.com/user-attachments/assets/43b700c6-a178-41db-b1f8-8190a5d3fcfc | [LLaVA-NeXT](https://huggingface.co/llava-hf) | 7B/8B/13B/34B/72B/110B | llava_next | | [LLaVA-NeXT-Video](https://huggingface.co/llava-hf) | 7B/34B | llava_next_video | | [MiMo](https://huggingface.co/XiaomiMiMo) | 7B/309B | mimo/mimo_v2 | -| [MiniCPM 4/5](https://huggingface.co/openbmb) | 0.5B/1B/8B | cpm4/empty | +| [MiniCPM 4/5](https://huggingface.co/openbmb) | 0.5B/1B/8B | cpm4/minicpm5 | | [MiniCPM-o/MiniCPM-V 4.5](https://huggingface.co/openbmb) | 8B/9B | minicpm_o/minicpm_v | | [MiniCPM-V 4.6](https://huggingface.co/openbmb) | 3B/8B | minicpm_v_4_6 | | [MiniMax-M1/MiniMax-M2](https://huggingface.co/MiniMaxAI/models) | 229B/456B | minimax1/minimax2 | diff --git a/src/llamafactory/data/template.py b/src/llamafactory/data/template.py index caa926abc..37952fc06 100644 --- a/src/llamafactory/data/template.py +++ b/src/llamafactory/data/template.py @@ -150,6 +150,12 @@ class Template: elements += self.format_prefix.apply() if system or tools: tool_text = self.format_tools.apply(content=tools)[0] if tools else "" + if tools and not system: + # Tool prompts that separate themselves from the system message with a + # leading newline would otherwise emit a blank line when there is no + # system message to separate from. + tool_text = tool_text.lstrip("\n") + elements += self.format_system.apply(content=(system + tool_text)) if message["role"] == Role.USER: @@ -1823,6 +1829,23 @@ register_template( ) +register_template( + name="minicpm5", + format_user=StringFormatter(slots=["<|im_start|>user\n{{content}}<|im_end|>\n<|im_start|>assistant\n"]), + format_assistant=StringFormatter(slots=["{{content}}<|im_end|>\n"]), + format_system=StringFormatter(slots=["<|im_start|>system\n{{content}}<|im_end|>\n"]), + format_function=FunctionFormatter(slots=["{{content}}<|im_end|>\n"], tool_format="minicpm5"), + format_observation=StringFormatter( + slots=["<|im_start|>user\n\n{{content}}\n<|im_end|>\n<|im_start|>assistant\n"] + ), + format_tools=ToolFormatter(tool_format="minicpm5"), + format_prefix=EmptyFormatter(slots=[{"bos_token"}]), + stop_words=["<|im_end|>"], + replace_eos=True, + template_class=ReasoningTemplate, +) + + register_template( name="minimax1", format_user=StringFormatter( diff --git a/src/llamafactory/data/tool_utils.py b/src/llamafactory/data/tool_utils.py index 69a13c574..773fd35d8 100644 --- a/src/llamafactory/data/tool_utils.py +++ b/src/llamafactory/data/tool_utils.py @@ -119,6 +119,17 @@ LING_TOOL_PROMPT = ( LFM2_TOOL_PROMPT = "List of tools: <|tool_list_start|>{tool_text}<|tool_list_end|>" +MINICPM5_TOOL_PROMPT = ( + "\n\n# Tools\n\nYou are provided with function signatures within XML tags:\n" + "{tool_text}\n\n\nTool usage guidelines:\n" + "- You may call zero or more functions. If no function calls are needed, just answer normally " + "and do not include any .\n" + "- When calling a function, return an XML object within using:\n" + 'param-value\n' + "- param-value may be multi-line. If it contains <, & or newline characters, wrap it in a " + 'CDATA block: ' +) + @dataclass class ToolUtils(ABC): @@ -551,6 +562,72 @@ class MiniMaxM2ToolUtils(ToolUtils): return results +class MiniCPM5ToolUtils(ToolUtils): + r"""MiniCPM-5 tool using template.""" + + @override + @staticmethod + def tool_formatter(tools: list[dict[str, Any]]) -> str: + tool_text = "" + for tool in tools: + if tool.get("type") != "function": + tool = {"type": "function", "function": tool} + + tool_text += "\n" + json.dumps(tool, ensure_ascii=False) + + return MINICPM5_TOOL_PROMPT.format(tool_text=tool_text) + + @override + @staticmethod + def function_formatter(functions: list["FunctionCall"]) -> str: + function_texts = [] + for name, arguments in functions: + prompt = f'' + for key, value in json.loads(arguments).items(): + prompt += f'' + if isinstance(value, str): + if "<" in value or "&" in value or "\n" in value: + prompt += f"" + else: + prompt += value + else: + prompt += str(value) + + prompt += "" + + prompt += "" + function_texts.append(prompt) + + return "\n".join(function_texts) + + @override + @staticmethod + def tool_extractor(content: str) -> Union[str, list["FunctionCall"]]: + results = [] + regex = re.compile(r'((?:|.)*?)', re.DOTALL) + for func_name, params_block in re.findall(regex, content): + args_dict = {} + param_pattern = re.compile(r'(|.*?)', re.DOTALL) + for key, raw_value in re.findall(param_pattern, params_block): + cdata = re.fullmatch(r"", raw_value, re.DOTALL) + if cdata: + args_dict[key] = cdata.group(1) + continue + + value = raw_value.strip() + try: + args_dict[key] = json.loads(value) + except json.JSONDecodeError: + try: + args_dict[key] = ast.literal_eval(value) + except Exception: + args_dict[key] = raw_value + + results.append(FunctionCall(func_name, json.dumps(args_dict, ensure_ascii=False))) + + return results if results else content + + class MistralToolUtils(ToolUtils): r"""Mistral v0.3 tool using template.""" @@ -887,6 +964,7 @@ TOOLS = { "glm4": GLM4ToolUtils(), "llama3": Llama3ToolUtils(), "lfm2": LFM2ToolUtils(), + "minicpm5": MiniCPM5ToolUtils(), "minimax1": MiniMaxM1ToolUtils(), "minimax2": MiniMaxM2ToolUtils(), "mistral": MistralToolUtils(), diff --git a/src/llamafactory/extras/constants.py b/src/llamafactory/extras/constants.py index 99662d482..e0167dc0b 100644 --- a/src/llamafactory/extras/constants.py +++ b/src/llamafactory/extras/constants.py @@ -1961,7 +1961,7 @@ register_model_group( DownloadSource.MODELSCOPE: "OpenBMB/MiniCPM5-1B", }, }, - template="empty", + template="minicpm5", ) diff --git a/tests/data/test_formatter.py b/tests/data/test_formatter.py index 3aaa6f991..b60621fca 100644 --- a/tests/data/test_formatter.py +++ b/tests/data/test_formatter.py @@ -380,3 +380,79 @@ def test_lfm2_tool_round_trip(): assert len(extracted) == 1 assert extracted[0][0] == original["name"] assert json.loads(extracted[0][1]) == original["arguments"] + + +@pytest.mark.runs_on(["cpu", "mps"]) +def test_minicpm5_function_formatter(): + formatter = FunctionFormatter(slots=["{{content}}<|im_end|>\n"], tool_format="minicpm5") + tool_calls = json.dumps(FUNCTION) + assert formatter.apply(content=tool_calls) == [ + 'bar10<|im_end|>\n' + ] + + +@pytest.mark.runs_on(["cpu", "mps"]) +def test_minicpm5_multi_function_formatter(): + formatter = FunctionFormatter(slots=["{{content}}<|im_end|>\n"], tool_format="minicpm5") + tool_calls = json.dumps([FUNCTION] * 2) + assert formatter.apply(content=tool_calls) == [ + 'bar' + '10\n' + 'bar' + '10<|im_end|>\n' + ] + + +@pytest.mark.runs_on(["cpu", "mps"]) +def test_minicpm5_tool_formatter(): + formatter = ToolFormatter(tool_format="minicpm5") + wrapped = json.dumps({"type": "function", "function": TOOLS[0]}, ensure_ascii=False) + assert formatter.apply(content=json.dumps(TOOLS)) == [ + "\n\n# Tools\n\nYou are provided with function signatures within XML tags:\n" + f"\n{wrapped}\n\n\nTool usage guidelines:\n" + "- You may call zero or more functions. If no function calls are needed, just answer " + "normally and do not include any .\n" + "- When calling a function, return an XML object within using:\n" + 'param-value\n' + "- param-value may be multi-line. If it contains <, & or newline characters, wrap it in a " + 'CDATA block: ' + ] + + +@pytest.mark.runs_on(["cpu", "mps"]) +def test_minicpm5_tool_extractor(): + formatter = ToolFormatter(tool_format="minicpm5") + result = 'bar10' + assert formatter.extract(result) == [("test_tool", """{"foo": "bar", "size": 10}""")] + + +@pytest.mark.runs_on(["cpu", "mps"]) +def test_minicpm5_tool_extractor_cdata(): + formatter = ToolFormatter(tool_format="minicpm5") + result = '' + assert formatter.extract(result) == [("test_tool", json.dumps({"foo": "a < b\nsecond line"}))] + + +@pytest.mark.runs_on(["cpu", "mps"]) +def test_minicpm5_tool_extractor_malformed_value(): + formatter = ToolFormatter(tool_format="minicpm5") + result = '{[1, 2], [3, 4]}' + assert formatter.extract(result) == [("test_tool", json.dumps({"foo": "{[1, 2], [3, 4]}"}))] + + +@pytest.mark.runs_on(["cpu", "mps"]) +@pytest.mark.parametrize( + "arguments", + [ + {"foo": "x y"}, + {"foo": "see here"}, + {"foo": " padded "}, + {"foo": "a < b", "bar": "tom & jerry"}, + {"foo": True, "bar": [1, 2], "baz": {"k": "v"}}, + ], +) +def test_minicpm5_tool_round_trip(arguments): + formatter = ToolFormatter(tool_format="minicpm5") + function_formatter = FunctionFormatter(slots=["{{content}}"], tool_format="minicpm5") + rendered = function_formatter.apply(content=json.dumps({"name": "test_tool", "arguments": arguments}))[0] + assert formatter.extract(rendered) == [("test_tool", json.dumps(arguments, ensure_ascii=False))]