model/parsers: fix missing parallel tool call indices (#15467)

We were missing setting the function index for several models that can
make parallel tool calls.

In the future we may want to consider putting some sort of post-parse
hook and relieve the parsers of this duty.

Fixes: #15457
This commit is contained in:
Devon Rifkin
2026-04-10 15:23:21 -07:00
committed by GitHub
parent 9517864603
commit fdfe9cec98
16 changed files with 186 additions and 17 deletions

View File

@@ -33,8 +33,9 @@ const (
)
type CogitoParser struct {
state CogitoParserState
buffer strings.Builder
state CogitoParserState
buffer strings.Builder
callIndex int
}
func (p *CogitoParser) HasToolSupport() bool {
@@ -72,6 +73,7 @@ func (p *CogitoParser) setInitialState(lastMessage *api.Message, tools []api.Too
}
func (p *CogitoParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.callIndex = 0
p.setInitialState(lastMessage, tools, thinkValue)
return tools
}
@@ -114,6 +116,11 @@ func (p *CogitoParser) Add(s string, done bool) (content string, thinking string
}
}
for i := range toolCalls {
toolCalls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentSb.String(), thinkingSb.String(), toolCalls, nil
}

View File

@@ -39,7 +39,8 @@ func TestCogitoParser(t *testing.T) {
expectedToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Index: 0,
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
}),
@@ -110,7 +111,8 @@ func TestCogitoParser(t *testing.T) {
},
{
Function: api.ToolCallFunction{
Name: "get_weather",
Index: 1,
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "London",
}),

View File

@@ -33,6 +33,7 @@ const (
type DeepSeek3Parser struct {
state DeepSeek3ParserState
buffer strings.Builder
callIndex int
hasThinkingSupport bool
}
@@ -64,6 +65,7 @@ func (p *DeepSeek3Parser) setInitialState(lastMessage *api.Message, tools []api.
}
func (p *DeepSeek3Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.callIndex = 0
p.setInitialState(lastMessage, tools, thinkValue)
return tools
}
@@ -106,6 +108,11 @@ func (p *DeepSeek3Parser) Add(s string, done bool) (content string, thinking str
}
}
for i := range toolCalls {
toolCalls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentSb.String(), thinkingSb.String(), toolCalls, nil
}

View File

@@ -50,7 +50,8 @@ func TestDeepSeekParser(t *testing.T) {
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Index: 0,
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
}),
@@ -74,7 +75,8 @@ func TestDeepSeekParser(t *testing.T) {
},
{
Function: api.ToolCallFunction{
Name: "get_weather",
Index: 1,
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "London",
}),

View File

@@ -22,9 +22,10 @@ const (
// This format uses <start_function_call>call:name{args}<end_function_call> for tool calls.
type FunctionGemmaParser struct {
state FunctionGemmaParserState
buffer strings.Builder
tools []api.Tool
state FunctionGemmaParserState
buffer strings.Builder
tools []api.Tool
callIndex int
}
func (p *FunctionGemmaParser) HasToolSupport() bool { return true }
@@ -33,6 +34,7 @@ func (p *FunctionGemmaParser) HasThinkingSupport() bool { return false }
func (p *FunctionGemmaParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.tools = tools
p.state = FunctionGemmaCollectingContent
p.callIndex = 0
return tools
}
@@ -66,6 +68,11 @@ func (p *FunctionGemmaParser) Add(s string, done bool) (content string, thinking
}
}
for i := range toolCalls {
toolCalls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentSb.String(), "", toolCalls, nil
}

View File

@@ -124,12 +124,14 @@ func TestFunctionGemmaParser(t *testing.T) {
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Index: 0,
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
},
{
Function: api.ToolCallFunction{
Index: 1,
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "London"}),
},
@@ -345,12 +347,14 @@ func TestFunctionGemmaParser(t *testing.T) {
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Index: 0,
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
},
{
Function: api.ToolCallFunction{
Index: 1,
Name: "get_time",
Arguments: testArgs(map[string]any{"timezone": "UTC"}),
},
@@ -372,12 +376,14 @@ func TestFunctionGemmaParser(t *testing.T) {
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Index: 0,
Name: "first",
Arguments: api.NewToolCallFunctionArguments(),
},
},
{
Function: api.ToolCallFunction{
Index: 1,
Name: "second",
Arguments: api.NewToolCallFunctionArguments(),
},

View File

@@ -38,6 +38,7 @@ type Gemma4Parser struct {
state Gemma4ParserState
buffer strings.Builder
tools []api.Tool
callIndex int
hasThinkingSupport bool
thinkingEnabled bool // true when both model supports and user requested thinking
needsChannelNameStrip bool // true when we just entered thinking and need to strip "thought\n"
@@ -53,6 +54,7 @@ func (p *Gemma4Parser) HasThinkingSupport() bool {
func (p *Gemma4Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.tools = tools
p.callIndex = 0
prefill := lastMessage != nil && lastMessage.Role == "assistant"
@@ -116,6 +118,11 @@ func (p *Gemma4Parser) Add(s string, done bool) (content string, thinking string
}
}
for i := range toolCalls {
toolCalls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentSb.String(), thinkingSb.String(), toolCalls, nil
}

View File

@@ -290,7 +290,8 @@ func TestGemma4Parser(t *testing.T) {
expectedToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Index: 0,
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
}),
@@ -298,7 +299,8 @@ func TestGemma4Parser(t *testing.T) {
},
{
Function: api.ToolCallFunction{
Name: "get_weather",
Index: 1,
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "London",
}),

View File

@@ -29,6 +29,7 @@ const (
type LFM2Parser struct {
state LFM2ParserState
buffer strings.Builder
callIndex int
hasThinkingSupport bool
needsThinkingLeadingTrim bool // trim leading whitespace after <think> tag
needsContentLeadingTrim bool // trim leading whitespace after </think> tag
@@ -66,6 +67,7 @@ func (p *LFM2Parser) setInitialState(lastMessage *api.Message, thinkValue *api.T
func (p *LFM2Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.toolNames = make(map[string]struct{}, len(tools))
p.callIndex = 0
p.hasTools = len(tools) > 0
for _, tool := range tools {
if tool.Function.Name != "" {
@@ -123,6 +125,11 @@ func (p *LFM2Parser) Add(s string, done bool) (content string, thinking string,
}
}
for i := range toolCalls {
toolCalls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentSb.String(), thinkingSb.String(), toolCalls, nil
}

View File

@@ -60,7 +60,8 @@ func TestLFM2Parser(t *testing.T) {
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Index: 0,
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
}),
@@ -68,7 +69,8 @@ func TestLFM2Parser(t *testing.T) {
},
{
Function: api.ToolCallFunction{
Name: "get_weather",
Index: 1,
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "London",
}),
@@ -205,7 +207,8 @@ func TestLFM2Parser(t *testing.T) {
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "bash",
Index: 0,
Name: "bash",
Arguments: testArgs(map[string]any{
"command": "ls",
}),
@@ -213,7 +216,8 @@ func TestLFM2Parser(t *testing.T) {
},
{
Function: api.ToolCallFunction{
Name: "bash",
Index: 1,
Name: "bash",
Arguments: testArgs(map[string]any{
"command": "pwd",
}),

View File

@@ -44,6 +44,7 @@ type MinistralParser struct {
state ministralParserState
buffer strings.Builder
tools []api.Tool
callIndex int
hasThinkingSupport bool
pendingToolName string // stores tool name while collecting args
}
@@ -73,6 +74,7 @@ func (p *MinistralParser) setInitialState(lastMessage *api.Message) {
func (p *MinistralParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.tools = tools
p.callIndex = 0
p.setInitialState(lastMessage)
return tools
}
@@ -288,6 +290,11 @@ func (p *MinistralParser) Add(s string, done bool) (content string, thinking str
}
}
for i := range toolCalls {
toolCalls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentBuilder.String(), thinkingBuilder.String(), toolCalls, nil
}

View File

@@ -4,6 +4,7 @@ import (
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
)
@@ -395,6 +396,54 @@ func TestMinistralParserStreaming(t *testing.T) {
}
}
func TestMinistralParserAssignsSequentialToolCallIndices(t *testing.T) {
parser := &MinistralParser{}
parser.Init([]api.Tool{
{Function: api.ToolFunction{Name: "get_weather"}},
{Function: api.ToolFunction{Name: "get_time"}},
}, nil, nil)
content, thinking, calls, err := parser.Add(
`[TOOL_CALLS]get_weather[ARGS]{"location":"NYC"}[TOOL_CALLS]get_time[ARGS]{"timezone":"EST"}`,
true,
)
if err != nil {
t.Fatalf("Add() error = %v", err)
}
if content != "" {
t.Fatalf("expected no content, got %q", content)
}
if thinking != "" {
t.Fatalf("expected no thinking, got %q", thinking)
}
expected := []api.ToolCall{
{
Function: api.ToolCallFunction{
Index: 0,
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "NYC",
}),
},
},
{
Function: api.ToolCallFunction{
Index: 1,
Name: "get_time",
Arguments: testArgs(map[string]any{
"timezone": "EST",
}),
},
},
}
if diff := cmp.Diff(expected, calls, argsComparer); diff != "" {
t.Fatalf("tool calls mismatch (-want +got):\n%s", diff)
}
}
func TestMinistralParser_Errors(t *testing.T) {
t.Run("unknown tool returns error", func(t *testing.T) {
p := &MinistralParser{}

View File

@@ -26,8 +26,9 @@ const (
)
type Olmo3Parser struct {
state olmo3ParserState
buffer strings.Builder
state olmo3ParserState
buffer strings.Builder
callIndex int
}
func (p *Olmo3Parser) HasToolSupport() bool {
@@ -40,6 +41,7 @@ func (p *Olmo3Parser) HasThinkingSupport() bool {
func (p *Olmo3Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.state = olmo3StateContent
p.callIndex = 0
return tools
}
@@ -84,6 +86,11 @@ func (p *Olmo3Parser) Add(s string, done bool) (content string, thinking string,
}
}
for i := range allCalls {
allCalls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentSb.String(), "", allCalls, nil
}

View File

@@ -69,12 +69,14 @@ get_weather(location="New York")</function_calls>`,
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Index: 0,
Name: "get_weather",
Arguments: testArgs(map[string]any{"location": "San Francisco"}),
},
},
{
Function: api.ToolCallFunction{
Index: 1,
Name: "get_weather",
Arguments: testArgs(map[string]any{"location": "New York"}),
},

View File

@@ -28,6 +28,7 @@ type Qwen3VLParser struct {
state qwenParserState
buffer strings.Builder
tools []api.Tool
callIndex int
hasThinkingSupport bool
}
@@ -56,6 +57,7 @@ func (p *Qwen3VLParser) setInitialState(lastMessage *api.Message) {
func (p *Qwen3VLParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.tools = tools
p.callIndex = 0
p.setInitialState(lastMessage)
return tools
}
@@ -90,6 +92,11 @@ func (p *Qwen3VLParser) Add(s string, done bool) (content string, thinking strin
}
}
for i := range calls {
calls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentSb.String(), thinkingSb.String(), calls, nil
}

View File

@@ -4,6 +4,7 @@ import (
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
)
@@ -217,6 +218,51 @@ func TestQwen3VLNonThinkingParserStreaming(t *testing.T) {
}
}
func TestQwen3VLNonThinkingAssignsSequentialToolCallIndices(t *testing.T) {
parser := Qwen3VLParser{hasThinkingSupport: false}
parser.Init([]api.Tool{}, nil, nil)
content, thinking, calls, err := parser.Add(
`<tool_call>{"name":"first","arguments":{"a":"1"}}</tool_call><tool_call>{"name":"second","arguments":{"b":"2"}}</tool_call>`,
true,
)
if err != nil {
t.Fatalf("Add() error = %v", err)
}
if content != "" {
t.Fatalf("expected no content, got %q", content)
}
if thinking != "" {
t.Fatalf("expected no thinking, got %q", thinking)
}
expected := []api.ToolCall{
{
Function: api.ToolCallFunction{
Index: 0,
Name: "first",
Arguments: testArgs(map[string]any{
"a": "1",
}),
},
},
{
Function: api.ToolCallFunction{
Index: 1,
Name: "second",
Arguments: testArgs(map[string]any{
"b": "2",
}),
},
},
}
if diff := cmp.Diff(expected, calls, argsComparer); diff != "" {
t.Fatalf("tool calls mismatch (-want +got):\n%s", diff)
}
}
func TestQwenOldParserStreaming(t *testing.T) {
type step struct {
input string