Add MiniMax-M2 tool call support (add `PARSER minimaxm2` in Modelfile to enable)
This commit is contained in:
parent
18fdcc94e5
commit
a3bcb60738
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 & Machine Learning</parameter>
|
||||||
|
</invoke>
|
||||||
|
</minimax:tool_call>`,
|
||||||
|
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: "<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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -68,6 +68,8 @@ func ParserForName(name string) Parser {
|
||||||
return &Nemotron3NanoParser{}
|
return &Nemotron3NanoParser{}
|
||||||
case "functiongemma":
|
case "functiongemma":
|
||||||
return &FunctionGemmaParser{}
|
return &FunctionGemmaParser{}
|
||||||
|
case "minimaxm2":
|
||||||
|
return &MiniMaxM2Parser{}
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue