Add MiniMax-M2 tool call support (add `PARSER minimaxm2` in Modelfile to enable)

This commit is contained in:
Yu-Cheng (Henry) Huang 2025-12-28 11:27:58 +08:00
parent 18fdcc94e5
commit a3bcb60738
3 changed files with 1290 additions and 0 deletions

530
model/parsers/minimaxm2.go Normal file
View File

@ -0,0 +1,530 @@
package parsers
import (
"encoding/json"
"fmt"
"log/slog"
"strings"
"unicode"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/logutil"
)
// MiniMaxM2Parser handles MiniMax-M2's XML-style tool call format:
// <minimax:tool_call>
// <invoke name="function_name">
// <parameter name="key">value</parameter>
// </invoke>
// </minimax:tool_call>
//
// And thinking format:
// <think>thinking content</think>
type MiniMaxM2Parser struct {
state MiniMaxM2ParserState
buffer strings.Builder
tools []api.Tool
err error // Store critical errors (like unknown tools)
}
type MiniMaxM2ParserState int
const (
MiniMaxM2CollectingContent MiniMaxM2ParserState = iota
MiniMaxM2CollectingThinking
MiniMaxM2CollectingToolCalls
)
const (
minimaxm2ThinkingOpenTag = "<think>"
minimaxm2ThinkingCloseTag = "</think>"
minimaxm2ToolCallOpenTag = "<minimax:tool_call>"
minimaxm2ToolCallCloseTag = "</minimax:tool_call>"
minimaxm2InvokeOpenPrefix = "<invoke"
minimaxm2InvokeCloseTag = "</invoke>"
minimaxm2ParameterOpenPrefix = "<parameter"
minimaxm2ParameterCloseTag = "</parameter>"
)
func (p *MiniMaxM2Parser) HasToolSupport() bool {
return true
}
func (p *MiniMaxM2Parser) HasThinkingSupport() bool {
return true
}
func (p *MiniMaxM2Parser) setInitialState(lastMessage *api.Message, tools []api.Tool, thinkValue *api.ThinkValue) {
prefill := lastMessage != nil && lastMessage.Role == "assistant"
// Check both model capability AND request preference
thinkingEnabled := thinkValue != nil && thinkValue.Bool()
// If tools are present, we don't start in thinking mode
if len(tools) > 0 {
p.state = MiniMaxM2CollectingContent
return
}
if !thinkingEnabled {
p.state = MiniMaxM2CollectingContent
return
}
if prefill && lastMessage.Content != "" {
p.state = MiniMaxM2CollectingContent
return
}
p.state = MiniMaxM2CollectingThinking
}
func (p *MiniMaxM2Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.tools = tools
p.err = nil
p.setInitialState(lastMessage, tools, thinkValue)
return tools
}
// Event types
type minimaxm2Event interface {
isMiniMaxM2Event()
}
type minimaxm2EventContent struct {
content string
}
type minimaxm2EventThinkingContent struct {
content string
}
type minimaxm2EventToolCall struct {
toolCall api.ToolCall
}
func (minimaxm2EventContent) isMiniMaxM2Event() {}
func (minimaxm2EventThinkingContent) isMiniMaxM2Event() {}
func (minimaxm2EventToolCall) isMiniMaxM2Event() {}
func (p *MiniMaxM2Parser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
events := p.parseEvents()
// Check for critical errors
if p.err != nil {
return "", "", nil, p.err
}
var toolCalls []api.ToolCall
var contentSb strings.Builder
var thinkingSb strings.Builder
for _, event := range events {
switch event := event.(type) {
case minimaxm2EventToolCall:
toolCalls = append(toolCalls, event.toolCall)
case minimaxm2EventThinkingContent:
thinkingSb.WriteString(event.content)
case minimaxm2EventContent:
contentSb.WriteString(event.content)
}
}
return contentSb.String(), thinkingSb.String(), toolCalls, nil
}
func (p *MiniMaxM2Parser) parseEvents() []minimaxm2Event {
var all []minimaxm2Event
keepLooping := true
for keepLooping && p.err == nil {
var events []minimaxm2Event
events, keepLooping = p.eat()
if len(events) > 0 {
all = append(all, events...)
}
}
return all
}
func (p *MiniMaxM2Parser) eat() ([]minimaxm2Event, bool) {
var events []minimaxm2Event
bufStr := p.buffer.String()
if bufStr == "" {
return events, false
}
switch p.state {
case MiniMaxM2CollectingThinking:
if strings.Contains(bufStr, minimaxm2ThinkingCloseTag) {
// thinking[</think>] -> content
split := strings.SplitN(bufStr, minimaxm2ThinkingCloseTag, 2)
thinking := split[0]
thinking = strings.TrimRightFunc(thinking, unicode.IsSpace)
remaining := split[1]
remaining = strings.TrimLeftFunc(remaining, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = MiniMaxM2CollectingContent
if len(thinking) > 0 {
events = append(events, minimaxm2EventThinkingContent{content: thinking})
}
return events, true
} else if overlapLen := overlap(bufStr, minimaxm2ThinkingCloseTag); overlapLen > 0 {
// partial </think>
beforePartialTag := bufStr[:len(bufStr)-overlapLen]
trailingLen := trailingWhitespaceLen(beforePartialTag)
ambiguousStart := len(beforePartialTag) - trailingLen
unambiguous := bufStr[:ambiguousStart]
ambiguous := bufStr[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, minimaxm2EventThinkingContent{content: unambiguous})
}
return events, false
} else {
// otherwise it's thinking content
whitespaceLen := trailingWhitespaceLen(bufStr)
ambiguousStart := len(bufStr) - whitespaceLen
unambiguous := bufStr[:ambiguousStart]
ambiguous := bufStr[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, minimaxm2EventThinkingContent{content: unambiguous})
}
return events, false
}
case MiniMaxM2CollectingContent:
// Check which tag appears first
toolCallIdx := strings.Index(bufStr, minimaxm2ToolCallOpenTag)
thinkIdx := strings.Index(bufStr, minimaxm2ThinkingOpenTag)
// Determine which tag comes first
var tagIdx int
var tagName string
var nextState MiniMaxM2ParserState
if toolCallIdx >= 0 && (thinkIdx < 0 || toolCallIdx < thinkIdx) {
tagIdx = toolCallIdx
tagName = minimaxm2ToolCallOpenTag
nextState = MiniMaxM2CollectingToolCalls
} else if thinkIdx >= 0 {
tagIdx = thinkIdx
tagName = minimaxm2ThinkingOpenTag
nextState = MiniMaxM2CollectingThinking
} else {
tagIdx = -1
}
if tagIdx >= 0 {
// Found a tag - emit content before it
before := bufStr[:tagIdx]
if before != "" {
events = append(events, minimaxm2EventContent{content: before})
}
// Move past the tag
remaining := bufStr[tagIdx+len(tagName):]
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = nextState
logutil.Trace("minimaxm2: found tag", "tag", tagName, "before", before)
return events, true
}
// No complete tag found - check for partial tags at end
toolCallOverlap := overlap(bufStr, minimaxm2ToolCallOpenTag)
thinkingOverlap := overlap(bufStr, minimaxm2ThinkingOpenTag)
maxOverlap := max(toolCallOverlap, thinkingOverlap)
if maxOverlap > 0 {
// Hold back potential partial tag
content := bufStr[:len(bufStr)-maxOverlap]
if content != "" {
events = append(events, minimaxm2EventContent{content: content})
}
// Keep the potential partial tag in buffer
p.buffer.Reset()
p.buffer.WriteString(bufStr[len(bufStr)-maxOverlap:])
logutil.Trace("minimaxm2: holding potential partial tag", "overlap", maxOverlap)
return events, false
}
// No partial tag - emit everything
if bufStr != "" {
events = append(events, minimaxm2EventContent{content: bufStr})
p.buffer.Reset()
}
return events, false
case MiniMaxM2CollectingToolCalls:
if strings.Contains(bufStr, minimaxm2ToolCallCloseTag) {
// Found closing tag - parse all tool calls in the block
split := strings.SplitN(bufStr, minimaxm2ToolCallCloseTag, 2)
toolCallBlock := split[0]
remaining := split[1]
remaining = strings.TrimLeftFunc(remaining, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = MiniMaxM2CollectingContent
// Parse all <invoke> blocks within this tool call block
toolCalls, errs := p.parseToolCallBlock(toolCallBlock)
// If there were critical errors (unknown tools), don't emit any events
// This allows errors to propagate properly
if len(errs) == 0 {
for _, tc := range toolCalls {
events = append(events, minimaxm2EventToolCall{toolCall: tc})
}
} else {
// Log warnings for non-critical errors, but still emit successful tool calls
for _, err := range errs {
slog.Warn("minimaxm2 tool call parsing failed", "error", err)
}
// Only emit successfully parsed tool calls
for _, tc := range toolCalls {
events = append(events, minimaxm2EventToolCall{toolCall: tc})
}
}
return events, true
}
// Check for partial closing tag
if overlapLen := overlap(bufStr, minimaxm2ToolCallCloseTag); overlapLen > 0 {
// Hold back potential partial closing tag
return events, false
}
// Still collecting tool call content
return events, false
}
return events, false
}
// parseToolCallBlock extracts all <invoke> blocks from a <minimax:tool_call> content
func (p *MiniMaxM2Parser) parseToolCallBlock(content string) ([]api.ToolCall, []error) {
var toolCalls []api.ToolCall
var errors []error
remaining := content
for {
// Find next <invoke> tag
invokeStartIdx := strings.Index(remaining, minimaxm2InvokeOpenPrefix)
if invokeStartIdx == -1 {
break
}
// Find the end of the opening <invoke> tag (the '>')
tagEndIdx := strings.Index(remaining[invokeStartIdx:], ">")
if tagEndIdx == -1 {
logutil.Trace("minimaxm2: incomplete invoke opening tag")
break
}
tagEndIdx += invokeStartIdx
// Extract the opening tag to get the name attribute
openingTag := remaining[invokeStartIdx : tagEndIdx+1]
functionName := extractNameAttribute(openingTag)
if functionName == "" {
err := fmt.Errorf("invoke tag missing name attribute: %s", openingTag)
slog.Warn("minimaxm2: invoke tag missing name attribute", "tag", openingTag)
errors = append(errors, err)
remaining = remaining[tagEndIdx+1:]
continue
}
// Find the closing </invoke> tag
invokeEndIdx := strings.Index(remaining[tagEndIdx+1:], minimaxm2InvokeCloseTag)
if invokeEndIdx == -1 {
// Missing closing tag - conservative recovery
slog.Warn("minimaxm2: missing </invoke> tag, recovering", "function", functionName)
// Try to parse what we have up to the end
invokeContent := remaining[tagEndIdx+1:]
toolCall, err := p.parseInvoke(functionName, invokeContent)
if err != nil {
errors = append(errors, err)
} else {
toolCalls = append(toolCalls, toolCall)
}
break
}
invokeEndIdx += tagEndIdx + 1
// Extract the content between <invoke> and </invoke>
invokeContent := remaining[tagEndIdx+1 : invokeEndIdx]
// Parse this invoke
toolCall, err := p.parseInvoke(functionName, invokeContent)
if err != nil {
slog.Warn("minimaxm2: failed to parse invoke", "function", functionName, "error", err)
errors = append(errors, err)
} else {
toolCalls = append(toolCalls, toolCall)
logutil.Trace("minimaxm2: parsed tool call", "name", functionName, "args", toolCall.Function.Arguments)
}
// Move past this invoke
remaining = remaining[invokeEndIdx+len(minimaxm2InvokeCloseTag):]
}
return toolCalls, errors
}
// parseInvoke extracts the function name and parameters from an <invoke> block
func (p *MiniMaxM2Parser) parseInvoke(functionName string, content string) (api.ToolCall, error) {
// Validate tool exists
tool := p.findToolByName(functionName)
if tool == nil {
availableTools := make([]string, len(p.tools))
for i, t := range p.tools {
availableTools[i] = t.Function.Name
}
// Store critical error to halt processing
p.err = fmt.Errorf("model called unknown tool %q - available tools: %v (ensure tools are provided in API request)", functionName, availableTools)
slog.Error("MiniMaxM2 model attempted to call unregistered tool",
"tool", functionName,
"available_tools", availableTools,
"recommendation", "ensure tools array includes this tool in API request")
return api.ToolCall{}, p.err
}
// Extract all <parameter> tags
params := make(map[string]any)
remaining := content
for {
// Find next <parameter> tag
paramStartIdx := strings.Index(remaining, minimaxm2ParameterOpenPrefix)
if paramStartIdx == -1 {
break
}
// Find the end of the opening <parameter> tag (the '>')
tagEndIdx := strings.Index(remaining[paramStartIdx:], ">")
if tagEndIdx == -1 {
logutil.Trace("minimaxm2: incomplete parameter opening tag")
break
}
tagEndIdx += paramStartIdx
// Extract the opening tag to get the name attribute
openingTag := remaining[paramStartIdx : tagEndIdx+1]
paramName := extractNameAttribute(openingTag)
if paramName == "" {
slog.Warn("minimaxm2: parameter tag missing name attribute", "tag", openingTag)
remaining = remaining[tagEndIdx+1:]
continue
}
// Find the closing </parameter> tag
paramEndIdx := strings.Index(remaining[tagEndIdx+1:], minimaxm2ParameterCloseTag)
if paramEndIdx == -1 {
// Missing closing tag - conservative recovery
slog.Warn("minimaxm2: missing </parameter> tag for parameter, recovering", "parameter", paramName)
// Take everything until the next parameter or end of content
nextParamIdx := strings.Index(remaining[tagEndIdx+1:], minimaxm2ParameterOpenPrefix)
if nextParamIdx == -1 {
// No more parameters, take rest of content
paramValue := strings.TrimSpace(remaining[tagEndIdx+1:])
params[paramName] = parseParameterValue(paramValue)
break
} else {
// Take up to next parameter
paramValue := strings.TrimSpace(remaining[tagEndIdx+1 : tagEndIdx+1+nextParamIdx])
params[paramName] = parseParameterValue(paramValue)
remaining = remaining[tagEndIdx+1+nextParamIdx:]
continue
}
}
paramEndIdx += tagEndIdx + 1
// Extract the parameter value
paramValue := strings.TrimSpace(remaining[tagEndIdx+1 : paramEndIdx])
params[paramName] = parseParameterValue(paramValue)
logutil.Trace("minimaxm2: parsed parameter", "name", paramName, "value", params[paramName])
// Move past this parameter
remaining = remaining[paramEndIdx+len(minimaxm2ParameterCloseTag):]
}
return api.ToolCall{
Function: api.ToolCallFunction{
Name: tool.Function.Name,
Arguments: params,
},
}, nil
}
// extractNameAttribute extracts the value of name="..." from an opening tag
// Handles: <invoke name="function_name"> or <parameter name="param_name">
func extractNameAttribute(tag string) string {
// Look for name="..."
nameAttrIdx := strings.Index(tag, `name="`)
if nameAttrIdx == -1 {
// Try with single quotes: name='...'
nameAttrIdx = strings.Index(tag, `name='`)
if nameAttrIdx == -1 {
return ""
}
// Extract with single quotes
start := nameAttrIdx + len(`name='`)
end := strings.Index(tag[start:], `'`)
if end == -1 {
return ""
}
return tag[start : start+end]
}
// Extract with double quotes
start := nameAttrIdx + len(`name="`)
end := strings.Index(tag[start:], `"`)
if end == -1 {
return ""
}
return tag[start : start+end]
}
// parseParameterValue attempts to parse value as JSON, falls back to string
func parseParameterValue(value string) any {
// Remove leading and trailing newlines
value = strings.TrimPrefix(value, "\n")
value = strings.TrimSuffix(value, "\n")
// Check for null
if strings.ToLower(value) == "null" {
return nil
}
// Try to parse as JSON (handles objects, arrays, numbers, booleans)
var jsonValue any
if err := json.Unmarshal([]byte(value), &jsonValue); err == nil {
return jsonValue
}
// Fallback to string
return value
}
func (p *MiniMaxM2Parser) findToolByName(name string) *api.Tool {
name = strings.TrimSpace(name)
for i := range p.tools {
if p.tools[i].Function.Name == name {
return &p.tools[i]
}
}
return nil
}

View File

@ -0,0 +1,758 @@
package parsers
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
)
func TestMiniMaxM2Parser(t *testing.T) {
tools := []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Description: "Get the weather for a location",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: map[string]api.ToolProperty{
"location": {
Type: api.PropertyType{"string"},
},
"unit": {
Type: api.PropertyType{"string"},
},
},
},
},
},
{
Type: "function",
Function: api.ToolFunction{
Name: "search_web",
Description: "Search the web",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: map[string]api.ToolProperty{
"query": {
Type: api.PropertyType{"string"},
},
"limit": {
Type: api.PropertyType{"integer"},
},
},
},
},
},
{
Type: "function",
Function: api.ToolFunction{
Name: "calculate",
Description: "Perform a calculation",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: map[string]api.ToolProperty{
"expression": {
Type: api.PropertyType{"string"},
},
},
},
},
},
}
tests := []struct {
name string
input string
wantContent string
wantThinking string
wantCalls []api.ToolCall
wantError bool
}{
// Basic functionality
{
name: "simple content",
input: "Hello world",
wantContent: "Hello world",
wantThinking: "",
wantCalls: nil,
},
{
name: "single invoke with single parameter",
input: `<minimax:tool_call>
<invoke name="get_weather">
<parameter name="location">Tokyo</parameter>
</invoke>
</minimax:tool_call>`,
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{
"location": "Tokyo",
},
},
},
},
},
{
name: "single invoke with multiple parameters",
input: `<minimax:tool_call>
<invoke name="get_weather">
<parameter name="location">San Francisco</parameter>
<parameter name="unit">celsius</parameter>
</invoke>
</minimax:tool_call>`,
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{
"location": "San Francisco",
"unit": "celsius",
},
},
},
},
},
{
name: "single invoke with no parameters",
input: `<minimax:tool_call>
<invoke name="calculate">
</invoke>
</minimax:tool_call>`,
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "calculate",
Arguments: map[string]any{},
},
},
},
},
{
name: "multiple invokes in one tool_call block",
input: `<minimax:tool_call>
<invoke name="get_weather">
<parameter name="location">Tokyo</parameter>
</invoke>
<invoke name="get_weather">
<parameter name="location">London</parameter>
</invoke>
</minimax:tool_call>`,
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{
"location": "Tokyo",
},
},
},
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{
"location": "London",
},
},
},
},
},
{
name: "parameter with JSON object value",
input: `<minimax:tool_call>
<invoke name="search_web">
<parameter name="query">{"keywords": ["AI", "news"]}</parameter>
</invoke>
</minimax:tool_call>`,
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "search_web",
Arguments: map[string]any{
"query": map[string]any{
"keywords": []any{"AI", "news"},
},
},
},
},
},
},
{
name: "parameter with JSON array value",
input: `<minimax:tool_call>
<invoke name="search_web">
<parameter name="query">["AI", "machine learning"]</parameter>
</invoke>
</minimax:tool_call>`,
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "search_web",
Arguments: map[string]any{
"query": []any{"AI", "machine learning"},
},
},
},
},
},
{
name: "parameter with integer value",
input: `<minimax:tool_call>
<invoke name="search_web">
<parameter name="query">AI news</parameter>
<parameter name="limit">10</parameter>
</invoke>
</minimax:tool_call>`,
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "search_web",
Arguments: map[string]any{
"query": "AI news",
"limit": float64(10), // JSON unmarshals numbers as float64
},
},
},
},
},
// Content mixing
{
name: "content before tool call",
input: "Let me check the weather. <minimax:tool_call>\n<invoke name=\"get_weather\">\n<parameter name=\"location\">Tokyo</parameter>\n</invoke>\n</minimax:tool_call>",
wantContent: "Let me check the weather. ",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{
"location": "Tokyo",
},
},
},
},
},
{
name: "content after tool call",
input: "<minimax:tool_call>\n<invoke name=\"get_weather\">\n<parameter name=\"location\">Tokyo</parameter>\n</invoke>\n</minimax:tool_call>\nHere is the weather.",
wantContent: "Here is the weather.",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{
"location": "Tokyo",
},
},
},
},
},
{
name: "content before and after tool call",
input: "Let me check. <minimax:tool_call>\n<invoke name=\"get_weather\">\n<parameter name=\"location\">Tokyo</parameter>\n</invoke>\n</minimax:tool_call>\nDone checking.",
wantContent: "Let me check. Done checking.",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{
"location": "Tokyo",
},
},
},
},
},
{
name: "thinking content only",
input: "<think>I need to analyze this problem first.</think>",
wantContent: "",
wantThinking: "I need to analyze this problem first.",
wantCalls: nil,
},
{
name: "thinking then tool call",
input: "<think>I should check the weather.</think>\n<minimax:tool_call>\n<invoke name=\"get_weather\">\n<parameter name=\"location\">Tokyo</parameter>\n</invoke>\n</minimax:tool_call>",
wantContent: "",
wantThinking: "I should check the weather.",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{
"location": "Tokyo",
},
},
},
},
},
{
name: "thinking with content",
input: "Let me think. <think>This is complex.</think> Okay, done.",
wantContent: "Let me think. Okay, done.",
wantThinking: "This is complex.",
wantCalls: nil,
},
// Edge cases
{
name: "whitespace in parameter values",
input: `<minimax:tool_call>
<invoke name="get_weather">
<parameter name="location"> San Francisco </parameter>
</invoke>
</minimax:tool_call>`,
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{
"location": "San Francisco",
},
},
},
},
},
{
name: "empty parameter value",
input: `<minimax:tool_call>
<invoke name="get_weather">
<parameter name="location"></parameter>
</invoke>
</minimax:tool_call>`,
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{
"location": "",
},
},
},
},
},
{
name: "special characters in values",
input: `<minimax:tool_call>
<invoke name="search_web">
<parameter name="query">AI &amp; Machine Learning</parameter>
</invoke>
</minimax:tool_call>`,
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "search_web",
Arguments: map[string]any{
"query": "AI &amp; Machine Learning",
},
},
},
},
},
{
name: "tool call inside think tag (should treat as thinking)",
input: "<think>Maybe I should call <minimax:tool_call><invoke name=\"get_weather\"><parameter name=\"location\">Tokyo</parameter></invoke></minimax:tool_call></think>",
wantContent: "",
wantThinking: "Maybe I should call <minimax:tool_call><invoke name=\"get_weather\"><parameter name=\"location\">Tokyo</parameter></invoke></minimax:tool_call>",
wantCalls: nil,
},
{
name: "multiple thinking blocks",
input: "<think>First thought</think> Some content. <think>Second thought</think>",
wantContent: "Some content. ",
wantThinking: "First thoughtSecond thought",
wantCalls: nil,
},
{
name: "multiple tool call blocks",
input: `<minimax:tool_call>
<invoke name="get_weather">
<parameter name="location">Tokyo</parameter>
</invoke>
</minimax:tool_call>
Some text.
<minimax:tool_call>
<invoke name="search_web">
<parameter name="query">news</parameter>
</invoke>
</minimax:tool_call>`,
wantContent: "Some text.\n",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{
"location": "Tokyo",
},
},
},
{
Function: api.ToolCallFunction{
Name: "search_web",
Arguments: map[string]any{
"query": "news",
},
},
},
},
},
// Recovery scenarios
{
name: "missing closing parameter tag",
input: `<minimax:tool_call>
<invoke name="get_weather">
<parameter name="location">Tokyo</invoke>
</minimax:tool_call>`,
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{
"location": "Tokyo",
},
},
},
},
},
{
name: "missing closing invoke tag",
input: `<minimax:tool_call>
<invoke name="get_weather">
<parameter name="location">Tokyo</parameter>
</minimax:tool_call>`,
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{
"location": "Tokyo",
},
},
},
},
},
{
name: "parameter with newlines (should trim)",
input: `<minimax:tool_call>
<invoke name="get_weather">
<parameter name="location">
Tokyo
</parameter>
</invoke>
</minimax:tool_call>`,
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{
"location": "Tokyo",
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &MiniMaxM2Parser{}
parser.Init(tools, nil, nil)
gotContent, gotThinking, gotCalls, err := parser.Add(tt.input, true)
if (err != nil) != tt.wantError {
t.Errorf("Add() error = %v, wantError %v", err, tt.wantError)
return
}
if gotContent != tt.wantContent {
t.Errorf("Add() content = %q, want %q", gotContent, tt.wantContent)
}
if gotThinking != tt.wantThinking {
t.Errorf("Add() thinking = %q, want %q", gotThinking, tt.wantThinking)
}
if diff := cmp.Diff(tt.wantCalls, gotCalls); diff != "" {
t.Errorf("Add() calls mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestMiniMaxM2ParserStreaming(t *testing.T) {
tools := []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: map[string]api.ToolProperty{
"location": {Type: api.PropertyType{"string"}},
},
},
},
},
}
tests := []struct {
name string
chunks []string
wantContent string
wantThinking string
wantCalls []api.ToolCall
}{
{
name: "tool call tag split across chunks",
chunks: []string{
"Let me check. <minimax:tool",
"_call>\n<invoke name=\"get_weather\">\n",
"<parameter name=\"location\">Tokyo</parameter>\n",
"</invoke>\n</minimax:tool_call>",
},
wantContent: "Let me check. ",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{"location": "Tokyo"},
},
},
},
},
{
name: "invoke tag split across chunks",
chunks: []string{
"<minimax:tool_call>\n<inv",
"oke name=\"get_weather\">\n<parameter name=\"location\">",
"Tokyo</parameter>\n</invoke>\n</minimax:tool_call>",
},
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{"location": "Tokyo"},
},
},
},
},
{
name: "parameter value split across chunks",
chunks: []string{
"<minimax:tool_call>\n<invoke name=\"get_weather\">\n",
"<parameter name=\"location\">San Fran",
"cisco</parameter>\n</invoke>\n</minimax:tool_call>",
},
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{"location": "San Francisco"},
},
},
},
},
{
name: "closing tag split across chunks",
chunks: []string{
"<minimax:tool_call>\n<invoke name=\"get_weather\">\n",
"<parameter name=\"location\">Tokyo</parameter>\n</inv",
"oke>\n</minimax:tool_call>",
},
wantContent: "",
wantThinking: "",
wantCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: map[string]any{"location": "Tokyo"},
},
},
},
},
{
name: "thinking tag split across chunks",
chunks: []string{
"Let me think. <thi",
"nk>This is complex.</th",
"ink> Done.",
},
wantContent: "Let me think. Done.",
wantThinking: "This is complex.",
wantCalls: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &MiniMaxM2Parser{}
parser.Init(tools, nil, nil)
var allContent, allThinking string
var allCalls []api.ToolCall
for i, chunk := range tt.chunks {
isDone := i == len(tt.chunks)-1
content, thinking, calls, err := parser.Add(chunk, isDone)
if err != nil {
t.Fatalf("Add() error = %v", err)
}
allContent += content
allThinking += thinking
allCalls = append(allCalls, calls...)
}
if allContent != tt.wantContent {
t.Errorf("Streaming content = %q, want %q", allContent, tt.wantContent)
}
if allThinking != tt.wantThinking {
t.Errorf("Streaming thinking = %q, want %q", allThinking, tt.wantThinking)
}
if diff := cmp.Diff(tt.wantCalls, allCalls); diff != "" {
t.Errorf("Streaming calls mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestMiniMaxM2ParserErrors(t *testing.T) {
tools := []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: map[string]api.ToolProperty{},
},
},
},
}
tests := []struct {
name string
input string
wantError bool
}{
{
name: "unknown tool",
input: `<minimax:tool_call>
<invoke name="unknown_function">
<parameter name="param">value</parameter>
</invoke>
</minimax:tool_call>`,
wantError: true,
},
{
name: "no tools provided but model makes tool call",
input: `<minimax:tool_call>\n<invoke name="get_weather">\n<parameter name="location">Tokyo</parameter>\n</invoke>\n</minimax:tool_call>`,
wantError: true, // Should error when tool is not in registry
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &MiniMaxM2Parser{}
if tt.name == "no tools provided but model makes tool call" {
parser.Init(nil, nil, nil) // No tools
} else {
parser.Init(tools, nil, nil)
}
_, _, _, err := parser.Add(tt.input, true)
if (err != nil) != tt.wantError {
t.Errorf("Add() error = %v, wantError %v", err, tt.wantError)
}
})
}
}
func TestMiniMaxM2ParserAttributeExtraction(t *testing.T) {
tests := []struct {
name string
tag string
want string
}{
{
name: "double quotes",
tag: `<invoke name="get_weather">`,
want: "get_weather",
},
{
name: "single quotes",
tag: `<invoke name='get_weather'>`,
want: "get_weather",
},
{
name: "with extra attributes",
tag: `<invoke name="get_weather" id="123">`,
want: "get_weather",
},
{
name: "parameter tag",
tag: `<parameter name="location">`,
want: "location",
},
{
name: "no name attribute",
tag: `<invoke>`,
want: "",
},
{
name: "malformed quote",
tag: `<invoke name="get_weather>`,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractNameAttribute(tt.tag)
if got != tt.want {
t.Errorf("extractNameAttribute() = %q, want %q", got, tt.want)
}
})
}
}

View File

@ -68,6 +68,8 @@ func ParserForName(name string) Parser {
return &Nemotron3NanoParser{}
case "functiongemma":
return &FunctionGemmaParser{}
case "minimaxm2":
return &MiniMaxM2Parser{}
default:
return nil
}