Skip to content

Commit c818263

Browse files
aldehirzhangtao
andauthored
chat : implement minicpm5 parser (ggml-org#24889)
* Add minicpm5 tool call parser * Refactor MiniCPM5 PEG parser per review feedback * Fix jinja min/max API to match Jinja2 * modify by review * MiniCPM5: use autoparser for XML tool calls and fix grammar preserved-token triggers * MiniCPM5: fix streaming tool-arg placeholder and remove alt XML markers * skip min/max attribute tests in -py mode * test-jinja: use real expected output for min/max attribute tests * MiniCPM5: revert shared mapper and history fallbacks per review Drop streaming tool-arg placeholder workarounds from the generic PEG mapper and restore strict tool-call argument JSON parsing so MiniCPM5 support stays limited to autoparser/diff-analyzer changes. * chat : refactor minicpm5 back to dedicated parser * cont : simplify grammar * cont : refactor * cont : fixes * cont : rename template to openbmb-MiniCPM5-1B.jinja * cont : add message delimiters * cont : fix tests --------- Co-authored-by: zhangtao <zhangtao2@modelbest.cn> Co-authored-by: 张涛 <>
1 parent f68a788 commit c818263

5 files changed

Lines changed: 482 additions & 0 deletions

File tree

common/chat.cpp

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2376,6 +2376,149 @@ static void func_args_not_string(json & messages) {
23762376

23772377
}
23782378

2379+
// MiniCPM5 format:
2380+
// - Reasoning: <think>{reasoning}</think> (optional)
2381+
// - Tool calls: <function name="foo"><param name="bar">value</param></function>
2382+
static common_chat_params common_chat_params_init_minicpm5(const common_chat_template & tmpl,
2383+
const autoparser::generation_params & inputs) {
2384+
common_chat_params data;
2385+
2386+
data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs);
2387+
data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, inputs);
2388+
data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
2389+
data.supports_thinking = true;
2390+
data.preserved_tokens = {
2391+
"<function",
2392+
"<param",
2393+
"</function>",
2394+
"</param>",
2395+
"<think>",
2396+
"</think>",
2397+
};
2398+
2399+
data.thinking_start_tag = "<think>";
2400+
data.thinking_end_tag = "</think>";
2401+
2402+
data.message_delimiters = {
2403+
{ COMMON_CHAT_ROLE_ASSISTANT, "<|im_start|>assistant" },
2404+
{ COMMON_CHAT_ROLE_TOOL, "<|im_start|>user\n<tool_response>" },
2405+
{ COMMON_CHAT_ROLE_USER, "<|im_start|>user" },
2406+
{ COMMON_CHAT_ROLE_SYSTEM, "<|im_start|>system" },
2407+
};
2408+
2409+
auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
2410+
auto has_response_format = inputs.json_schema.is_object() && !inputs.json_schema.empty();
2411+
auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;
2412+
auto include_grammar = has_response_format || (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE);
2413+
2414+
if (inputs.has_continuation()) {
2415+
const auto & msg = inputs.continue_msg;
2416+
2417+
data.generation_prompt = "<|im_start|>assistant\n<think>\n" + msg.reasoning_content;
2418+
if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_CONTENT) {
2419+
data.generation_prompt += "\n</think>\n\n" + msg.render_content();
2420+
}
2421+
2422+
data.prompt += data.generation_prompt;
2423+
}
2424+
2425+
auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
2426+
auto generation_prompt = p.literal("<|im_start|>assistant\n");
2427+
2428+
auto reasoning = p.eps();
2429+
if (extract_reasoning) {
2430+
reasoning = ("<think>" << p.reasoning(p.until("</think>")) << "</think>") + p.space();
2431+
}
2432+
2433+
// Response format parser
2434+
if (has_response_format) {
2435+
return generation_prompt + reasoning + p.content(p.schema(p.json(), "response-format", inputs.json_schema));
2436+
}
2437+
2438+
if (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE) {
2439+
// CDATA lets a value carry characters that would otherwise close the tag (e.g.
2440+
// </param>); capture the inner text only, excluding the CDATA markers.
2441+
auto string_value = p.choice({
2442+
p.literal("<![CDATA[") + p.ac(p.tool_arg_string_value(p.until("]]>")) + p.literal("]]>"), "]]>") + p.tool_arg_close(p.literal("</param>")),
2443+
p.negate(p.literal("<![CDATA[")) + p.ac(p.tool_arg_string_value(p.until("</param>")) + p.tool_arg_close(p.literal("</param>")), "</param>")
2444+
});
2445+
2446+
auto tool_choice = p.choice();
2447+
foreach_function(inputs.tools, [&](const json & tool) {
2448+
const auto & function = tool.at("function");
2449+
const std::string name = function.at("name");
2450+
auto params = function.contains("parameters") ? function.at("parameters") : json::object();
2451+
2452+
auto args = p.eps();
2453+
if (params.contains("properties") && params.at("properties").is_object() && !params.at("properties").empty()) {
2454+
auto schema_info = common_schema_info();
2455+
schema_info.resolve_refs(params);
2456+
2457+
auto arg_choice = p.choice();
2458+
for (const auto & [prop_name, prop_schema] : params.at("properties").items()) {
2459+
auto value_parser = p.eps();
2460+
if (schema_info.resolves_to_string(prop_schema)) {
2461+
value_parser = string_value;
2462+
} else {
2463+
value_parser = p.tool_arg_json_value(
2464+
p.schema(p.json(), "tool-" + name + "-arg-" + prop_name + "-schema", prop_schema, false)
2465+
) + p.tool_arg_close(p.literal("</param>"));
2466+
}
2467+
2468+
auto arg_rule = p.tool_arg(
2469+
p.tool_arg_open(p.literal("<param name=\"") + p.tool_arg_name(p.literal(prop_name)) + p.literal("\">")) +
2470+
value_parser
2471+
);
2472+
2473+
arg_choice |= arg_rule;
2474+
}
2475+
args = p.zero_or_more(arg_choice + p.space());
2476+
}
2477+
2478+
auto tool_parser = p.tool(
2479+
p.tool_open(p.literal("<function name=\"") + p.tool_name(p.literal(name)) + p.literal("\">"))
2480+
<< p.tool_args(args)
2481+
<< p.tool_close(p.literal("</function>")));
2482+
2483+
tool_choice |= p.rule("tool-" + name, tool_parser);
2484+
});
2485+
2486+
auto max_calls = inputs.parallel_tool_calls ? -1 : 1;
2487+
auto tool_calls = p.trigger_rule("tool-call", p.repeat(tool_choice + p.space(), 1, max_calls));
2488+
2489+
auto content = p.content(p.until("<function"));
2490+
2491+
return generation_prompt + reasoning + content + tool_calls + p.end();
2492+
}
2493+
2494+
return generation_prompt + reasoning + p.content(p.rest()) + p.end();
2495+
});
2496+
2497+
data.parser = parser.save();
2498+
2499+
if (include_grammar) {
2500+
data.grammar_lazy = !(has_response_format || (has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED));
2501+
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
2502+
foreach_function(inputs.tools, [&](const json & tool) {
2503+
const auto & function = tool.at("function");
2504+
auto schema = function.contains("parameters") ? function.at("parameters") : json::object();
2505+
builder.resolve_refs(schema);
2506+
});
2507+
if (has_response_format) {
2508+
auto schema = inputs.json_schema;
2509+
builder.resolve_refs(schema);
2510+
}
2511+
parser.build_grammar(builder, data.grammar_lazy);
2512+
});
2513+
2514+
data.grammar_triggers = {
2515+
{ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, "<function" },
2516+
};
2517+
}
2518+
2519+
return data;
2520+
}
2521+
23792522
static json common_chat_extra_context() {
23802523
json ctx = json::object();
23812524
std::chrono::system_clock::time_point now = std::chrono::system_clock::now();
@@ -2468,6 +2611,14 @@ std::optional<common_chat_params> common_chat_try_specialized_template(
24682611
return common_chat_params_init_gemma4(tmpl, params);
24692612
}
24702613

2614+
// MiniCPM5 - XML tool calls with <function name="..."><param name="...">...</param></function>
2615+
if (src.find("Tool usage guidelines:") != std::string::npos &&
2616+
src.find("<function name=\"") != std::string::npos &&
2617+
src.find("<param name=\"") != std::string::npos) {
2618+
LOG_DBG("Using specialized template: MiniCPM5\n");
2619+
return common_chat_params_init_minicpm5(tmpl, params);
2620+
}
2621+
24712622
return std::nullopt;
24722623
}
24732624

common/jinja/value.cpp

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,6 +1108,50 @@ const func_builtins & value_array_t::get_builtins() const {
11081108
std::reverse(arr.begin(), arr.end());
11091109
return is_val<value_tuple>(val) ? mk_val<value_tuple>(std::move(arr)) : mk_val<value_array>(std::move(arr));
11101110
}},
1111+
{"min", [](const func_args & args) -> value {
1112+
args.ensure_count(1, 4);
1113+
args.ensure_vals<value_array>();
1114+
value val_case = args.get_kwarg_or_pos("case_sensitive", 1);
1115+
value attribute = args.get_kwarg_or_pos("attribute", 2);
1116+
if (!attribute->is_undefined()) {
1117+
throw not_implemented_exception("min: attribute not implemented");
1118+
}
1119+
// FIXME: min is currently always case sensitive
1120+
(void) val_case;
1121+
const auto & arr = args.get_pos(0)->as_array();
1122+
if (arr.empty()) {
1123+
return mk_val<value_undefined>();
1124+
}
1125+
value result = arr[0];
1126+
for (size_t i = 1; i < arr.size(); ++i) {
1127+
if (value_compare(arr[i], result, value_compare_op::lt)) {
1128+
result = arr[i];
1129+
}
1130+
}
1131+
return result;
1132+
}},
1133+
{"max", [](const func_args & args) -> value {
1134+
args.ensure_count(1, 4);
1135+
args.ensure_vals<value_array>();
1136+
value val_case = args.get_kwarg_or_pos("case_sensitive", 1);
1137+
value attribute = args.get_kwarg_or_pos("attribute", 2);
1138+
if (!attribute->is_undefined()) {
1139+
throw not_implemented_exception("max: attribute not implemented");
1140+
}
1141+
// FIXME: max is currently always case sensitive
1142+
(void) val_case;
1143+
const auto & arr = args.get_pos(0)->as_array();
1144+
if (arr.empty()) {
1145+
return mk_val<value_undefined>();
1146+
}
1147+
value result = arr[0];
1148+
for (size_t i = 1; i < arr.size(); ++i) {
1149+
if (value_compare(arr[i], result, value_compare_op::gt)) {
1150+
result = arr[i];
1151+
}
1152+
}
1153+
return result;
1154+
}},
11111155
{"unique", array_unique_not_implemented},
11121156
};
11131157
return builtins;
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
{{- bos_token }}{%- if tools %}
2+
{%- set tool_definitions %}
3+
{{- "# Tools\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
4+
{%- for tool in tools %}
5+
{{- "\n" }}
6+
{{- tool | tojson(ensure_ascii=False) }}
7+
{%- endfor %}
8+
{{- '\n</tools>\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 <function ... </function>.\n- When calling a function, return an XML object within <function ... </function> using:\n<function name="function-name"><param name="param-name">param-value</param></function>\n- param-value may be multi-line. If it contains <, & or newline characters, wrap it in a CDATA block: <param name="param-name"><![CDATA[...multi-line value...]]></param>' }}
9+
{%- endset %}
10+
11+
{{- '<|im_start|>system\n' }}
12+
{%- if messages[0].role == 'system' %}
13+
{%- if '<tool_def_sep>' in messages[0].content %}
14+
{{- messages[0].content.replace('<tool_def_sep>', tool_definitions) }}
15+
{%- else %}
16+
{{- messages[0].content + '\n\n' + tool_definitions }}
17+
{%- endif %}
18+
{%- else %}
19+
{{- tool_definitions.lstrip() }}
20+
{%- endif %}
21+
{{- '<|im_end|>\n' }}
22+
{%- else %}
23+
{%- if messages[0].role == 'system' %}
24+
{{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
25+
{%- endif %}
26+
{%- endif %}
27+
{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
28+
{%- for message in messages[::-1] %}
29+
{%- set index = (messages|length - 1) - loop.index0 %}
30+
{%- if ns.multi_step_tool and message.role == "user" and message.content is string and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}
31+
{%- set ns.multi_step_tool = false %}
32+
{%- set ns.last_query_index = index %}
33+
{%- endif %}
34+
{%- endfor %}
35+
{%- for message in messages %}
36+
{%- if message.content is string %}
37+
{%- set content = message.content %}
38+
{%- else %}
39+
{%- set content = '' %}
40+
{%- endif %}
41+
{%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
42+
{{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
43+
{%- elif message.role == "assistant" %}
44+
{%- set reasoning_content = '' %}
45+
{%- if message.reasoning_content is string %}
46+
{%- set reasoning_content = message.reasoning_content %}
47+
{%- else %}
48+
{%- if '</think>' in content %}
49+
{%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
50+
{%- set content = content.split('</think>')[-1].lstrip('\n') %}
51+
{%- endif %}
52+
{%- endif %}
53+
54+
{%- if message.tool_calls %}
55+
{%- set content_parts = content.split('<tool_sep>') %}
56+
{%- set processed_content = content_parts[0] %}
57+
{%- set tool_calls_count = message.tool_calls|length %}
58+
{%- set tool_sep_count = content_parts|length - 1 %}
59+
{%- set min_count = [tool_calls_count, tool_sep_count]|min %}
60+
61+
{%- for i in range(1, content_parts|length) %}
62+
{%- set tool_index = i - 1 %}
63+
{%- if tool_index < tool_calls_count %}
64+
{%- set tool_call = message.tool_calls[tool_index] %}
65+
{%- if tool_call.function %}
66+
{%- set tool_call = tool_call.function %}
67+
{%- endif %}
68+
{%- set single_tool_xml %}
69+
{{- '<function name="' ~ tool_call.name ~ '">' }}
70+
{%- if tool_call.arguments %}
71+
{%- set args_dict = tool_call.arguments %}
72+
{%- for param_name, param_value in args_dict.items() %}
73+
{{- '<param name="' ~ param_name ~ '">' }}
74+
{%- if param_value is string and ('<' in param_value or '&' in param_value or '\n' in param_value) %}
75+
{{- '<![CDATA[' + param_value + ']]>' }}
76+
{%- else %}
77+
{{- param_value }}
78+
{%- endif %}
79+
{{- '</param>' }}
80+
{%- endfor %}
81+
{%- endif %}
82+
{{- '</function>' }}
83+
{%- endset %}
84+
{%- set processed_content = processed_content + single_tool_xml + content_parts[i] %}
85+
{%- else %}
86+
{%- set processed_content = processed_content + content_parts[i] %}
87+
{%- endif %}
88+
{%- endfor %}
89+
90+
{%- if tool_calls_count > tool_sep_count %}
91+
{%- for remaining_index in range(tool_sep_count, tool_calls_count) %}
92+
{%- set tool_call = message.tool_calls[remaining_index] %}
93+
{%- if tool_call.function %}
94+
{%- set tool_call = tool_call.function %}
95+
{%- endif %}
96+
{%- set remaining_tool_xml %}
97+
{{- '<function name="' ~ tool_call.name ~ '">' }}
98+
{%- if tool_call.arguments %}
99+
{%- set args_dict = tool_call.arguments %}
100+
{%- for param_name, param_value in args_dict.items() %}
101+
{{- '<param name="' ~ param_name ~ '">' }}
102+
{%- if param_value is string and ('<' in param_value or '&' in param_value or '\n' in param_value) %}
103+
{{- '<![CDATA[' + param_value + ']]>' }}
104+
{%- else %}
105+
{{- param_value }}
106+
{%- endif %}
107+
{{- '</param>' }}
108+
{%- endfor %}
109+
{%- endif %}
110+
{{- '</function>' }}
111+
{%- endset %}
112+
{%- set processed_content = processed_content + remaining_tool_xml %}
113+
{%- endfor %}
114+
{%- endif %}
115+
116+
{%- set content = processed_content %}
117+
{%- endif %}
118+
119+
{%- if loop.index0 > ns.last_query_index %}
120+
{%- if reasoning_content %}
121+
{{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
122+
{%- else %}
123+
{{- '<|im_start|>' + message.role + '\n' + content }}
124+
{%- endif %}
125+
{%- else %}
126+
{{- '<|im_start|>' + message.role + '\n' + content }}
127+
{%- endif %}
128+
129+
{%- if message.tool_calls and not has_tool_sep %}
130+
{%- for tool_call in message.tool_calls %}
131+
{%- if (loop.first and content) or (not loop.first) %}
132+
{{- '\n' }}
133+
{%- endif %}
134+
{%- if tool_call.function %}
135+
{%- set tool_call = tool_call.function %}
136+
{%- endif %}
137+
{{- '<function name="' ~ tool_call.name ~ '">' }}
138+
{%- if tool_call.arguments %}
139+
{%- set args_dict = tool_call.arguments %}
140+
{%- for param_name, param_value in args_dict.items() %}
141+
{{- '<param name="' ~ param_name ~ '">' }}
142+
{%- if param_value is string and ('<' in param_value or '&' in param_value or '\n' in param_value) %}
143+
{{- '<![CDATA[' + param_value + ']]>' }}
144+
{%- else %}
145+
{{- param_value }}
146+
{%- endif %}
147+
{{- '</param>' }}
148+
{%- endfor %}
149+
{%- endif %}
150+
{{- '</function>' }}
151+
{%- endfor %}
152+
{%- endif %}
153+
{{- '<|im_end|>\n' }}
154+
{%- elif message.role == "tool" %}
155+
{%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
156+
{{- '<|im_start|>user' }}
157+
{%- endif %}
158+
{{- '\n<tool_response>\n' }}
159+
{%- if message.content is string %}
160+
{{- content }}
161+
{%- else %}
162+
{{- message.content | tojson(ensure_ascii=False) }}
163+
{%- endif %}
164+
{{- '\n</tool_response>' }}
165+
{%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
166+
{{- '<|im_end|>\n' }}
167+
{%- endif %}
168+
{%- endif %}
169+
{%- endfor %}
170+
{%- if add_generation_prompt %}
171+
{{- '<|im_start|>assistant\n' }}
172+
{%- if enable_thinking is defined %}
173+
{%- if enable_thinking is false %}
174+
{{- '<think>\n\n</think>\n\n' }}
175+
{%- elif enable_thinking is true %}
176+
{{- '<think>\n' }}
177+
{%- endif %}
178+
{%- endif %}
179+
{%- endif %}

0 commit comments

Comments
 (0)