Skip to content

Commit e21f87e

Browse files
committed
Minimax M2 chat template support
1 parent 66d8ecc commit e21f87e

File tree

4 files changed

+380
-0
lines changed

4 files changed

+380
-0
lines changed

common/chat.cpp

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,7 @@ const char * common_chat_format_name(common_chat_format format) {
643643
case COMMON_CHAT_FORMAT_NEMOTRON_V2: return "Nemotron V2";
644644
case COMMON_CHAT_FORMAT_APERTUS: return "Apertus";
645645
case COMMON_CHAT_FORMAT_LFM2_WITH_JSON_TOOLS: return "LFM2 with JSON tools";
646+
case COMMON_CHAT_FORMAT_MINIMAX_M2: return "MiniMax M2";
646647
default:
647648
throw std::runtime_error("Unknown chat format");
648649
}
@@ -2791,6 +2792,151 @@ static void common_chat_parse_seed_oss(common_chat_msg_parser & builder) {
27912792
}
27922793
}
27932794

2795+
static common_chat_params common_chat_params_init_minimax_m2(
2796+
const common_chat_template & tmpl,
2797+
templates_params & params,
2798+
const common_chat_templates_inputs & inputs)
2799+
{
2800+
common_chat_params data;
2801+
data.prompt = apply(tmpl, params);
2802+
data.format = COMMON_CHAT_FORMAT_MINIMAX_M2;
2803+
if (string_ends_with(data.prompt, "<think>")) {
2804+
if (!inputs.enable_thinking) {
2805+
data.prompt += "</think>";
2806+
} else {
2807+
data.thinking_forced_open = true;
2808+
}
2809+
}
2810+
2811+
if (params.tools.is_array() && !params.tools.empty()) {
2812+
data.grammar_lazy = inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_REQUIRED;
2813+
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
2814+
std::vector<std::string> tool_rules;
2815+
foreach_function(params.tools, [&](const json & tool) {
2816+
const auto & function = tool.at("function");
2817+
std::string name = function.at("name");
2818+
auto parameters = function.at("parameters");
2819+
builder.resolve_refs(parameters);
2820+
2821+
// Create rule for Seed-OSS function call format
2822+
std::string param_rules;
2823+
if (parameters.contains("properties")) {
2824+
for (const auto & [key, value] : parameters.at("properties").items()) {
2825+
param_rules += "\"<parameter name=\\\"" + key + "\\\">\" " + builder.add_schema(name + "-arg-" + key, value) + " \"</parameter>\" space ";
2826+
}
2827+
}
2828+
tool_rules.push_back(builder.add_rule(name + "-call",
2829+
"\"<minimax:tool_call>\" space \"<invoke name=\\\"" + name + "\\\">\" space " +
2830+
param_rules +
2831+
" \"</invoke>\" space \"</minimax:tool_call>\""));
2832+
});
2833+
2834+
data.grammar_triggers.push_back({ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, "<minimax:tool_call>" });
2835+
2836+
data.preserved_tokens = {
2837+
"<minimax:tool_call>", "</minimax:tool_call>", "<think>", "</think>",
2838+
"<function", "</function>", "<parameter", "</parameter>",
2839+
};
2840+
2841+
builder.add_rule("root", string_join(tool_rules, " | "));
2842+
});
2843+
}
2844+
return data;
2845+
}
2846+
2847+
static void common_chat_parse_minimax_m2(common_chat_msg_parser & builder) {
2848+
// Parse thinking tags first - this handles the main reasoning content
2849+
// Chat template doesn't seem to handle interleaving thinking, so we don't worry about it either
2850+
builder.try_parse_reasoning("<think>", "</think>");
2851+
2852+
if (!builder.syntax().parse_tool_calls) {
2853+
builder.add_content(builder.consume_rest());
2854+
return;
2855+
}
2856+
2857+
static const std::string tool_call_tag = "minimax:tool_call";
2858+
static const std::string function_tag = "invoke";
2859+
static const std::string parameter_tag = "parameter";
2860+
2861+
// Parse tool calls - similar to Seed OSS (pseudo-XML), but different syntax
2862+
static const common_regex tool_call_begin_regex("<" + tool_call_tag + ">");
2863+
static const common_regex tool_call_end_regex("</" + tool_call_tag + ">");
2864+
static const common_regex function_regex("<" + function_tag + " name=\"([^\"]+)\">");
2865+
static const common_regex param_regex("<" + parameter_tag + " name=\"([^\"]+)\">");
2866+
2867+
while (auto tool_res = builder.try_find_regex(tool_call_begin_regex)) {
2868+
builder.consume_spaces(); // Consume whitespace after <seed:tool_call>
2869+
2870+
// Look for function call inside tool call, ignore any content before it
2871+
if (auto func_res = builder.try_find_regex(function_regex, std::string::npos, false)) {
2872+
auto function_name = builder.str(func_res->groups[1]);
2873+
2874+
// Parse XML parameters <parameter name=\"name\">value</parameter>
2875+
json args = json::object();
2876+
// Parse all parameters
2877+
while (auto param_res = builder.try_find_regex(param_regex, std::string::npos, false)) {
2878+
// again, ignore noise around parameters
2879+
auto param_name = builder.str(param_res->groups[1]);
2880+
builder.move_to(param_res->groups[0].end);
2881+
builder.consume_spaces(); // Consume whitespace after parameter
2882+
auto savedPos = builder.pos();
2883+
if (auto param_parse = builder.try_find_literal("</" + parameter_tag + ">")) {
2884+
auto param = param_parse->prelude;
2885+
builder.move_to(savedPos);
2886+
try {
2887+
if (auto param_res = builder.try_consume_json()) {
2888+
args[param_name] = param_res->json;
2889+
} else {
2890+
args[param_name] = param;
2891+
}
2892+
} catch (json::exception &) {
2893+
args[param_name] = param;
2894+
}
2895+
} else {
2896+
throw common_chat_msg_partial_exception("Incomplete tool parameter");
2897+
}
2898+
}
2899+
// Look for closing function tag
2900+
auto end_func = builder.try_find_literal("</" + function_tag + ">");
2901+
if (end_func) {
2902+
builder.move_to(end_func->groups[0].end);
2903+
builder.consume_spaces(); // Consume whitespace after </function>
2904+
2905+
// Add the tool call with parsed arguments, but only if we REALLY got the literal
2906+
auto eaten_fragment = builder.input().substr(end_func->groups[0].begin, end_func->groups[0].end);
2907+
auto funlen = std::string("</" + function_tag + ">").length();
2908+
if (eaten_fragment.length() >= funlen && eaten_fragment.substr(0, funlen) == std::string("</" + function_tag + ">")) {
2909+
if (!builder.add_tool_call(function_name, "", args.dump())) {
2910+
throw common_chat_msg_partial_exception("Incomplete tool call");
2911+
}
2912+
} else {
2913+
throw common_chat_msg_partial_exception("Incomplete tool call");
2914+
}
2915+
} else {
2916+
throw common_chat_msg_partial_exception("Incomplete tool call");
2917+
}
2918+
// Look for closing tool call tag
2919+
if (auto end_tool = builder.try_find_regex(tool_call_end_regex, std::string::npos, false)) {
2920+
builder.move_to(end_tool->groups[0].end);
2921+
builder.consume_spaces(); // Consume trailing whitespace after tool call
2922+
} else {
2923+
throw common_chat_msg_partial_exception("Incomplete tool call");
2924+
}
2925+
} else {
2926+
// No function found - don't consume content here, let it be handled at the end
2927+
break;
2928+
}
2929+
}
2930+
2931+
// Consume any remaining whitespace after all tool call processing
2932+
builder.consume_spaces();
2933+
auto remaining = builder.consume_rest();
2934+
// If there's any non-whitespace content remaining, add it as content
2935+
if (!string_strip(remaining).empty()) {
2936+
builder.add_content(remaining);
2937+
}
2938+
}
2939+
27942940
static common_chat_params common_chat_params_init_without_tools(const common_chat_template & tmpl, const struct templates_params & inputs) {
27952941
common_chat_params data;
27962942
data.prompt = apply(tmpl, inputs);
@@ -2942,6 +3088,11 @@ static common_chat_params common_chat_templates_apply_jinja(
29423088
return common_chat_params_init_seed_oss(tmpl, params, inputs);
29433089
}
29443090

3091+
// MiniMax M2
3092+
if (src.find("<minimax:tool_call>") != std::string::npos) {
3093+
return common_chat_params_init_minimax_m2(tmpl, params, inputs);
3094+
}
3095+
29453096
// Nemotron v2
29463097
if (src.find("<SPECIAL_10>") != std::string::npos) {
29473098
return common_chat_params_init_nemotron_v2(tmpl, params);
@@ -3139,6 +3290,9 @@ static void common_chat_parse(common_chat_msg_parser & builder) {
31393290
case COMMON_CHAT_FORMAT_LFM2_WITH_JSON_TOOLS:
31403291
common_chat_parse_lfm2(builder);
31413292
break;
3293+
case COMMON_CHAT_FORMAT_MINIMAX_M2:
3294+
common_chat_parse_minimax_m2(builder);
3295+
break;
31423296
default:
31433297
throw std::runtime_error(std::string("Unsupported format: ") + common_chat_format_name(builder.syntax().format));
31443298
}

common/chat.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ enum common_chat_format {
117117
COMMON_CHAT_FORMAT_NEMOTRON_V2,
118118
COMMON_CHAT_FORMAT_APERTUS,
119119
COMMON_CHAT_FORMAT_LFM2_WITH_JSON_TOOLS,
120+
COMMON_CHAT_FORMAT_MINIMAX_M2,
120121

121122
COMMON_CHAT_FORMAT_COUNT, // Not a format, just the # formats
122123
};

models/templates/MiniMax-M2.jinja

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

0 commit comments

Comments
 (0)