diff --git a/model/parsers/minimaxm2.go b/model/parsers/minimaxm2.go
new file mode 100644
index 000000000..b0c720384
--- /dev/null
+++ b/model/parsers/minimaxm2.go
@@ -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:
+//
+//
+// value
+//
+//
+//
+// And thinking format:
+// thinking content
+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 = ""
+ minimaxm2ThinkingCloseTag = ""
+ minimaxm2ToolCallOpenTag = ""
+ minimaxm2ToolCallCloseTag = ""
+ minimaxm2InvokeOpenPrefix = " 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[] -> 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
+ 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 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 blocks from a content
+func (p *MiniMaxM2Parser) parseToolCallBlock(content string) ([]api.ToolCall, []error) {
+ var toolCalls []api.ToolCall
+ var errors []error
+
+ remaining := content
+ for {
+ // Find next tag
+ invokeStartIdx := strings.Index(remaining, minimaxm2InvokeOpenPrefix)
+ if invokeStartIdx == -1 {
+ break
+ }
+
+ // Find the end of the opening 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 tag
+ invokeEndIdx := strings.Index(remaining[tagEndIdx+1:], minimaxm2InvokeCloseTag)
+ if invokeEndIdx == -1 {
+ // Missing closing tag - conservative recovery
+ slog.Warn("minimaxm2: missing 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 and
+ 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 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 tags
+ params := make(map[string]any)
+ remaining := content
+
+ for {
+ // Find next tag
+ paramStartIdx := strings.Index(remaining, minimaxm2ParameterOpenPrefix)
+ if paramStartIdx == -1 {
+ break
+ }
+
+ // Find the end of the opening 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 tag
+ paramEndIdx := strings.Index(remaining[tagEndIdx+1:], minimaxm2ParameterCloseTag)
+ if paramEndIdx == -1 {
+ // Missing closing tag - conservative recovery
+ slog.Warn("minimaxm2: missing 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: or
+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
+}
diff --git a/model/parsers/minimaxm2_test.go b/model/parsers/minimaxm2_test.go
new file mode 100644
index 000000000..9ec4d206e
--- /dev/null
+++ b/model/parsers/minimaxm2_test.go
@@ -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: `
+
+Tokyo
+
+`,
+ wantContent: "",
+ wantThinking: "",
+ wantCalls: []api.ToolCall{
+ {
+ Function: api.ToolCallFunction{
+ Name: "get_weather",
+ Arguments: map[string]any{
+ "location": "Tokyo",
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "single invoke with multiple parameters",
+ input: `
+
+San Francisco
+celsius
+
+`,
+ 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: `
+
+
+`,
+ wantContent: "",
+ wantThinking: "",
+ wantCalls: []api.ToolCall{
+ {
+ Function: api.ToolCallFunction{
+ Name: "calculate",
+ Arguments: map[string]any{},
+ },
+ },
+ },
+ },
+ {
+ name: "multiple invokes in one tool_call block",
+ input: `
+
+Tokyo
+
+
+London
+
+`,
+ 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: `
+
+{"keywords": ["AI", "news"]}
+
+`,
+ 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: `
+
+["AI", "machine learning"]
+
+`,
+ 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: `
+
+AI news
+10
+
+`,
+ 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. \n\nTokyo\n\n",
+ 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: "\n\nTokyo\n\n\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. \n\nTokyo\n\n\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: "I need to analyze this problem first.",
+ wantContent: "",
+ wantThinking: "I need to analyze this problem first.",
+ wantCalls: nil,
+ },
+ {
+ name: "thinking then tool call",
+ input: "I should check the weather.\n\n\nTokyo\n\n",
+ 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. This is complex. Okay, done.",
+ wantContent: "Let me think. Okay, done.",
+ wantThinking: "This is complex.",
+ wantCalls: nil,
+ },
+
+ // Edge cases
+ {
+ name: "whitespace in parameter values",
+ input: `
+
+ San Francisco
+
+`,
+ wantContent: "",
+ wantThinking: "",
+ wantCalls: []api.ToolCall{
+ {
+ Function: api.ToolCallFunction{
+ Name: "get_weather",
+ Arguments: map[string]any{
+ "location": "San Francisco",
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "empty parameter value",
+ input: `
+
+
+
+`,
+ wantContent: "",
+ wantThinking: "",
+ wantCalls: []api.ToolCall{
+ {
+ Function: api.ToolCallFunction{
+ Name: "get_weather",
+ Arguments: map[string]any{
+ "location": "",
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "special characters in values",
+ input: `
+
+AI & Machine Learning
+
+`,
+ wantContent: "",
+ wantThinking: "",
+ wantCalls: []api.ToolCall{
+ {
+ Function: api.ToolCallFunction{
+ Name: "search_web",
+ Arguments: map[string]any{
+ "query": "AI & Machine Learning",
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "tool call inside think tag (should treat as thinking)",
+ input: "Maybe I should call Tokyo",
+ wantContent: "",
+ wantThinking: "Maybe I should call Tokyo",
+ wantCalls: nil,
+ },
+ {
+ name: "multiple thinking blocks",
+ input: "First thought Some content. Second thought",
+ wantContent: "Some content. ",
+ wantThinking: "First thoughtSecond thought",
+ wantCalls: nil,
+ },
+ {
+ name: "multiple tool call blocks",
+ input: `
+
+Tokyo
+
+
+Some text.
+
+
+news
+
+`,
+ 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: `
+
+Tokyo
+`,
+ wantContent: "",
+ wantThinking: "",
+ wantCalls: []api.ToolCall{
+ {
+ Function: api.ToolCallFunction{
+ Name: "get_weather",
+ Arguments: map[string]any{
+ "location": "Tokyo",
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "missing closing invoke tag",
+ input: `
+
+Tokyo
+`,
+ wantContent: "",
+ wantThinking: "",
+ wantCalls: []api.ToolCall{
+ {
+ Function: api.ToolCallFunction{
+ Name: "get_weather",
+ Arguments: map[string]any{
+ "location": "Tokyo",
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "parameter with newlines (should trim)",
+ input: `
+
+
+Tokyo
+
+
+`,
+ 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. \n\n",
+ "Tokyo\n",
+ "\n",
+ },
+ 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{
+ "\n\n",
+ "Tokyo\n\n",
+ },
+ 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{
+ "\n\n",
+ "San Fran",
+ "cisco\n\n",
+ },
+ 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{
+ "\n\n",
+ "Tokyo\n\n",
+ },
+ 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. This is complex. 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: `
+
+value
+
+`,
+ wantError: true,
+ },
+ {
+ name: "no tools provided but model makes tool call",
+ input: `\n\nTokyo\n\n`,
+ 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: ``,
+ want: "get_weather",
+ },
+ {
+ name: "single quotes",
+ tag: ``,
+ want: "get_weather",
+ },
+ {
+ name: "with extra attributes",
+ tag: ``,
+ want: "get_weather",
+ },
+ {
+ name: "parameter tag",
+ tag: ``,
+ want: "location",
+ },
+ {
+ name: "no name attribute",
+ tag: ``,
+ want: "",
+ },
+ {
+ name: "malformed quote",
+ tag: `