common/chat: add specialized minimax m3 parser (#26210)
This commit is contained in:
@@ -1056,3 +1056,141 @@ void common_chat_peg_gemma4_mapper::visit(const common_peg_ast_arena & arena, co
|
||||
visit(arena, child_id);
|
||||
}
|
||||
}
|
||||
|
||||
static void minimax_m3_collect(const common_peg_ast_arena & arena,
|
||||
const common_peg_ast_node & node,
|
||||
const std::string & tag,
|
||||
std::vector<common_peg_ast_id> & out) {
|
||||
for (auto child_id : node.children) {
|
||||
const auto & child = arena.get(child_id);
|
||||
if (child.tag == tag) {
|
||||
out.push_back(child_id);
|
||||
} else {
|
||||
minimax_m3_collect(arena, child, tag, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static common_peg_ast_id minimax_m3_value_of(const common_peg_ast_arena & arena, const common_peg_ast_node & node) {
|
||||
for (auto child_id : node.children) {
|
||||
const auto & tag = arena.get(child_id).tag;
|
||||
if (tag == common_chat_peg_builder::TOOL_ARG_VALUE ||
|
||||
tag == common_chat_peg_builder::TOOL_ARG_STRING_VALUE ||
|
||||
tag == common_chat_peg_minimax_m3_mapper::TOOL_ARG_OBJECT ||
|
||||
tag == common_chat_peg_minimax_m3_mapper::TOOL_ARG_ARRAY) {
|
||||
return child_id;
|
||||
}
|
||||
}
|
||||
return COMMON_PEG_INVALID_AST_ID;
|
||||
}
|
||||
|
||||
static std::string minimax_m3_value_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id, bool closed);
|
||||
|
||||
static std::string minimax_m3_member_to_json(const common_peg_ast_arena & arena, const common_peg_ast_node & node) {
|
||||
auto name_id = arena.find_by_tag(node, common_chat_peg_builder::TOOL_ARG_NAME);
|
||||
if (name_id == COMMON_PEG_INVALID_AST_ID) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return ordered_json(arena.get(name_id).text).dump() + ":" +
|
||||
minimax_m3_value_to_json(arena, minimax_m3_value_of(arena, node), !node.is_partial);
|
||||
}
|
||||
|
||||
static std::string minimax_m3_container_to_json(const common_peg_ast_arena & arena,
|
||||
const common_peg_ast_node & node,
|
||||
bool is_object,
|
||||
bool closed) {
|
||||
const std::string tag = is_object ? common_chat_peg_builder::TOOL_ARG
|
||||
: common_chat_peg_minimax_m3_mapper::TOOL_ARG_ITEM;
|
||||
|
||||
std::vector<common_peg_ast_id> entries;
|
||||
minimax_m3_collect(arena, node, tag, entries);
|
||||
|
||||
std::string result = is_object ? "{" : "[";
|
||||
|
||||
bool add_comma = false;
|
||||
for (auto entry_id : entries) {
|
||||
const auto & entry = arena.get(entry_id);
|
||||
|
||||
std::string text;
|
||||
if (is_object) {
|
||||
text = minimax_m3_member_to_json(arena, entry);
|
||||
} else {
|
||||
text = minimax_m3_value_to_json(arena, minimax_m3_value_of(arena, entry), !entry.is_partial);
|
||||
}
|
||||
|
||||
if (text.empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (add_comma) {
|
||||
result += ",";
|
||||
}
|
||||
add_comma = true;
|
||||
result += text;
|
||||
}
|
||||
|
||||
if (closed) {
|
||||
result += is_object ? "}" : "]";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static std::string minimax_m3_value_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id, bool closed) {
|
||||
if (id == COMMON_PEG_INVALID_AST_ID) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const auto & node = arena.get(id);
|
||||
|
||||
if (node.tag == common_chat_peg_minimax_m3_mapper::TOOL_ARG_OBJECT) {
|
||||
return minimax_m3_container_to_json(arena, node, /* is_object = */ true, closed);
|
||||
}
|
||||
|
||||
if (node.tag == common_chat_peg_minimax_m3_mapper::TOOL_ARG_ARRAY) {
|
||||
return minimax_m3_container_to_json(arena, node, /* is_object = */ false, closed);
|
||||
}
|
||||
|
||||
if (node.tag == common_chat_peg_builder::TOOL_ARG_STRING_VALUE) {
|
||||
return "\"" + escape_json_string_inner(std::string(node.text)) + (closed ? "\"" : "");
|
||||
}
|
||||
|
||||
// Numbers and booleans are written verbatim by the template
|
||||
return std::string(node.text);
|
||||
}
|
||||
|
||||
void common_chat_peg_minimax_m3_mapper::from_ast(const common_peg_ast_arena & arena,
|
||||
const common_peg_parse_result & result) {
|
||||
for (const auto & node : result.nodes) {
|
||||
visit(arena, node);
|
||||
}
|
||||
}
|
||||
|
||||
void common_chat_peg_minimax_m3_mapper::visit(const common_peg_ast_arena & arena, common_peg_ast_id id) {
|
||||
const auto & node = arena.get(id);
|
||||
|
||||
if (node.tag == common_chat_peg_builder::REASONING) {
|
||||
result.reasoning_content += std::string(node.text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.tag == common_chat_peg_builder::CONTENT) {
|
||||
result.content += std::string(node.text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.tag == common_chat_peg_builder::TOOL) {
|
||||
auto name_id = arena.find_by_tag(node, common_chat_peg_builder::TOOL_NAME);
|
||||
if (name_id != COMMON_PEG_INVALID_AST_ID) {
|
||||
common_chat_tool_call call;
|
||||
call.name = std::string(arena.get(name_id).text);
|
||||
call.arguments = minimax_m3_container_to_json(arena, node, /* is_object = */ true, !node.is_partial);
|
||||
result.tool_calls.push_back(call);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (auto child_id : node.children) {
|
||||
visit(arena, child_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,18 @@ class common_chat_peg_gemma4_mapper : public common_chat_peg_mapper {
|
||||
void visit(const common_peg_ast_arena & arena, common_peg_ast_id id);
|
||||
};
|
||||
|
||||
class common_chat_peg_minimax_m3_mapper : public common_chat_peg_mapper {
|
||||
public:
|
||||
static constexpr const char * TOOL_ARG_OBJECT = "tool-arg-object";
|
||||
static constexpr const char * TOOL_ARG_ARRAY = "tool-arg-array";
|
||||
static constexpr const char * TOOL_ARG_ITEM = "tool-arg-item";
|
||||
|
||||
common_chat_peg_minimax_m3_mapper(common_chat_msg & msg) : common_chat_peg_mapper(msg) {}
|
||||
virtual void from_ast(const common_peg_ast_arena & arena, const common_peg_parse_result & result);
|
||||
private:
|
||||
void visit(const common_peg_ast_arena & arena, common_peg_ast_id id);
|
||||
};
|
||||
|
||||
struct content_structure;
|
||||
struct tool_call_structure;
|
||||
|
||||
|
||||
+273
@@ -816,6 +816,8 @@ const char * common_chat_format_name(common_chat_format format) {
|
||||
return "peg-native";
|
||||
case COMMON_CHAT_FORMAT_PEG_GEMMA4:
|
||||
return "peg-gemma4";
|
||||
case COMMON_CHAT_FORMAT_PEG_MINIMAX_M3:
|
||||
return "peg-minimax-m3";
|
||||
default:
|
||||
throw std::runtime_error("Unknown chat format");
|
||||
}
|
||||
@@ -2270,6 +2272,264 @@ static common_chat_params common_chat_params_init_cohere2moe(const common_chat_t
|
||||
return data;
|
||||
}
|
||||
|
||||
static common_chat_params common_chat_params_init_minimax_m3(const common_chat_template & tmpl,
|
||||
const autoparser::generation_params & inputs) {
|
||||
common_chat_params data;
|
||||
|
||||
data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs);
|
||||
data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, inputs);
|
||||
data.format = COMMON_CHAT_FORMAT_PEG_MINIMAX_M3;
|
||||
data.supports_thinking = true;
|
||||
data.thinking_start_tag = "<mm:think>";
|
||||
data.thinking_end_tags = {"</mm:think>"};
|
||||
|
||||
// M3 prefixes every tool tag with the namespace token "]<]minimax[>[";
|
||||
// params use the parameter name as the tag (<file_path>...</file_path>).
|
||||
const std::string NS = "]<]minimax[>[";
|
||||
const std::string THINK_START = "<mm:think>";
|
||||
const std::string THINK_END = "</mm:think>";
|
||||
const std::string FC_START = NS + "<tool_call>";
|
||||
const std::string FC_END = NS + "</tool_call>";
|
||||
const std::string INVOKE_END = NS + "</invoke>";
|
||||
|
||||
data.preserved_tokens = {
|
||||
NS,
|
||||
"<tool_call>",
|
||||
"</tool_call>",
|
||||
THINK_START,
|
||||
THINK_END,
|
||||
};
|
||||
|
||||
data.message_delimiters = {
|
||||
{ COMMON_CHAT_ROLE_ASSISTANT, "]~b]ai" },
|
||||
{ COMMON_CHAT_ROLE_USER, "]~b]user" },
|
||||
{ COMMON_CHAT_ROLE_TOOL, "]~b]tool" },
|
||||
{ COMMON_CHAT_ROLE_SYSTEM, "]~b]developer" },
|
||||
{ COMMON_CHAT_ROLE_SYSTEM, "]~b]system" },
|
||||
};
|
||||
|
||||
auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
|
||||
auto has_response_format = !inputs.json_schema.is_null() && inputs.json_schema.is_object();
|
||||
auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;
|
||||
auto include_grammar = has_response_format || (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE);
|
||||
|
||||
const std::string GEN_PROMPT = data.generation_prompt;
|
||||
|
||||
using mm3 = common_chat_peg_minimax_m3_mapper;
|
||||
|
||||
if (inputs.has_continuation()) {
|
||||
const auto & msg = inputs.continue_msg;
|
||||
|
||||
data.generation_prompt = GEN_PROMPT + THINK_START + msg.reasoning_content;
|
||||
if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_CONTENT) {
|
||||
data.generation_prompt += THINK_END + msg.render_content();
|
||||
}
|
||||
|
||||
data.prompt += data.generation_prompt;
|
||||
}
|
||||
|
||||
auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
|
||||
auto generation_prompt = p.prefix(GEN_PROMPT, THINK_START);
|
||||
auto end = p.end();
|
||||
|
||||
auto reasoning = p.eps();
|
||||
if (extract_reasoning) {
|
||||
auto block = inputs.enable_thinking
|
||||
? p.literal(THINK_START) + p.space() +
|
||||
p.ac(p.reasoning(p.until(THINK_END)) + p.literal(THINK_END), THINK_END)
|
||||
: p.literal(THINK_START) + p.ac(p.until(THINK_END) + p.literal(THINK_END), THINK_END);
|
||||
|
||||
// A turn without reasoning is prefixed with a bare </mm:think>, written either by the
|
||||
// generation prompt (thinking_mode = "disabled") or by the model itself.
|
||||
reasoning = p.optional(p.choice({ block, p.literal(THINK_END) }));
|
||||
}
|
||||
|
||||
if (has_response_format) {
|
||||
auto response_format = p.rule("response-format",
|
||||
p.literal("```json") + p.space() +
|
||||
p.content(p.schema(p.json(), "response-format-schema", inputs.json_schema)) +
|
||||
p.space() + p.literal("```"));
|
||||
return generation_prompt + reasoning + response_format + end;
|
||||
}
|
||||
|
||||
if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE) {
|
||||
return generation_prompt + reasoning + p.content(p.rest()) + end;
|
||||
}
|
||||
|
||||
auto alternatives_of = [](const json & schema) -> std::optional<json> {
|
||||
for (const auto * keyword : { "oneOf", "anyOf" }) {
|
||||
if (schema.contains(keyword) && schema.at(keyword).is_array() && !schema.at(keyword).empty()) {
|
||||
return schema.at(keyword);
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
};
|
||||
|
||||
auto tool_choice = p.choice();
|
||||
foreach_function(inputs.tools, [&](const json & tool) {
|
||||
const auto & function = tool.at("function");
|
||||
std::string name = function.at("name");
|
||||
auto params = function.contains("parameters") ? function.at("parameters") : json::object();
|
||||
|
||||
auto schema_info = common_schema_info();
|
||||
schema_info.resolve_refs(params);
|
||||
|
||||
// The template expands argument values recursively in XML (see the to_xml() macro)
|
||||
std::function<common_peg_parser(const json &, const std::string &, const std::string &)> value_of;
|
||||
std::function<common_peg_parser(const json &, const std::string &)> members_of;
|
||||
|
||||
auto element_of = [&](const std::string & tag, const json & schema, const std::string & rule_name) {
|
||||
const std::string close = NS + "</" + tag + ">";
|
||||
return p.rule(rule_name,
|
||||
p.tool_arg(
|
||||
p.tool_arg_open(
|
||||
p.literal(NS + "<") +
|
||||
p.tool_arg_name(p.literal(tag)) +
|
||||
p.literal(">")) +
|
||||
value_of(schema, rule_name, close)));
|
||||
};
|
||||
|
||||
value_of = [&](const json & schema,
|
||||
const std::string & rule_name,
|
||||
const std::string & close) -> common_peg_parser {
|
||||
auto close_tag = p.tool_arg_close(p.literal(close));
|
||||
|
||||
// A string accepts anything, so a union with a string alternative is a string
|
||||
if (schema_info.resolves_to_string(schema)) {
|
||||
return p.ac(p.tool_arg_string_value(p.until(close)) + close_tag, close);
|
||||
}
|
||||
|
||||
if (auto alternatives = alternatives_of(schema)) {
|
||||
std::vector<common_peg_parser> choices;
|
||||
|
||||
size_t index = 0;
|
||||
for (const auto & alternative : *alternatives) {
|
||||
const std::string alt_name = rule_name + "-" + std::to_string(index++);
|
||||
|
||||
// There is a risk that this breaks streaming deltas, but that's a risk we
|
||||
// assume to provide tool arg streaming.
|
||||
choices.push_back(value_of(alternative, alt_name, close));
|
||||
}
|
||||
|
||||
return p.choice(choices);
|
||||
}
|
||||
|
||||
const std::string type = schema.contains("type") && schema.at("type").is_string()
|
||||
? schema.at("type").get<std::string>()
|
||||
: "";
|
||||
|
||||
if (type == "object" && schema.contains("properties")) {
|
||||
return p.tag(mm3::TOOL_ARG_OBJECT, members_of(schema, rule_name)) + p.space() + close_tag;
|
||||
}
|
||||
|
||||
if (type == "array" && schema.contains("items")) {
|
||||
const std::string item_close = NS + "</item>";
|
||||
auto item = p.rule(rule_name + "-item",
|
||||
p.tag(mm3::TOOL_ARG_ITEM,
|
||||
p.literal(NS + "<item>") +
|
||||
value_of(schema.at("items"), rule_name + "-item", item_close)));
|
||||
return p.tag(mm3::TOOL_ARG_ARRAY, p.repeat(p.space() + item, 0, -1)) + p.space() + close_tag;
|
||||
}
|
||||
|
||||
return p.tool_arg_json_value(p.schema(p.json(), rule_name + "-schema", schema, false)) + close_tag;
|
||||
};
|
||||
|
||||
// Required properties in schema order, then any number of optional ones in any order.
|
||||
members_of = [&](const json & schema, const std::string & rule_prefix) -> common_peg_parser {
|
||||
const auto & props = schema.at("properties");
|
||||
|
||||
std::set<std::string> required;
|
||||
if (schema.contains("required")) {
|
||||
schema.at("required").get_to(required);
|
||||
}
|
||||
|
||||
std::vector<common_peg_parser> required_elements;
|
||||
std::vector<common_peg_parser> optional_elements;
|
||||
for (const auto & [key, key_schema] : props.items()) {
|
||||
auto element = element_of(key, key_schema, rule_prefix + "-" + key);
|
||||
if (required.find(key) != required.end()) {
|
||||
required_elements.push_back(element);
|
||||
} else {
|
||||
optional_elements.push_back(element);
|
||||
}
|
||||
}
|
||||
|
||||
common_peg_parser members = p.eps();
|
||||
for (size_t i = 0; i < required_elements.size(); i++) {
|
||||
if (i > 0) {
|
||||
members = members + p.space();
|
||||
}
|
||||
members = members + required_elements[i];
|
||||
}
|
||||
|
||||
if (!optional_elements.empty()) {
|
||||
common_peg_parser any_optional = p.choice();
|
||||
for (const auto & element : optional_elements) {
|
||||
any_optional |= element;
|
||||
}
|
||||
members = members + p.repeat(p.space() + any_optional, 0, -1);
|
||||
}
|
||||
|
||||
return members;
|
||||
};
|
||||
|
||||
common_peg_parser invoke_body =
|
||||
params.contains("properties") ? members_of(params, "tool-" + name + "-arg") : p.eps();
|
||||
|
||||
auto func_parser = p.tool(
|
||||
p.tool_open(p.literal(NS + "<invoke name=\"") +
|
||||
p.tool_name(p.literal(name)) + p.literal("\">")) +
|
||||
p.space() + invoke_body + p.space() +
|
||||
p.tool_close(p.literal(INVOKE_END)));
|
||||
|
||||
tool_choice |= p.rule("tool-" + name, func_parser);
|
||||
});
|
||||
|
||||
auto require_tools = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED;
|
||||
|
||||
common_peg_parser tool_calls = p.eps();
|
||||
if (inputs.parallel_tool_calls) {
|
||||
tool_calls = p.trigger_rule("tool-call",
|
||||
p.literal(FC_START) + p.space() + tool_choice +
|
||||
p.zero_or_more(p.space() + tool_choice) + p.space() + p.literal(FC_END));
|
||||
} else {
|
||||
tool_calls = p.trigger_rule("tool-call",
|
||||
p.literal(FC_START) + p.space() + tool_choice + p.space() + p.literal(FC_END));
|
||||
}
|
||||
|
||||
if (!require_tools) {
|
||||
tool_calls = p.optional(tool_calls);
|
||||
}
|
||||
|
||||
auto content_before_tools = p.content(p.until(FC_START));
|
||||
return generation_prompt + reasoning + content_before_tools + tool_calls + end;
|
||||
});
|
||||
|
||||
data.parser = parser.save();
|
||||
|
||||
if (include_grammar) {
|
||||
data.grammar_lazy = !(has_response_format || (has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED));
|
||||
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
|
||||
foreach_function(inputs.tools, [&](const json & tool) {
|
||||
const auto & function = tool.at("function");
|
||||
auto schema = function.contains("parameters") ? function.at("parameters") : json::object();
|
||||
builder.resolve_refs(schema);
|
||||
});
|
||||
if (has_response_format) {
|
||||
auto schema = inputs.json_schema;
|
||||
builder.resolve_refs(schema);
|
||||
}
|
||||
parser.build_grammar(builder, data.grammar_lazy);
|
||||
});
|
||||
|
||||
data.grammar_triggers = {
|
||||
{ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, FC_START },
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
namespace workaround {
|
||||
|
||||
static void map_developer_role_to_system(json & messages) {
|
||||
@@ -2707,6 +2967,15 @@ std::optional<common_chat_params> common_chat_try_specialized_template(
|
||||
return common_chat_params_init_gigachat_v3(tmpl, params);
|
||||
}
|
||||
|
||||
// MiniMax-M3: the namespace token "]<]minimax[>[" collides with the autoparser's
|
||||
// markup delimiters, so detect the template and use a dedicated parser.
|
||||
if (src.find("]<]minimax[>[") != std::string::npos &&
|
||||
src.find("<tool_call>") != std::string::npos &&
|
||||
src.find("<invoke name=") != std::string::npos) {
|
||||
LOG_DBG("Using specialized template: MiniMax-M3\n");
|
||||
return common_chat_params_init_minimax_m3(tmpl, params);
|
||||
}
|
||||
|
||||
// DeepSeek V3.2/V4 format detection: template defines dsml_token and uses it for tool calls.
|
||||
// The template source contains the token as a variable assignment, not as a literal in markup.
|
||||
// V3.2 names the tool call block "function_calls", V4 names it "tool_calls".
|
||||
@@ -2998,6 +3267,8 @@ common_chat_msg common_chat_peg_parse(const common_peg_arena & src_pars
|
||||
std::unique_ptr<common_chat_peg_mapper> mapper;
|
||||
if (params.format == COMMON_CHAT_FORMAT_PEG_GEMMA4) {
|
||||
mapper = std::make_unique<common_chat_peg_gemma4_mapper>(msg);
|
||||
} else if (params.format == COMMON_CHAT_FORMAT_PEG_MINIMAX_M3) {
|
||||
mapper = std::make_unique<common_chat_peg_minimax_m3_mapper>(msg);
|
||||
} else {
|
||||
mapper = std::make_unique<common_chat_peg_mapper>(msg);
|
||||
}
|
||||
@@ -3020,6 +3291,8 @@ common_chat_msg common_chat_peg_parse(const common_peg_arena & src_pars
|
||||
std::unique_ptr<common_chat_peg_mapper> mapper;
|
||||
if (params.format == COMMON_CHAT_FORMAT_PEG_GEMMA4) {
|
||||
mapper = std::make_unique<common_chat_peg_gemma4_mapper>(msg);
|
||||
} else if (params.format == COMMON_CHAT_FORMAT_PEG_MINIMAX_M3) {
|
||||
mapper = std::make_unique<common_chat_peg_minimax_m3_mapper>(msg);
|
||||
} else {
|
||||
mapper = std::make_unique<common_chat_peg_mapper>(msg);
|
||||
}
|
||||
|
||||
@@ -233,6 +233,7 @@ enum common_chat_format {
|
||||
COMMON_CHAT_FORMAT_PEG_SIMPLE,
|
||||
COMMON_CHAT_FORMAT_PEG_NATIVE,
|
||||
COMMON_CHAT_FORMAT_PEG_GEMMA4,
|
||||
COMMON_CHAT_FORMAT_PEG_MINIMAX_M3,
|
||||
|
||||
COMMON_CHAT_FORMAT_COUNT, // Not a format, just the # formats
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user