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: `