Skip to content

Commit c7a88e3

Browse files
committed
Squashed commit of the following (ggml-org#16946):
commit 23d4bb7 Author: Piotr Wilkin <[email protected]> Date: Tue Nov 4 19:07:49 2025 +0100 Add proper handling of optional parameters with test commit 9481289 Author: Piotr Wilkin <[email protected]> Date: Sun Nov 2 19:30:35 2025 +0100 Whitespace. commit 1a351a0 Author: Piotr Wilkin <[email protected]> Date: Sun Nov 2 17:34:47 2025 +0100 Use Unsloth template, add extra test parameters for ignoring additional whitespace commit de67255 Author: Piotr Wilkin <[email protected]> Date: Sat Nov 1 22:33:40 2025 +0100 On the other hand, this is probably safer commit 4e58382 Author: Piotr Wilkin <[email protected]> Date: Sat Nov 1 22:32:20 2025 +0100 No newline after <think> commit e21f87e Author: Piotr Wilkin <[email protected]> Date: Sat Nov 1 22:19:48 2025 +0100 Minimax M2 chat template support
1 parent 9fdff61 commit c7a88e3

File tree

4 files changed

+483
-6
lines changed

4 files changed

+483
-6
lines changed

common/chat.cpp

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,7 @@ const char * common_chat_format_name(common_chat_format format) {
649649
case COMMON_CHAT_FORMAT_APERTUS: return "Apertus";
650650
case COMMON_CHAT_FORMAT_LFM2_WITH_JSON_TOOLS: return "LFM2 with JSON tools";
651651
case COMMON_CHAT_FORMAT_QWEN3_CODER_XML: return "Qwen3 Coder XML";
652+
case COMMON_CHAT_FORMAT_MINIMAX_M2: return "MiniMax M2";
652653
default:
653654
throw std::runtime_error("Unknown chat format");
654655
}
@@ -796,6 +797,22 @@ static void foreach_function(const json & tools, const std::function<void(const
796797
}
797798
}
798799

800+
static std::set<std::string> get_required_parameters(const json & params) {
801+
std::set<std::string> retval;
802+
if (!params.empty()) {
803+
for (const auto& element : params.array()) {
804+
if (element.is_string()) {
805+
retval.emplace(element.get<std::string>());
806+
}
807+
}
808+
}
809+
return retval;
810+
}
811+
812+
static std::string gr_optional(std::string rule) {
813+
return "( " + rule + " )?";
814+
}
815+
799816
static std::string apply(
800817
const common_chat_template & tmpl,
801818
const struct templates_params & inputs,
@@ -3367,6 +3384,156 @@ static void common_chat_parse_seed_oss(common_chat_msg_parser & builder) {
33673384
}
33683385
}
33693386

3387+
static common_chat_params common_chat_params_init_minimax_m2(
3388+
const common_chat_template & tmpl,
3389+
templates_params & params,
3390+
const common_chat_templates_inputs & inputs)
3391+
{
3392+
common_chat_params data;
3393+
data.prompt = apply(tmpl, params);
3394+
data.format = COMMON_CHAT_FORMAT_MINIMAX_M2;
3395+
if (string_ends_with(data.prompt, "<think>\n")) { // Minimax adds a new line at the start of reasoning content
3396+
if (!inputs.enable_thinking) {
3397+
data.prompt += "</think>";
3398+
} else {
3399+
data.thinking_forced_open = true;
3400+
}
3401+
}
3402+
3403+
if (params.tools.is_array() && !params.tools.empty()) {
3404+
data.grammar_lazy = inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_REQUIRED;
3405+
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
3406+
std::vector<std::string> tool_rules;
3407+
foreach_function(params.tools, [&](const json & tool) {
3408+
const auto & function = tool.at("function");
3409+
std::string name = function.at("name");
3410+
auto parameters = function.at("parameters");
3411+
builder.resolve_refs(parameters);
3412+
3413+
// Create rule for Seed-OSS function call format
3414+
std::string param_rules;
3415+
if (parameters.contains("properties")) {
3416+
std::set<std::string> requiredParameters;
3417+
if (parameters.contains("required")) {
3418+
requiredParameters = get_required_parameters(parameters.at("required"));
3419+
}
3420+
for (const auto & [key, value] : parameters.at("properties").items()) {
3421+
bool required = requiredParameters.count(key) > 0;
3422+
std::string specific_param_rules = "\"<parameter name=\\\"" + key + "\\\">\" " + builder.add_schema(name + "-arg-" + key, value) + " \"</parameter>\" space ";
3423+
param_rules += required ? specific_param_rules : gr_optional(specific_param_rules);
3424+
}
3425+
}
3426+
tool_rules.push_back(builder.add_rule(name + "-call",
3427+
"\"<minimax:tool_call>\" space \"<invoke name=\\\"" + name + "\\\">\" space " +
3428+
param_rules + " \"</invoke>\" space \"</minimax:tool_call>\""));
3429+
});
3430+
3431+
data.grammar_triggers.push_back({ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, "<minimax:tool_call>" });
3432+
3433+
data.preserved_tokens = {
3434+
"<minimax:tool_call>", "</minimax:tool_call>", "<think>", "</think>",
3435+
"<function", "</function>", "<parameter", "</parameter>",
3436+
};
3437+
3438+
builder.add_rule("root", string_join(tool_rules, " | "));
3439+
});
3440+
}
3441+
return data;
3442+
}
3443+
3444+
static void common_chat_parse_minimax_m2(common_chat_msg_parser & builder) {
3445+
// Parse thinking tags first - this handles the main reasoning content
3446+
// Chat template doesn't seem to handle interleaving thinking, so we don't worry about it either
3447+
builder.try_parse_reasoning("<think>", "</think>");
3448+
3449+
if (!builder.syntax().parse_tool_calls) {
3450+
builder.add_content(builder.consume_rest());
3451+
return;
3452+
}
3453+
3454+
static const std::string tool_call_tag = "minimax:tool_call";
3455+
static const std::string function_tag = "invoke";
3456+
static const std::string parameter_tag = "parameter";
3457+
3458+
// Parse tool calls - similar to Seed OSS (pseudo-XML), but different syntax
3459+
static const common_regex tool_call_begin_regex("<" + tool_call_tag + ">");
3460+
static const common_regex tool_call_end_regex("</" + tool_call_tag + ">");
3461+
static const common_regex function_regex("<" + function_tag + " name=\"([^\"]+)\">");
3462+
static const common_regex param_regex("<" + parameter_tag + " name=\"([^\"]+)\">");
3463+
3464+
while (auto tool_res = builder.try_find_regex(tool_call_begin_regex)) {
3465+
builder.consume_spaces(); // Consume whitespace after <seed:tool_call>
3466+
3467+
// Look for function call inside tool call, ignore any content before it
3468+
if (auto func_res = builder.try_find_regex(function_regex, std::string::npos, false)) {
3469+
auto function_name = builder.str(func_res->groups[1]);
3470+
3471+
// Parse XML parameters <parameter name=\"name\">value</parameter>
3472+
json args = json::object();
3473+
// Parse all parameters
3474+
while (auto param_res = builder.try_find_regex(param_regex, std::string::npos, false)) {
3475+
// again, ignore noise around parameters
3476+
auto param_name = builder.str(param_res->groups[1]);
3477+
builder.move_to(param_res->groups[0].end);
3478+
builder.consume_spaces(); // Consume whitespace after parameter
3479+
auto savedPos = builder.pos();
3480+
if (auto param_parse = builder.try_find_literal("</" + parameter_tag + ">")) {
3481+
auto param = param_parse->prelude;
3482+
builder.move_to(savedPos);
3483+
try {
3484+
if (auto param_res = builder.try_consume_json()) {
3485+
args[param_name] = param_res->json;
3486+
} else {
3487+
args[param_name] = param;
3488+
}
3489+
} catch (json::exception &) {
3490+
args[param_name] = param;
3491+
}
3492+
} else {
3493+
throw common_chat_msg_partial_exception("Incomplete tool parameter");
3494+
}
3495+
}
3496+
// Look for closing function tag
3497+
auto end_func = builder.try_find_literal("</" + function_tag + ">");
3498+
if (end_func) {
3499+
builder.move_to(end_func->groups[0].end);
3500+
builder.consume_spaces(); // Consume whitespace after </function>
3501+
3502+
// Add the tool call with parsed arguments, but only if we REALLY got the literal
3503+
auto eaten_fragment = builder.input().substr(end_func->groups[0].begin, end_func->groups[0].end);
3504+
auto funlen = std::string("</" + function_tag + ">").length();
3505+
if (eaten_fragment.length() >= funlen && eaten_fragment.substr(0, funlen) == std::string("</" + function_tag + ">")) {
3506+
if (!builder.add_tool_call(function_name, "", args.dump())) {
3507+
throw common_chat_msg_partial_exception("Incomplete tool call");
3508+
}
3509+
} else {
3510+
throw common_chat_msg_partial_exception("Incomplete tool call");
3511+
}
3512+
} else {
3513+
throw common_chat_msg_partial_exception("Incomplete tool call");
3514+
}
3515+
// Look for closing tool call tag
3516+
if (auto end_tool = builder.try_find_regex(tool_call_end_regex, std::string::npos, false)) {
3517+
builder.move_to(end_tool->groups[0].end);
3518+
builder.consume_spaces(); // Consume trailing whitespace after tool call
3519+
} else {
3520+
throw common_chat_msg_partial_exception("Incomplete tool call");
3521+
}
3522+
} else {
3523+
// No function found - don't consume content here, let it be handled at the end
3524+
break;
3525+
}
3526+
}
3527+
3528+
// Consume any remaining whitespace after all tool call processing
3529+
builder.consume_spaces();
3530+
auto remaining = builder.consume_rest();
3531+
// If there's any non-whitespace content remaining, add it as content
3532+
if (!string_strip(remaining).empty()) {
3533+
builder.add_content(remaining);
3534+
}
3535+
}
3536+
33703537
static common_chat_params common_chat_params_init_without_tools(const common_chat_template & tmpl, const struct templates_params & inputs) {
33713538
common_chat_params data;
33723539
data.prompt = apply(tmpl, inputs);
@@ -3532,6 +3699,11 @@ static common_chat_params common_chat_templates_apply_jinja(
35323699
return common_chat_params_init_seed_oss(tmpl, params, inputs);
35333700
}
35343701

3702+
// MiniMax M2
3703+
if (src.find("<minimax:tool_call>") != std::string::npos) {
3704+
return common_chat_params_init_minimax_m2(tmpl, params, inputs);
3705+
}
3706+
35353707
// Nemotron v2
35363708
if (src.find("<SPECIAL_10>") != std::string::npos) {
35373709
return common_chat_params_init_nemotron_v2(tmpl, params);
@@ -3736,6 +3908,9 @@ static void common_chat_parse(common_chat_msg_parser & builder) {
37363908
case COMMON_CHAT_FORMAT_GLM_4_5:
37373909
common_chat_parse_glm_4_5(builder);
37383910
break;
3911+
case COMMON_CHAT_FORMAT_MINIMAX_M2:
3912+
common_chat_parse_minimax_m2(builder);
3913+
break;
37393914
default:
37403915
throw std::runtime_error(std::string("Unsupported format: ") + common_chat_format_name(builder.syntax().format));
37413916
}

common/chat.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ enum common_chat_format {
119119
COMMON_CHAT_FORMAT_NEMOTRON_V2,
120120
COMMON_CHAT_FORMAT_APERTUS,
121121
COMMON_CHAT_FORMAT_LFM2_WITH_JSON_TOOLS,
122+
COMMON_CHAT_FORMAT_MINIMAX_M2,
122123

123124
COMMON_CHAT_FORMAT_COUNT, // Not a format, just the # formats
124125
};
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
{# Unsloth template fixes #}
2+
{# ----------‑‑‑ special token variables ‑‑‑---------- #}
3+
{%- set toolcall_begin_token = '<minimax:tool_call>' -%}
4+
{%- set toolcall_end_token = '</minimax:tool_call>' -%}
5+
{#- Tool Rendering Functions ============================================== -#}
6+
{%- macro render_tool_namespace(namespace_name, tool_list) -%}
7+
{%- for tool in tool_list -%}
8+
<tool>{{ tool.function | tojson | string }}</tool>
9+
{% endfor -%}
10+
{%- endmacro -%}
11+
{%- macro visible_text(content) -%}
12+
{%- if content is string -%}
13+
{{ content }}
14+
{%- elif content is iterable and content is not mapping -%}
15+
{%- for item in content -%}
16+
{%- if item is mapping and item.type == 'text' -%}
17+
{{- item.text }}
18+
{%- elif item is string -%}
19+
{{- item }}
20+
{%- endif -%}
21+
{%- endfor -%}
22+
{%- else -%}
23+
{{- content }}
24+
{%- endif -%}
25+
{%- endmacro -%}
26+
{#- System Message Construction ============================================ -#}
27+
{%- macro build_system_message(system_message) -%}
28+
{%- if system_message and system_message.content -%}
29+
{{- visible_text(system_message.content) }}
30+
{%- else -%}
31+
{%- if model_identity is not defined -%}
32+
{%- set model_identity = "You are a helpful assistant." -%}
33+
{%- endif -%}
34+
{{- model_identity }}
35+
{%- endif -%}
36+
37+
{#- Handle current_date -#}
38+
{%- if system_message and system_message.current_date -%}
39+
{{- '\n' ~ 'Current date: ' + system_message.current_date }}
40+
{%- endif -%}
41+
{#- Handle current_location -#}
42+
{%- if system_message and system_message.current_location -%}
43+
{{- '\n' ~ 'Current location: ' + system_message.current_location }}
44+
{%- endif -%}
45+
{%- endmacro -%}
46+
{#- Main Template Logic ================================================= -#}
47+
{#- Extract system message (only first message if it's system) -#}
48+
{%- set system_message = none -%}
49+
{%- set conversation_messages = messages -%}
50+
{%- if messages and messages[0].role == "system" -%}
51+
{%- set system_message = messages[0] -%}
52+
{%- set conversation_messages = messages[1:] -%}
53+
{%- endif -%}
54+
{#- Get the last user message turn, for interleved thinking -#}
55+
{%- set ns = namespace(last_user_index=-1) %}
56+
{% for m in conversation_messages %}
57+
{%- if m.role == 'user' %}
58+
{% set ns.last_user_index = loop.index0 -%}
59+
{%- endif %}
60+
{%- endfor %}
61+
{#- Render system message -#}
62+
{{- ']~!b[' ~ ']~b]system' ~ '\n' }}
63+
{{- build_system_message(system_message) }}
64+
{#- Render tools if available -#}
65+
{%- if tools -%}
66+
{{- '\n\n' ~ '# Tools' ~ '\n' ~ 'You may call one or more tools to assist with the user query.\nHere are the tools available in JSONSchema format:' ~ '\n' }}
67+
{{- '\n' ~ '<tools>' ~ '\n' }}
68+
{{- render_tool_namespace("functions", tools) }}
69+
{{- '</tools>' ~ '\n\n' }}
70+
{{- 'When making tool calls, use XML format to invoke tools and pass parameters:' ~ '\n' }}
71+
{{- '\n' ~ toolcall_begin_token }}
72+
<invoke name="tool-name-1">
73+
<parameter name="param-key-1">param-value-1</parameter>
74+
<parameter name="param-key-2">param-value-2</parameter>
75+
...
76+
</invoke>
77+
{{- '\n' ~ toolcall_end_token }}
78+
{%- endif -%}
79+
{{- '[e~[\n' }}
80+
81+
{#- Render messages -#}
82+
{%- set last_tool_call = namespace(name=none) -%}
83+
{%- for message in conversation_messages -%}
84+
{%- if message.role == 'assistant' -%}
85+
{#- Only render reasoning_content if no user message follows -#}
86+
{{- ']~b]ai' ~ '\n' }}
87+
88+
{%- set reasoning_content = '' %}
89+
{%- set content = visible_text(message.content) %}
90+
{%- if message.reasoning_content is string %}
91+
{%- set reasoning_content = message.reasoning_content %}
92+
{%- else %}
93+
{%- if '</think>' in content %}
94+
{# Unsloth template fixes - must change to for loop since llama.cpp will error out if not #}
95+
{%- set parts = content.split('</think>') %}
96+
{%- for part in parts %}
97+
{%- if loop.index0 == 0 -%}
98+
{%- set reasoning_content = part.strip('\n') %}
99+
{%- set reasoning_content = (reasoning_content.split('<think>')|last) %}
100+
{%- set reasoning_content = reasoning_content.strip('\n') -%}
101+
{%- else -%}
102+
{%- set content = part.strip('\n') %}
103+
{%- endif %}
104+
{%- endfor %}
105+
{%- endif %}
106+
{%- endif %}
107+
{%- if reasoning_content and loop.index0 > ns.last_user_index -%}
108+
{{- '<think>' ~ '\n' ~ reasoning_content ~ '\n' ~ '</think>' ~ '\n\n' }}
109+
{%- endif -%}
110+
{%- if content -%}
111+
{{- content }}
112+
{%- endif -%}
113+
{%- if message.tool_calls -%}
114+
{{- '\n' ~ toolcall_begin_token ~ '\n' }}
115+
116+
{%- for tool_call in message.tool_calls -%}
117+
{%- if tool_call.function %}
118+
{%- set tool_call = tool_call.function %}
119+
{%- endif %}
120+
{{- '<invoke name="' + tool_call.name + '">\n' }}
121+
{%- if tool_call.arguments is defined and tool_call.arguments is mapping -%}
122+
{% set _args = tool_call.arguments %}
123+
{%- for k, v in _args|items %}
124+
{{- '<parameter name="' + k + '">' }}
125+
{{- v | tojson | string if v is not string else v }}
126+
{{- '</parameter>' }}
127+
{% endfor %}{%- endif -%}
128+
{{- '</invoke>' ~ '\n' }}
129+
{%- endfor -%}
130+
131+
{{- toolcall_end_token}}
132+
{%- set last_tool_call.name = message.tool_calls[-1].name -%}
133+
{%- else -%}
134+
{%- set last_tool_call.name = none -%}
135+
{%- endif -%}
136+
{{- '[e~[' ~ '\n' }}
137+
138+
{%- elif message.role == 'tool' -%}
139+
{%- if last_tool_call.name is none -%}
140+
{{- raise_exception("Message has tool role, but there was no previous assistant message with a tool call!") }}
141+
{%- endif -%}
142+
{%- if loop.first or (conversation_messages[loop.index0 - 1].role != 'tool') -%}
143+
{{- ']~b]tool' }}
144+
{%- endif -%}
145+
{%- if message.content is string -%}
146+
{{- '\n<response>' }}
147+
{{- message.content }}
148+
{{- '</response>' }}
149+
{%- else -%}
150+
{%- for tr in message.content -%}
151+
{{- '\n<response>' }}
152+
{{- tr.output if tr.output is defined else (tr.text if tr.type == 'text' and tr.text is defined else tr) }}
153+
{{- '\n</response>' }}
154+
{%- endfor -%}
155+
{%- endif -%}
156+
{%- if loop.last or (conversation_messages[loop.index0 + 1].role != 'tool') -%}
157+
{{- '[e~[\n' -}}
158+
{%- endif -%}
159+
160+
{%- elif message.role == 'user' -%}
161+
{{- ']~b]user' ~ '\n' }}
162+
{{- visible_text(message.content) }}
163+
{{- '[e~[' ~ '\n' }}
164+
{%- endif -%}
165+
{%- endfor -%}
166+
167+
{#- Generation prompt -#}
168+
{%- if add_generation_prompt -%}
169+
{{- ']~b]ai' ~ '\n' ~ '<think>' ~ '\n' }}
170+
{%- endif -%}
171+
{# Copyright 2025-present Unsloth. Apache 2.0 License. #}

0 commit comments

Comments
 (0)