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