ollama source for Momentry Core verification

This commit is contained in:
Accusys
2026-05-22 17:19:10 +08:00
commit 0b31ff9135
2020 changed files with 1413145 additions and 0 deletions

326
model/parsers/cogito.go Normal file
View File

@@ -0,0 +1,326 @@
package parsers
import (
"encoding/json"
"errors"
"log/slog"
"strings"
"unicode"
"github.com/ollama/ollama/api"
)
type CogitoParserState int
const (
CogitoCollectingThinking CogitoParserState = iota
CogitoCollectingContent
CogitoCollectingToolCalls
CogitoCollectingToolOutput
)
const (
cogitoThinkingCloseTag = "</think>"
cogitoToolCallsBeginTag = "<tool▁calls▁begin>"
cogitoToolCallsEndTag = "<tool▁calls▁end>"
cogitoToolCallBeginTag = "<tool▁call▁begin>"
cogitoToolCallEndTag = "<tool▁call▁end>"
cogitoToolSepTag = "<tool▁sep>"
cogitoToolOutputBeginTag = "<tool▁output▁begin>"
cogitoToolOutputEndTag = "<tool▁output▁end>"
cogitoToolOutputsBeginTag = "<tool▁outputs▁begin>"
cogitoToolOutputsEndTag = "<tool▁outputs▁end>"
)
type CogitoParser struct {
state CogitoParserState
buffer strings.Builder
callIndex int
}
func (p *CogitoParser) HasToolSupport() bool {
return true
}
func (p *CogitoParser) HasThinkingSupport() bool {
return true
}
func (p *CogitoParser) 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()
// thinkingEnabled should be set to false for tools
if !thinkingEnabled {
p.state = CogitoCollectingContent
return
}
if prefill && lastMessage.Content != "" {
p.state = CogitoCollectingContent
return
}
// Note: for cogito, if there are tools, then we don't want to be thinking
if len(tools) > 0 {
p.state = CogitoCollectingContent
return
}
p.state = CogitoCollectingThinking
}
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
}
type cogitoEvent interface {
isCogitoEvent()
}
type cogitoEventThinkingContent struct {
content string
}
type cogitoEventContent struct {
content string
}
type cogitoEventToolCall struct {
toolCall api.ToolCall
}
func (cogitoEventThinkingContent) isCogitoEvent() {}
func (cogitoEventContent) isCogitoEvent() {}
func (cogitoEventToolCall) isCogitoEvent() {}
func (p *CogitoParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
events := p.parseEvents()
var toolCalls []api.ToolCall
var contentSb strings.Builder
var thinkingSb strings.Builder
for _, event := range events {
switch event := event.(type) {
case cogitoEventToolCall:
toolCalls = append(toolCalls, event.toolCall)
case cogitoEventThinkingContent:
thinkingSb.WriteString(event.content)
case cogitoEventContent:
contentSb.WriteString(event.content)
}
}
for i := range toolCalls {
toolCalls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentSb.String(), thinkingSb.String(), toolCalls, nil
}
func (p *CogitoParser) parseEvents() []cogitoEvent {
var all []cogitoEvent
keepLooping := true
for keepLooping {
var events []cogitoEvent
events, keepLooping = p.eat()
if len(events) > 0 {
all = append(all, events...)
}
}
return all
}
func (p *CogitoParser) eat() ([]cogitoEvent, bool) {
var events []cogitoEvent
bufStr := p.buffer.String()
if bufStr == "" {
return events, false
}
switch p.state {
case CogitoCollectingThinking:
if strings.Contains(bufStr, cogitoThinkingCloseTag) { // thinking[</think>] -> content
split := strings.SplitN(bufStr, cogitoThinkingCloseTag, 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 = CogitoCollectingContent
if len(thinking) > 0 {
events = append(events, cogitoEventThinkingContent{content: thinking})
}
return events, true
} else if overlapLen := overlap(bufStr, cogitoThinkingCloseTag); 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, cogitoEventThinkingContent{content: unambiguous})
}
return events, false
} else { // otherwise its 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, cogitoEventThinkingContent{content: unambiguous})
}
return events, false
}
case CogitoCollectingContent:
switch {
case strings.Contains(bufStr, cogitoToolCallsBeginTag): // content[<tool▁calls▁begin>] -> tool calls
split := strings.SplitN(bufStr, cogitoToolCallsBeginTag, 2)
contentBefore := strings.TrimRightFunc(split[0], unicode.IsSpace)
remaining := split[1]
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = CogitoCollectingToolCalls
if len(contentBefore) > 0 {
events = append(events, cogitoEventContent{content: contentBefore})
}
return events, true
case strings.Contains(bufStr, cogitoToolOutputsBeginTag): // content[<tool▁outputs▁begin>] -> tool outputs
split := strings.SplitN(bufStr, cogitoToolOutputsBeginTag, 2)
contentBefore := strings.TrimRightFunc(split[0], unicode.IsSpace)
remaining := split[1]
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = CogitoCollectingToolOutput
if len(contentBefore) > 0 {
events = append(events, cogitoEventContent{content: contentBefore})
}
return events, true
default: // otherwise its content
p.buffer.Reset()
if len(bufStr) > 0 {
events = append(events, cogitoEventContent{content: bufStr})
}
return events, false
}
case CogitoCollectingToolCalls:
if idx := strings.Index(bufStr, cogitoToolCallBeginTag); idx != -1 {
startIdx := idx + len(cogitoToolCallBeginTag)
if endIdx := strings.Index(bufStr[startIdx:], cogitoToolCallEndTag); endIdx != -1 {
toolCallContent := bufStr[startIdx : startIdx+endIdx]
if toolCall, err := p.parseToolCallContent(toolCallContent); err == nil {
remaining := bufStr[startIdx+endIdx+len(cogitoToolCallEndTag):]
remaining = strings.TrimLeftFunc(remaining, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(remaining)
events = append(events, cogitoEventToolCall{toolCall: toolCall})
return events, true
} else {
slog.Warn("cogito tool call parsing failed", "error", err)
}
}
}
if idx := strings.Index(bufStr, cogitoToolCallsEndTag); idx != -1 {
remaining := bufStr[idx+len(cogitoToolCallsEndTag):]
remaining = strings.TrimLeftFunc(remaining, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = CogitoCollectingContent
return events, true
}
return events, false
case CogitoCollectingToolOutput:
if idx := strings.Index(bufStr, cogitoToolOutputBeginTag); idx != -1 {
startIdx := idx + len(cogitoToolOutputBeginTag)
if endIdx := strings.Index(bufStr[startIdx:], cogitoToolOutputEndTag); endIdx != -1 {
remaining := bufStr[startIdx+endIdx+len(cogitoToolOutputEndTag):]
remaining = strings.TrimLeftFunc(remaining, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(remaining)
return events, true
}
}
if idx := strings.Index(bufStr, cogitoToolOutputsEndTag); idx != -1 {
remaining := bufStr[idx+len(cogitoToolOutputsEndTag):]
remaining = strings.TrimLeftFunc(remaining, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = CogitoCollectingContent
return events, true
}
return events, false
}
return events, false
}
func (p *CogitoParser) parseToolCallContent(content string) (api.ToolCall, error) {
// Expected format: function<tool▁sep>tool_name\n```json\n{args}\n```
parts := strings.SplitN(content, cogitoToolSepTag, 2)
if len(parts) < 2 {
return api.ToolCall{}, errors.New("invalid format")
}
nameAndArgs := parts[1]
jsonStart := strings.Index(nameAndArgs, "\n```json\n")
if jsonStart == -1 {
return api.ToolCall{}, errors.New("invalid format")
}
toolName := strings.TrimSpace(nameAndArgs[:jsonStart])
jsonContent := nameAndArgs[jsonStart+len("\n```json\n"):]
jsonEnd := strings.Index(jsonContent, "\n```")
if jsonEnd == -1 {
return api.ToolCall{}, errors.New("invalid format")
}
argsJSON := jsonContent[:jsonEnd]
var args api.ToolCallFunctionArguments
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return api.ToolCall{}, err
}
return api.ToolCall{
Function: api.ToolCallFunction{
Name: toolName,
Arguments: args,
},
}, nil
}

View File

@@ -0,0 +1,567 @@
package parsers
import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
)
func TestCogitoParser(t *testing.T) {
tests := []struct {
name string
input string
expectedContent string
expectedThinking string
expectedToolCalls []api.ToolCall
tools []api.Tool
lastMessage *api.Message
}{
{
name: "simple_content",
input: "This is a simple response.",
expectedContent: "This is a simple response.",
expectedThinking: "",
},
{
name: "thinking_only",
input: "This is thinking content.</think>This is response content.",
expectedContent: "This is response content.",
expectedThinking: "This is thinking content.",
},
{
name: "tool_call_simple",
input: `<tool▁calls▁begin><tool▁call▁begin>function<tool▁sep>get_weather
` + "```json\n" + `{"location":"Paris"}
` + "```" + `<tool▁call▁end><tool▁calls▁end>`,
expectedToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Index: 0,
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
}),
},
},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Properties: testPropsMap(map[string]api.ToolProperty{
"location": {Type: api.PropertyType{"string"}},
}),
},
},
},
},
},
{
name: "thinking_with_tool_call",
input: `I need to check the weather.</think><tool▁calls▁begin><tool▁call▁begin>function<tool▁sep>get_weather
` + "```json\n" + `{"location":"Paris"}
` + "```" + `<tool▁call▁end><tool▁calls▁end>`,
expectedContent: "I need to check the weather.</think>",
expectedThinking: "", // No thinking when tools are present (Cogito-specific behavior)
expectedToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
}),
},
},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Properties: testPropsMap(map[string]api.ToolProperty{
"location": {Type: api.PropertyType{"string"}},
}),
},
},
},
},
},
{
name: "multiple_tool_calls",
input: `<tool▁calls▁begin><tool▁call▁begin>function<tool▁sep>get_weather
` + "```json\n" + `{"location":"Paris"}
` + "```" + `<tool▁call▁end>
<tool▁call▁begin>function<tool▁sep>get_weather
` + "```json\n" + `{"location":"London"}
` + "```" + `<tool▁call▁end><tool▁calls▁end>`,
expectedToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
}),
},
},
{
Function: api.ToolCallFunction{
Index: 1,
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "London",
}),
},
},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Properties: testPropsMap(map[string]api.ToolProperty{
"location": {Type: api.PropertyType{"string"}},
}),
},
},
},
},
},
{
name: "complex_tool_arguments",
input: `<tool▁calls▁begin><tool▁call▁begin>function<tool▁sep>process_data
` + "```json\n" + `{"items":["item1","item2"],"config":{"enabled":true,"threshold":0.95},"count":42}
` + "```" + `<tool▁call▁end><tool▁calls▁end>`,
expectedToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "process_data",
Arguments: testArgs(map[string]any{
"items": []any{"item1", "item2"},
"config": map[string]any{"enabled": true, "threshold": 0.95},
"count": 42.0,
}),
},
},
},
},
{
name: "tool_output_parsing",
input: `<tool▁outputs▁begin><tool▁output▁begin>{"temperature": 22, "condition": "sunny"}<tool▁output▁end><tool▁outputs▁end>`,
expectedContent: "",
expectedThinking: "",
},
{
name: "thinking_with_multiline_content",
input: `This is line 1
This is line 2
This is line 3</think>Final response here.`,
expectedContent: "Final response here.",
expectedThinking: "This is line 1\nThis is line 2\nThis is line 3",
},
{
name: "no_thinking_simple",
input: "This is content.",
expectedContent: "This is content.",
expectedThinking: "",
},
{
name: "prefill_content_only",
input: "Continuing from previous content.",
expectedContent: "Continuing from previous content.",
lastMessage: &api.Message{
Role: "assistant",
Content: "Previous content",
},
},
{
name: "prefill_with_thinking",
input: "Continuing thinking</think>Continuing content.",
expectedContent: "Continuing content.",
expectedThinking: "Continuing thinking",
lastMessage: &api.Message{
Role: "assistant",
},
},
// Edge cases
{
name: "nested_think_tags_in_thinking",
input: "I'm thinking <think>nested</think> more thinking</think>Final content.",
expectedContent: "more thinking</think>Final content.",
expectedThinking: "I'm thinking <think>nested",
},
{
name: "multiple_think_close_tags",
input: "First thinking</think>Content</think>More content.",
expectedContent: "Content</think>More content.",
expectedThinking: "First thinking",
},
{
name: "empty_thinking_content",
input: "</think>Just content here.",
expectedContent: "</think>Just content here.",
expectedThinking: "",
},
{
name: "thinking_disabled_with_think_tags",
input: "Content with </think> tags should be treated as content.",
expectedContent: "Content with </think> tags should be treated as content.",
expectedThinking: "",
lastMessage: &api.Message{
Role: "assistant",
Content: "existing", // Forces non-thinking mode
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Use thinking-enabled parser for tests that expect thinking
hasThinking := tt.expectedThinking != ""
parser := &CogitoParser{} // it has thinking support
parser.Init(tt.tools, tt.lastMessage, &api.ThinkValue{Value: hasThinking}) // but we should set it with the request that the user wants
content, thinking, toolCalls, err := parser.Add(tt.input, true)
if err != nil {
t.Fatalf("Add() error = %v", err)
}
if diff := cmp.Diff(tt.expectedContent, content); diff != "" {
t.Errorf("content mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.expectedThinking, thinking); diff != "" {
t.Errorf("thinking mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.expectedToolCalls, toolCalls, argsComparer); diff != "" {
t.Errorf("tool calls mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestCogitoParser_Streaming(t *testing.T) {
parser := &CogitoParser{}
parser.Init(nil, nil, &api.ThinkValue{Value: true})
chunks := []string{
"This is ",
"thinking content",
".</think>This is ",
"content.<tool▁calls▁begin><tool▁call▁begin>function<tool▁sep>test_tool\n```json\n{\"arg\":\"value\"}\n```<tool▁call▁end><tool▁calls▁end>",
}
var finalContent, finalThinking strings.Builder
var finalToolCalls []api.ToolCall
for i, chunk := range chunks {
done := i == len(chunks)-1
content, thinking, toolCalls, err := parser.Add(chunk, done)
if err != nil {
t.Fatalf("Add() error on chunk %d: %v", i, err)
}
finalContent.WriteString(content)
finalThinking.WriteString(thinking)
finalToolCalls = append(finalToolCalls, toolCalls...)
}
expectedContent := "This is content."
expectedThinking := "This is thinking content."
expectedToolCalls := []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "test_tool",
Arguments: testArgs(map[string]any{
"arg": "value",
}),
},
},
}
if finalContent.String() != expectedContent {
t.Errorf("expected content %q, got %q", expectedContent, finalContent.String())
}
if finalThinking.String() != expectedThinking {
t.Errorf("expected thinking %q, got %q", expectedThinking, finalThinking.String())
}
if diff := cmp.Diff(expectedToolCalls, finalToolCalls, argsComparer); diff != "" {
t.Errorf("tool calls mismatch (-want +got):\n%s", diff)
}
}
func TestCogitoParser_StreamingEdgeCases(t *testing.T) {
tests := []struct {
name string
chunks []string
expectedContent string
expectedThinking string
expectedToolCalls []api.ToolCall
hasThinkingSupport bool
}{
{
name: "split_thinking_tag",
chunks: []string{
"This is thinking content</thi",
"nk>This is content.",
},
expectedContent: "This is content.",
expectedThinking: "This is thinking content",
hasThinkingSupport: true,
},
{
name: "split_tool_calls_begin_tag_conservative_parsing",
chunks: []string{
"Content before<tool▁calls▁beg",
"in><tool▁call▁begin>function<tool▁sep>test\n```json\n{}\n```<tool▁call▁end><tool▁calls▁end>",
},
// Parser is conservative - treats incomplete tags as content
expectedContent: "Content before<tool▁calls▁begin><tool▁call▁begin>function<tool▁sep>test\n```json\n{}\n```<tool▁call▁end><tool▁calls▁end>",
expectedToolCalls: nil,
hasThinkingSupport: false,
},
{
name: "thinking_disabled_with_split_tags",
chunks: []string{
"Content with </thi",
"nk> should be treated as content.",
},
expectedContent: "Content with </think> should be treated as content.",
expectedThinking: "",
hasThinkingSupport: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &CogitoParser{}
parser.Init(nil, nil, &api.ThinkValue{Value: tt.hasThinkingSupport})
var finalContent, finalThinking strings.Builder
var finalToolCalls []api.ToolCall
for i, chunk := range tt.chunks {
done := i == len(tt.chunks)-1
content, thinking, toolCalls, err := parser.Add(chunk, done)
if err != nil {
t.Fatalf("Add() error on chunk %d: %v", i, err)
}
finalContent.WriteString(content)
finalThinking.WriteString(thinking)
finalToolCalls = append(finalToolCalls, toolCalls...)
}
if finalContent.String() != tt.expectedContent {
t.Errorf("expected content %q, got %q", tt.expectedContent, finalContent.String())
}
if finalThinking.String() != tt.expectedThinking {
t.Errorf("expected thinking %q, got %q", tt.expectedThinking, finalThinking.String())
}
if diff := cmp.Diff(tt.expectedToolCalls, finalToolCalls, argsComparer); diff != "" {
t.Errorf("tool calls mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestCogitoParser_HasToolSupport(t *testing.T) {
parser := &CogitoParser{}
if !parser.HasToolSupport() {
t.Error("CogitoParser should support tools")
}
}
func TestCogitoParser_Init(t *testing.T) {
parser := &CogitoParser{}
tools := []api.Tool{
{Function: api.ToolFunction{Name: "test_tool"}},
}
lastMessage := &api.Message{Role: "assistant", Content: "previous"}
returnedTools := parser.Init(tools, lastMessage, nil)
if len(returnedTools) != len(tools) {
t.Errorf("expected %d tools returned, got %d", len(tools), len(returnedTools))
}
}
func TestCogitoParser_parseToolCallContent(t *testing.T) {
tests := []struct {
name string
content string
expected api.ToolCall
expectError bool
}{
{
name: "valid_tool_call_standard_format",
content: `function<tool▁sep>get_weather
` + "```json\n" + `{"location":"Paris"}
` + "```",
expected: api.ToolCall{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
}),
},
},
expectError: false,
},
{
name: "valid_tool_call_complex_args",
content: `function<tool▁sep>process_data
` + "```json\n" + `{"items":["item1","item2"],"config":{"enabled":true},"count":42}
` + "```",
expected: api.ToolCall{
Function: api.ToolCallFunction{
Name: "process_data",
Arguments: testArgs(map[string]any{
"items": []any{"item1", "item2"},
"config": map[string]any{"enabled": true},
"count": 42.0,
}),
},
},
expectError: false,
},
{
name: "valid_tool_call_empty_args",
content: `function<tool▁sep>no_args_tool
` + "```json\n" + `{}
` + "```",
expected: api.ToolCall{
Function: api.ToolCallFunction{
Name: "no_args_tool",
Arguments: api.NewToolCallFunctionArguments(),
},
},
expectError: false,
},
{
name: "missing_separator",
content: `functionget_weather` + "```json\n" + `{"location":"Paris"}` + "\n```",
expected: api.ToolCall{},
expectError: true,
},
{
name: "invalid_function_type",
content: `not_function<tool▁sep>get_weather` + "```json\n" + `{"location":"Paris"}` + "\n```",
expected: api.ToolCall{},
expectError: true,
},
{
name: "missing_json_block_start",
content: `function<tool▁sep>get_weather{"location":"Paris"}` + "```",
expected: api.ToolCall{},
expectError: true,
},
{
name: "missing_json_block_end",
content: `function<tool▁sep>get_weather` + "```json\n" + `{"location":"Paris"}`,
expected: api.ToolCall{},
expectError: true,
},
{
name: "invalid_json",
content: `function<tool▁sep>get_weather` + "```json\n" + `{location:Paris}` + "\n```",
expected: api.ToolCall{},
expectError: true,
},
{
name: "empty_function_type",
content: `<tool▁sep>get_weather` + "```json\n" + `{"location":"Paris"}` + "\n```",
expected: api.ToolCall{},
expectError: true,
},
{
name: "tool_with_spaces_in_name",
content: `function<tool▁sep> get_weather
` + "```json\n" + `{"location":"Paris"}
` + "```",
expected: api.ToolCall{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
}),
},
},
expectError: false,
},
{
name: "tool_with_multiline_json",
content: `function<tool▁sep>get_weather
` + "```json\n" + `{
"location": "Paris",
"units": "metric"
}
` + "```",
expected: api.ToolCall{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
"units": "metric",
}),
},
},
expectError: false,
},
{
name: "tool_with_nested_objects",
content: `function<tool▁sep>complex_tool
` + "```json\n" + `{"nested":{"deep":{"value":123}}}
` + "```",
expected: api.ToolCall{
Function: api.ToolCallFunction{
Name: "complex_tool",
Arguments: testArgs(map[string]any{
"nested": map[string]any{
"deep": map[string]any{
"value": 123.0,
},
},
}),
},
},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &CogitoParser{}
result, err := parser.parseToolCallContent(tt.content)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if diff := cmp.Diff(tt.expected, result, argsComparer); diff != "" {
t.Errorf("tool call mismatch (-want +got):\n%s", diff)
}
})
}
}

299
model/parsers/deepseek3.go Normal file
View File

@@ -0,0 +1,299 @@
package parsers
import (
"encoding/json"
"errors"
"log/slog"
"strings"
"unicode"
"github.com/ollama/ollama/api"
)
type DeepSeek3ParserState int
const (
DeepSeekCollectingThinking DeepSeek3ParserState = iota
DeepSeekCollectingContent
DeepSeekCollectingToolCalls
DeepSeekCollectingToolOutput
)
const (
deepseekThinkingCloseTag = "</think>"
deepseekToolCallsBeginTag = "<tool▁calls▁begin>"
deepseekToolCallsEndTag = "<tool▁calls▁end>"
deepseekToolCallBeginTag = "<tool▁call▁begin>"
deepseekToolCallEndTag = "<tool▁call▁end>"
deepseekToolSepTag = "<tool▁sep>"
deepseekToolOutputBeginTag = "<tool▁output▁begin>"
deepseekToolOutputEndTag = "<tool▁output▁end>"
)
type DeepSeek3Parser struct {
state DeepSeek3ParserState
buffer strings.Builder
callIndex int
hasThinkingSupport bool
}
func (p *DeepSeek3Parser) HasToolSupport() bool {
return true
}
func (p *DeepSeek3Parser) HasThinkingSupport() bool {
return p.hasThinkingSupport
}
func (p *DeepSeek3Parser) setInitialState(lastMessage *api.Message, tools []api.Tool, thinkValue *api.ThinkValue) {
prefill := lastMessage != nil && lastMessage.Role == "assistant"
// Check both model capability AND request preference
thinkingEnabled := p.HasThinkingSupport() && (thinkValue != nil && thinkValue.Bool())
if !thinkingEnabled {
p.state = DeepSeekCollectingContent
return
}
if prefill && lastMessage.Content != "" {
p.state = DeepSeekCollectingContent
return
}
p.state = DeepSeekCollectingThinking
}
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
}
type deepseekEvent interface {
isDeepSeekEvent()
}
type deepseekEventThinkingContent struct {
content string
}
type deepseekEventContent struct {
content string
}
type deepseekEventToolCall struct {
toolCall api.ToolCall
}
func (deepseekEventThinkingContent) isDeepSeekEvent() {}
func (deepseekEventContent) isDeepSeekEvent() {}
func (deepseekEventToolCall) isDeepSeekEvent() {}
func (p *DeepSeek3Parser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
events := p.parseEvents()
var toolCalls []api.ToolCall
var contentSb strings.Builder
var thinkingSb strings.Builder
for _, event := range events {
switch event := event.(type) {
case deepseekEventToolCall:
toolCalls = append(toolCalls, event.toolCall)
case deepseekEventThinkingContent:
thinkingSb.WriteString(event.content)
case deepseekEventContent:
contentSb.WriteString(event.content)
}
}
for i := range toolCalls {
toolCalls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentSb.String(), thinkingSb.String(), toolCalls, nil
}
func (p *DeepSeek3Parser) parseEvents() []deepseekEvent {
var all []deepseekEvent
keepLooping := true
for keepLooping {
var events []deepseekEvent
events, keepLooping = p.eat()
if len(events) > 0 {
all = append(all, events...)
}
}
return all
}
func (p *DeepSeek3Parser) eat() ([]deepseekEvent, bool) {
var events []deepseekEvent
bufStr := p.buffer.String()
if bufStr == "" {
return events, false
}
switch p.state {
case DeepSeekCollectingThinking:
if strings.Contains(bufStr, deepseekThinkingCloseTag) { // thinking[</think>] -> content
split := strings.SplitN(bufStr, deepseekThinkingCloseTag, 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 = DeepSeekCollectingContent
if len(thinking) > 0 {
events = append(events, deepseekEventThinkingContent{content: thinking})
}
return events, true
} else if overlapLen := overlap(bufStr, deepseekThinkingCloseTag); 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, deepseekEventThinkingContent{content: unambiguous})
}
return events, false
} else { // otherwise its 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, deepseekEventThinkingContent{content: unambiguous})
}
return events, false
}
case DeepSeekCollectingContent:
switch {
case strings.Contains(bufStr, deepseekToolCallsBeginTag): // content[<tool▁calls▁begin>] -> tool calls
split := strings.SplitN(bufStr, deepseekToolCallsBeginTag, 2)
contentBefore := strings.TrimRightFunc(split[0], unicode.IsSpace)
remaining := split[1]
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = DeepSeekCollectingToolCalls
if len(contentBefore) > 0 {
events = append(events, deepseekEventContent{content: contentBefore})
}
return events, true
case strings.Contains(bufStr, deepseekToolOutputBeginTag): // content[<tool▁output▁begin>] -> tool output
split := strings.SplitN(bufStr, deepseekToolOutputBeginTag, 2)
contentBefore := split[0] // Don't trim whitespace - preserve spaces
remaining := split[1]
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = DeepSeekCollectingToolOutput
if len(contentBefore) > 0 {
events = append(events, deepseekEventContent{content: contentBefore})
}
return events, true
default: // otherwise its content
p.buffer.Reset()
if len(bufStr) > 0 {
events = append(events, deepseekEventContent{content: bufStr})
}
return events, false
}
case DeepSeekCollectingToolCalls:
if idx := strings.Index(bufStr, deepseekToolCallBeginTag); idx != -1 {
startIdx := idx + len(deepseekToolCallBeginTag)
if endIdx := strings.Index(bufStr[startIdx:], deepseekToolCallEndTag); endIdx != -1 {
toolCallContent := bufStr[startIdx : startIdx+endIdx]
if toolCall, err := p.parseToolCallContent(toolCallContent); err == nil {
remaining := bufStr[startIdx+endIdx+len(deepseekToolCallEndTag):]
remaining = strings.TrimLeftFunc(remaining, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(remaining)
events = append(events, deepseekEventToolCall{toolCall: toolCall})
return events, true
} else {
slog.Warn("deepseek tool call parsing failed", "error", err)
}
}
}
if idx := strings.Index(bufStr, deepseekToolCallsEndTag); idx != -1 {
remaining := bufStr[idx+len(deepseekToolCallsEndTag):]
remaining = strings.TrimLeftFunc(remaining, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = DeepSeekCollectingContent
return events, true
}
return events, false
case DeepSeekCollectingToolOutput:
if idx := strings.Index(bufStr, deepseekToolOutputEndTag); idx != -1 {
toolOutputContent := bufStr[:idx]
remaining := bufStr[idx+len(deepseekToolOutputEndTag):]
// Don't trim whitespace - preserve spaces after tool output tags
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = DeepSeekCollectingContent
if len(toolOutputContent) > 0 {
events = append(events, deepseekEventContent{content: toolOutputContent})
}
return events, true
}
return events, false
}
return events, false
}
func (p *DeepSeek3Parser) parseToolCallContent(content string) (api.ToolCall, error) {
// Expected format: tool_name<tool▁sep>{args}
parts := strings.SplitN(content, deepseekToolSepTag, 2)
if len(parts) < 2 {
return api.ToolCall{}, errors.New("invalid format")
}
toolName := strings.TrimSpace(parts[0])
argsJSON := strings.TrimSpace(parts[1])
var args api.ToolCallFunctionArguments
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return api.ToolCall{}, err
}
return api.ToolCall{
Function: api.ToolCallFunction{
Name: toolName,
Arguments: args,
},
}, nil
}

View File

@@ -0,0 +1,723 @@
package parsers
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
)
func TestDeepSeekParser(t *testing.T) {
tests := []struct {
name string
input string
expectedContent string
expectedThinking string
expectedCalls []api.ToolCall
hasThinking bool
}{
{
name: "simple_content",
input: "Hello, how are you?",
expectedContent: "Hello, how are you?",
hasThinking: false,
},
{
name: "thinking_content",
input: "I need to think about this...</think>The answer is 42.",
expectedThinking: "I need to think about this...",
expectedContent: "The answer is 42.",
hasThinking: true,
},
{
name: "no_thinking_simple",
input: "Just a regular response.",
expectedContent: "Just a regular response.",
hasThinking: false,
},
{
name: "thinking_with_newlines",
input: "Let me think:\n- Point 1\n- Point 2</think>\n\nHere's my answer.",
expectedThinking: "Let me think:\n- Point 1\n- Point 2",
expectedContent: "Here's my answer.",
hasThinking: true,
},
{
name: "tool_call_simple",
input: "I'll check the weather.<tool▁calls▁begin><tool▁call▁begin>get_weather<tool▁sep>{\"location\":\"Paris\"}<tool▁call▁end><tool▁calls▁end>",
expectedContent: "I'll check the weather.",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Index: 0,
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
}),
},
},
},
hasThinking: false,
},
{
name: "multiple_tool_calls",
input: "Getting weather for both cities.<tool▁calls▁begin><tool▁call▁begin>get_weather<tool▁sep>{\"location\":\"Paris\"}<tool▁call▁end><tool▁call▁begin>get_weather<tool▁sep>{\"location\":\"London\"}<tool▁call▁end><tool▁calls▁end>",
expectedContent: "Getting weather for both cities.",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
}),
},
},
{
Function: api.ToolCallFunction{
Index: 1,
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "London",
}),
},
},
},
hasThinking: false,
},
{
name: "tool_output",
input: "Here's the weather: <tool▁output▁begin>Temperature: 22°C, Sunny<tool▁output▁end> Hope that helps!",
expectedContent: "Here's the weather: Temperature: 22°C, Sunny Hope that helps!",
hasThinking: false,
},
{
name: "complex_tool_arguments",
input: "Processing data.<tool▁calls▁begin><tool▁call▁begin>process_data<tool▁sep>{\"items\":[\"item1\",\"item2\"],\"config\":{\"enabled\":true,\"threshold\":0.95}}<tool▁call▁end><tool▁calls▁end>",
expectedContent: "Processing data.",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "process_data",
Arguments: testArgs(map[string]any{
"items": []interface{}{"item1", "item2"},
"config": map[string]interface{}{"enabled": true, "threshold": 0.95},
}),
},
},
},
hasThinking: false,
},
{
name: "thinking_with_tool_call", // technically this can't happen, but the parser can handle it
input: "Let me check the weather...</think>I'll get that for you.<tool▁calls▁begin><tool▁call▁begin>get_weather<tool▁sep>{\"location\":\"Paris\"}<tool▁call▁end><tool▁calls▁end>",
expectedThinking: "Let me check the weather...",
expectedContent: "I'll get that for you.",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
}),
},
},
},
hasThinking: true,
},
{
name: "empty_content",
input: "",
expectedContent: "",
hasThinking: false,
},
{
name: "only_thinking",
input: "Just thinking content</think>",
expectedThinking: "Just thinking content",
expectedContent: "",
hasThinking: true,
},
{
name: "multiple_tool_outputs",
input: "Results: <tool▁output▁begin>Paris: 22°C<tool▁output▁end> and <tool▁output▁begin>London: 18°C<tool▁output▁end>",
expectedContent: "Results: Paris: 22°C and London: 18°C",
hasThinking: false,
},
{
name: "unicode_content",
input: "مرحبا بالعالم! 你好世界! 🌍",
expectedContent: "مرحبا بالعالم! 你好世界! 🌍",
hasThinking: false,
},
{
name: "emoji_passthrough",
input: "Task completed ✅ 🎉",
expectedContent: "Task completed ✅ 🎉",
hasThinking: false,
},
{
name: "emoji_after_tool_call",
input: "I'll help you.<tool▁calls▁begin><tool▁call▁begin>get_weather<tool▁sep>{\"location\":\"Tokyo\"}<tool▁call▁end><tool▁calls▁end>完成 ✅",
expectedContent: "I'll help you.完成 ✅",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Tokyo",
}),
},
},
},
hasThinking: false,
},
{
name: "newlines_and_whitespace",
input: "Line 1\n\nLine 3\t\tTabbed content",
expectedContent: "Line 1\n\nLine 3\t\tTabbed content",
hasThinking: false,
},
{
name: "thinking_with_unicode",
input: "我在思考这个问题...</think>答案是42。",
expectedThinking: "我在思考这个问题...",
expectedContent: "答案是42。",
hasThinking: true,
},
{
name: "tool_call_with_unicode_args",
input: "Searching for information.<tool▁calls▁begin><tool▁call▁begin>search<tool▁sep>{\"query\":\"北京天气\",\"language\":\"中文\"}<tool▁call▁end><tool▁calls▁end>",
expectedContent: "Searching for information.",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "search",
Arguments: testArgs(map[string]any{
"query": "北京天气",
"language": "中文",
}),
},
},
},
hasThinking: false,
},
{
name: "tool_output_with_unicode",
input: "天气信息: <tool▁output▁begin>北京: 25°C, 晴天<tool▁output▁end> 希望对您有帮助!",
expectedContent: "天气信息: 北京: 25°C, 晴天 希望对您有帮助!",
hasThinking: false,
},
{
name: "mixed_content_with_special_chars",
input: "Price: $100 & tax @ 10% = $110 <tool▁output▁begin>Total: $110<tool▁output▁end> (final)",
expectedContent: "Price: $100 & tax @ 10% = $110 Total: $110 (final)",
hasThinking: false,
},
{
name: "tool_call_with_special_chars",
input: "Processing data.<tool▁calls▁begin><tool▁call▁begin>execute_command<tool▁sep>{\"command\":\"ls && echo \\\"done\\\"\",\"path\":\"/home/user\"}<tool▁call▁end><tool▁calls▁end>",
expectedContent: "Processing data.",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "execute_command",
Arguments: testArgs(map[string]any{
"command": "ls && echo \"done\"",
"path": "/home/user",
}),
},
},
},
hasThinking: false,
},
{
name: "thinking_with_special_chars",
input: "Let me calculate: 2+2=4 & 3*3=9...</think>The results are correct!",
expectedThinking: "Let me calculate: 2+2=4 & 3*3=9...",
expectedContent: "The results are correct!",
hasThinking: true,
},
{
name: "empty_tool_call_args",
input: "Pinging server.<tool▁calls▁begin><tool▁call▁begin>ping<tool▁sep>{}<tool▁call▁end><tool▁calls▁end>",
expectedContent: "Pinging server.",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "ping",
Arguments: api.NewToolCallFunctionArguments(),
},
},
},
hasThinking: false,
},
{
name: "empty_tool_output",
input: "Checking status: <tool▁output▁begin><tool▁output▁end> No output received.",
expectedContent: "Checking status: No output received.",
hasThinking: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &DeepSeek3Parser{hasThinkingSupport: tt.hasThinking}
parser.Init([]api.Tool{}, nil, &api.ThinkValue{Value: tt.hasThinking})
content, thinking, calls, err := parser.Add(tt.input, true)
if err != nil {
t.Fatalf("Add() error = %v", err)
}
if diff := cmp.Diff(tt.expectedContent, content); diff != "" {
t.Errorf("Content mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.expectedThinking, thinking); diff != "" {
t.Errorf("Thinking mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.expectedCalls, calls, argsComparer); diff != "" {
t.Errorf("Tool calls mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestDeepSeekParser_Streaming(t *testing.T) {
tests := []struct {
name string
chunks []string
expectedContent string
expectedThinking string
expectedCalls []api.ToolCall
hasThinking bool
}{
{
name: "streaming_simple_content",
chunks: []string{"Hello, ", "how are ", "you?"},
expectedContent: "Hello, how are you?",
hasThinking: false,
},
{
name: "streaming_thinking",
chunks: []string{"I need to ", "think about this", "...</think>", "The answer is 42."},
expectedThinking: "I need to think about this...",
expectedContent: "The answer is 42.",
hasThinking: true,
},
{
name: "streaming_tool_call",
chunks: []string{"I'll check weather.", "<tool▁calls▁begin>", "<tool▁call▁begin>get_weather", "<tool▁sep>{\"location\":\"Paris\"}", "<tool▁call▁end><tool▁calls▁end>"},
expectedContent: "I'll check weather.",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
}),
},
},
},
hasThinking: false,
},
{
name: "streaming_thinking_with_partial_tag",
chunks: []string{"Thinking about this", "...</", "think>", "Done thinking."},
expectedThinking: "Thinking about this...",
expectedContent: "Done thinking.",
hasThinking: true,
},
{
name: "streaming_tool_output",
chunks: []string{"Weather info: ", "<tool▁output▁begin>", "25°C, Sunny", "<tool▁output▁end>", " Enjoy!"},
expectedContent: "Weather info: 25°C, Sunny Enjoy!",
hasThinking: false,
},
{
name: "streaming_with_split_tags",
chunks: []string{"Content before ", "<tool▁calls▁begin><tool▁call▁begin>test", "<tool▁sep>{}", "<tool▁call▁end><tool▁calls▁end>", " after"},
expectedContent: "Content before after",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "test",
Arguments: api.NewToolCallFunctionArguments(),
},
},
},
hasThinking: false,
},
{
name: "streaming_thinking_with_split_end_tag",
chunks: []string{"Thinking content", "</th", "ink>", "Regular content"},
expectedThinking: "Thinking content",
expectedContent: "Regular content",
hasThinking: true,
},
{
name: "streaming_unicode_content",
chunks: []string{"مرحبا ", "بالعالم! ", "你好", "世界!"},
expectedContent: "مرحبا بالعالم! 你好世界!",
hasThinking: false,
},
{
name: "streaming_multiple_tool_outputs",
chunks: []string{"Results: ", "<tool▁output▁begin>", "Paris: 22°C", "<tool▁output▁end>", " and ", "<tool▁output▁begin>", "London: 18°C", "<tool▁output▁end>"},
expectedContent: "Results: Paris: 22°C and London: 18°C",
hasThinking: false,
},
{
name: "streaming_tool_call_with_split_json",
chunks: []string{"Processing.", "<tool▁calls▁begin><tool▁call▁begin>calc<tool▁sep>{\"x\":", "42,\"y\":", "24}<tool▁call▁end><tool▁calls▁end>"},
expectedContent: "Processing.",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "calc",
Arguments: testArgs(map[string]any{
"x": float64(42),
"y": float64(24),
}),
},
},
},
hasThinking: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &DeepSeek3Parser{hasThinkingSupport: tt.hasThinking}
parser.Init([]api.Tool{}, nil, &api.ThinkValue{Value: tt.hasThinking})
var allContent, allThinking string
var allCalls []api.ToolCall
for i, chunk := range tt.chunks {
done := i == len(tt.chunks)-1
content, thinking, calls, err := parser.Add(chunk, done)
if err != nil {
t.Fatalf("Add() error = %v", err)
}
allContent += content
allThinking += thinking
allCalls = append(allCalls, calls...)
}
if diff := cmp.Diff(tt.expectedContent, allContent); diff != "" {
t.Errorf("Content mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.expectedThinking, allThinking); diff != "" {
t.Errorf("Thinking mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.expectedCalls, allCalls, argsComparer); diff != "" {
t.Errorf("Tool calls mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestDeepSeekParser_HasThinkingSupport(t *testing.T) {
tests := []struct {
name string
hasThinking bool
expectedSupport bool
}{
{
name: "thinking_enabled",
hasThinking: true,
expectedSupport: true,
},
{
name: "thinking_disabled",
hasThinking: false,
expectedSupport: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &DeepSeek3Parser{hasThinkingSupport: tt.hasThinking}
if got := parser.HasThinkingSupport(); got != tt.expectedSupport {
t.Errorf("HasThinkingSupport() = %v, want %v", got, tt.expectedSupport)
}
})
}
}
func TestDeepSeekParser_HasToolSupport(t *testing.T) {
parser := &DeepSeek3Parser{}
if !parser.HasToolSupport() {
t.Error("HasToolSupport() should return true")
}
}
func TestDeepSeekParser_Init(t *testing.T) {
parser := &DeepSeek3Parser{hasThinkingSupport: true}
tools := []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "test_tool",
},
},
}
returnedTools := parser.Init(tools, nil, &api.ThinkValue{Value: true})
if diff := cmp.Diff(tools, returnedTools, toolsComparer); diff != "" {
t.Errorf("Init() returned tools mismatch (-want +got):\n%s", diff)
}
// Test initial state is set to thinking when enabled
if parser.state != DeepSeekCollectingThinking {
t.Errorf("Expected initial state to be DeepSeekCollectingThinking, got %v", parser.state)
}
}
func TestDeepSeek3Parser_parseToolCallContent(t *testing.T) {
tests := []struct {
name string
content string
expected api.ToolCall
expectError bool
}{
{
name: "valid_tool_call",
content: "get_weather<tool▁sep>{\"location\":\"Paris\"}",
expected: api.ToolCall{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
}),
},
},
},
{
name: "complex_arguments",
content: "process_data<tool▁sep>{\"items\":[\"a\",\"b\"],\"config\":{\"enabled\":true}}",
expected: api.ToolCall{
Function: api.ToolCallFunction{
Name: "process_data",
Arguments: testArgs(map[string]any{
"items": []interface{}{"a", "b"},
"config": map[string]interface{}{"enabled": true},
}),
},
},
},
{
name: "empty_arguments",
content: "ping<tool▁sep>{}",
expected: api.ToolCall{
Function: api.ToolCallFunction{
Name: "ping",
Arguments: api.NewToolCallFunctionArguments(),
},
},
},
{
name: "unicode_in_tool_name",
content: "获取天气<tool▁sep>{\"城市\":\"北京\"}",
expected: api.ToolCall{
Function: api.ToolCallFunction{
Name: "获取天气",
Arguments: testArgs(map[string]any{
"城市": "北京",
}),
},
},
},
{
name: "special_chars_in_arguments",
content: "execute<tool▁sep>{\"command\":\"ls && echo \\\"done\\\"\",\"path\":\"/home/user\"}",
expected: api.ToolCall{
Function: api.ToolCallFunction{
Name: "execute",
Arguments: testArgs(map[string]any{
"command": "ls && echo \"done\"",
"path": "/home/user",
}),
},
},
},
{
name: "numeric_arguments",
content: "calculate<tool▁sep>{\"x\":3.14,\"y\":42,\"enabled\":true}",
expected: api.ToolCall{
Function: api.ToolCallFunction{
Name: "calculate",
Arguments: testArgs(map[string]any{
"x": 3.14,
"y": float64(42),
"enabled": true,
}),
},
},
},
{
name: "invalid_format_no_separator",
content: "get_weather{\"location\":\"Paris\"}",
expectError: true,
},
{
name: "invalid_json",
content: "get_weather<tool▁sep>{invalid json}",
expectError: true,
},
{
name: "empty_tool_name",
content: "<tool▁sep>{\"arg\":\"value\"}",
expectError: false, // This should work, just empty name
expected: api.ToolCall{
Function: api.ToolCallFunction{
Name: "",
Arguments: testArgs(map[string]any{
"arg": "value",
}),
},
},
},
{
name: "missing_json_part",
content: "tool_name<tool▁sep>",
expectError: true,
},
}
parser := &DeepSeek3Parser{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parser.parseToolCallContent(tt.content)
if tt.expectError {
if err == nil {
t.Error("Expected error but got none")
}
return
}
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if diff := cmp.Diff(tt.expected, result, argsComparer); diff != "" {
t.Errorf("parseToolCallContent() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestDeepSeekParser_EdgeCases(t *testing.T) {
tests := []struct {
name string
input string
expectedContent string
expectedThinking string
hasThinking bool
}{
{
name: "nested_think_tags_in_thinking",
input: "Outer thinking <think>inner</think> content</think>Final content",
expectedThinking: "Outer thinking <think>inner",
expectedContent: "content</think>Final content",
hasThinking: true,
},
{
name: "multiple_think_close_tags",
input: "First thought</think>Second thought</think>Final content",
expectedThinking: "First thought",
expectedContent: "Second thought</think>Final content",
hasThinking: true,
},
{
name: "empty_thinking_content",
input: "</think>Just content",
expectedThinking: "",
expectedContent: "Just content",
hasThinking: true,
},
{
name: "thinking_disabled_with_think_tags",
input: "Some content</think>More content",
expectedContent: "Some content</think>More content",
hasThinking: false,
},
{
name: "malformed_tool_call_missing_sep",
input: "Testing.<tool▁calls▁begin><tool▁call▁begin>bad_tool{\"arg\":\"value\"}<tool▁call▁end><tool▁calls▁end>",
expectedContent: "Testing.",
hasThinking: false,
},
{
name: "malformed_tool_call_invalid_json",
input: "Testing.<tool▁calls▁begin><tool▁call▁begin>bad_tool<tool▁sep>{invalid json}<tool▁call▁end><tool▁calls▁end>",
expectedContent: "Testing.",
hasThinking: false,
},
{
name: "partial_tool_tag_at_end",
input: "Content with partial <tool▁calls▁",
expectedContent: "Content with partial <tool▁calls▁",
hasThinking: false,
},
{
name: "partial_think_tag_at_end",
input: "Thinking content</th",
expectedContent: "Thinking content</th",
hasThinking: false,
},
{
name: "partial_think_tag_at_end_with_thinking",
input: "Thinking content</th",
expectedThinking: "Thinking content",
expectedContent: "",
hasThinking: true,
},
{
name: "whitespace_only_content",
input: " \n\t ",
expectedContent: " \n\t ",
hasThinking: false,
},
{
name: "tool_output_with_newlines",
input: "Output:\n<tool▁output▁begin>Line 1\nLine 2\nLine 3<tool▁output▁end>\nDone.",
expectedContent: "Output:\nLine 1\nLine 2\nLine 3\nDone.",
hasThinking: false,
},
{
name: "consecutive_tool_calls",
input: "First.<tool▁calls▁begin><tool▁call▁begin>tool1<tool▁sep>{}<tool▁call▁end><tool▁calls▁end>Second.<tool▁calls▁begin><tool▁call▁begin>tool2<tool▁sep>{}<tool▁call▁end><tool▁calls▁end>",
expectedContent: "First.",
hasThinking: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &DeepSeek3Parser{hasThinkingSupport: tt.hasThinking}
parser.Init([]api.Tool{}, nil, &api.ThinkValue{Value: tt.hasThinking})
content, thinking, _, err := parser.Add(tt.input, true)
if err != nil {
t.Fatalf("Add() error = %v", err)
}
if diff := cmp.Diff(tt.expectedContent, content); diff != "" {
t.Errorf("Content mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.expectedThinking, thinking); diff != "" {
t.Errorf("Thinking mismatch (-want +got):\n%s", diff)
}
})
}
}

View File

@@ -0,0 +1,330 @@
package parsers
import (
"fmt"
"regexp"
"strings"
"github.com/ollama/ollama/api"
)
type FunctionGemmaParserState int
const (
FunctionGemmaCollectingContent FunctionGemmaParserState = iota
FunctionGemmaCollectingToolCalls
)
const (
functionGemmaFunctionCallOpen = "<start_function_call>"
functionGemmaFunctionCallClose = "<end_function_call>"
)
// 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
callIndex int
}
func (p *FunctionGemmaParser) HasToolSupport() bool { return true }
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
}
type functionGemmaEvent interface {
isFunctionGemmaEvent()
}
type FunctionGemmaEventContent struct {
content string
}
type functionGemmaEventToolCall struct {
toolCall api.ToolCall
}
func (FunctionGemmaEventContent) isFunctionGemmaEvent() {}
func (functionGemmaEventToolCall) isFunctionGemmaEvent() {}
func (p *FunctionGemmaParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
events := p.parseEvents()
var toolCalls []api.ToolCall
var contentSb strings.Builder
for _, event := range events {
switch event := event.(type) {
case functionGemmaEventToolCall:
toolCalls = append(toolCalls, event.toolCall)
case FunctionGemmaEventContent:
contentSb.WriteString(event.content)
}
}
for i := range toolCalls {
toolCalls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentSb.String(), "", toolCalls, nil
}
func (p *FunctionGemmaParser) parseEvents() []functionGemmaEvent {
var all []functionGemmaEvent
keepLooping := true
for keepLooping {
var events []functionGemmaEvent
events, keepLooping = p.eat()
if len(events) > 0 {
all = append(all, events...)
}
}
return all
}
// emitWithPartialCheck extracts unambiguous content before a potential partial tag
func (p *FunctionGemmaParser) emitWithPartialCheck(bufStr, tag string) (unambiguous, ambiguous string) {
if overlapLen := overlap(bufStr, tag); overlapLen > 0 {
beforePartialTag := bufStr[:len(bufStr)-overlapLen]
return beforePartialTag, bufStr[len(beforePartialTag):]
}
return bufStr, ""
}
func (p *FunctionGemmaParser) eat() ([]functionGemmaEvent, bool) {
bufStr := p.buffer.String()
if bufStr == "" {
return nil, false
}
switch p.state {
case FunctionGemmaCollectingContent:
if strings.Contains(bufStr, functionGemmaFunctionCallOpen) {
split := strings.SplitN(bufStr, functionGemmaFunctionCallOpen, 2)
content := split[0]
p.buffer.Reset()
p.buffer.WriteString(split[1])
p.state = FunctionGemmaCollectingToolCalls
if content != "" {
return []functionGemmaEvent{FunctionGemmaEventContent{content: content}}, true
}
return nil, true
}
unambig, ambig := p.emitWithPartialCheck(bufStr, functionGemmaFunctionCallOpen)
p.buffer.Reset()
p.buffer.WriteString(ambig)
if unambig != "" {
return []functionGemmaEvent{FunctionGemmaEventContent{content: unambig}}, false
}
return nil, false
case FunctionGemmaCollectingToolCalls:
if strings.Contains(bufStr, functionGemmaFunctionCallClose) {
split := strings.SplitN(bufStr, functionGemmaFunctionCallClose, 2)
remaining := split[1]
p.buffer.Reset()
p.buffer.WriteString(remaining)
var events []functionGemmaEvent
if tc, err := p.parseToolCall(split[0]); err == nil {
events = append(events, functionGemmaEventToolCall{toolCall: tc})
}
if !strings.Contains(remaining, functionGemmaFunctionCallOpen) {
p.state = FunctionGemmaCollectingContent
}
return events, true
}
return nil, false
}
return nil, false
}
// Matches call:function_name{args}
var functionGemmaCallRegex = regexp.MustCompile(`call:([^{]+)\{(.*)\}`)
func (p *FunctionGemmaParser) parseToolCall(content string) (api.ToolCall, error) {
toolCall := api.ToolCall{}
// Extract function name and arguments
match := functionGemmaCallRegex.FindStringSubmatch(content)
if len(match) < 3 {
return toolCall, nil
}
toolCall.Function.Name = match[1]
argsStr := match[2]
// Parse arguments
toolCall.Function.Arguments = p.parseArguments(argsStr)
return toolCall, nil
}
// parseArguments parses the key:value,key:value format
func (p *FunctionGemmaParser) parseArguments(argsStr string) api.ToolCallFunctionArguments {
args := api.NewToolCallFunctionArguments()
if argsStr == "" {
return args
}
// Split by comma, but handle nested structures
parts := p.splitArguments(argsStr)
for _, part := range parts {
// Find the first colon to split key:value
colonIdx := strings.Index(part, ":")
if colonIdx == -1 {
continue
}
key := part[:colonIdx]
value := part[colonIdx+1:]
// Parse the value
args.Set(key, p.parseValue(value))
}
return args
}
// splitArguments splits arguments by comma, respecting nested structures
func (p *FunctionGemmaParser) splitArguments(argsStr string) []string {
var parts []string
var current strings.Builder
depth := 0
inEscape := false
for i := 0; i < len(argsStr); i++ {
ch := argsStr[i]
// Check for <escape> tags
if i+8 <= len(argsStr) && argsStr[i:i+8] == "<escape>" {
inEscape = !inEscape
current.WriteString("<escape>")
i += 7 // Skip the rest of <escape>
continue
}
if !inEscape {
switch ch {
case '{', '[':
depth++
current.WriteByte(ch)
case '}', ']':
depth--
current.WriteByte(ch)
case ',':
if depth == 0 {
if current.Len() > 0 {
parts = append(parts, current.String())
current.Reset()
}
continue
}
current.WriteByte(ch)
default:
current.WriteByte(ch)
}
} else {
current.WriteByte(ch)
}
}
if current.Len() > 0 {
parts = append(parts, current.String())
}
return parts
}
// parseValue parses a single value from the FunctionGemma format
func (p *FunctionGemmaParser) parseValue(value string) any {
// Check for escaped string
if strings.HasPrefix(value, "<escape>") && strings.HasSuffix(value, "<escape>") {
// Remove the escape tags
return value[8 : len(value)-8]
}
// Check for boolean
if value == "true" {
return true
}
if value == "false" {
return false
}
// Check for number
if num, ok := parseNumber(value); ok {
return num
}
// Check for array
if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
return p.parseArray(value[1 : len(value)-1])
}
// Check for object
if strings.HasPrefix(value, "{") && strings.HasSuffix(value, "}") {
return p.parseObject(value[1 : len(value)-1])
}
// Default to string
return value
}
// parseArray parses an array value
func (p *FunctionGemmaParser) parseArray(content string) []any {
var result []any
parts := p.splitArguments(content)
for _, part := range parts {
result = append(result, p.parseValue(part))
}
return result
}
// parseObject parses an object value
func (p *FunctionGemmaParser) parseObject(content string) map[string]any {
result := make(map[string]any)
parts := p.splitArguments(content)
for _, part := range parts {
colonIdx := strings.Index(part, ":")
if colonIdx == -1 {
continue
}
key := part[:colonIdx]
value := part[colonIdx+1:]
result[key] = p.parseValue(value)
}
return result
}
// parseNumber tries to parse a string as a number
func parseNumber(s string) (any, bool) {
// Try integer first
var intVal int64
if _, err := fmt.Sscanf(s, "%d", &intVal); err == nil {
// Check if the entire string was consumed
if fmt.Sprintf("%d", intVal) == s {
return intVal, true
}
}
// Try float
var floatVal float64
if _, err := fmt.Sscanf(s, "%f", &floatVal); err == nil {
return floatVal, true
}
return nil, false
}

View File

@@ -0,0 +1,432 @@
package parsers
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
"github.com/stretchr/testify/assert"
)
func TestFunctionGemmaParser(t *testing.T) {
tests := []struct {
name string
chunks []string
tools []api.Tool
expectedCalls []api.ToolCall
expectedText string
}{
{
name: "plain_content",
chunks: []string{"H", "e", "l", "l", "o", ",", " ", "w", "o", "r", "l", "d", "!"},
expectedCalls: nil,
expectedText: "Hello, world!",
},
{
name: "simple_tool_call",
chunks: []string{
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "get", "_", "weather", "{",
"city", ":", "<", "escape", ">", "Paris", "<", "escape", ">",
"}", "<", "end", "_", "function", "_", "call", ">",
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"city": {Type: api.PropertyType{"string"}},
}),
},
},
},
},
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
},
},
expectedText: "",
},
{
name: "content_before_tool_call",
chunks: []string{
"L", "et", " ", "me", " ", "check", ".",
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "get", "_", "weather", "{",
"city", ":", "<", "escape", ">", "Paris", "<", "escape", ">",
"}", "<", "end", "_", "function", "_", "call", ">",
},
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
},
},
expectedText: "Let me check.",
},
{
name: "numeric_arguments",
chunks: []string{
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "add", "{",
"a", ":", "1", ",", "b", ":", "2",
"}", "<", "end", "_", "function", "_", "call", ">",
},
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "add",
Arguments: testArgs(map[string]any{"a": int64(1), "b": int64(2)}),
},
},
},
expectedText: "",
},
{
name: "boolean_arguments",
chunks: []string{
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "set", "_", "flag", "{",
"enabled", ":", "true", ",", "verbose", ":", "false",
"}", "<", "end", "_", "function", "_", "call", ">",
},
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "set_flag",
Arguments: testArgs(map[string]any{"enabled": true, "verbose": false}),
},
},
},
expectedText: "",
},
{
name: "multiple_tool_calls",
chunks: []string{
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "get", "_", "weather", "{",
"city", ":", "<", "escape", ">", "Paris", "<", "escape", ">",
"}", "<", "end", "_", "function", "_", "call", ">",
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "get", "_", "weather", "{",
"city", ":", "<", "escape", ">", "London", "<", "escape", ">",
"}", "<", "end", "_", "function", "_", "call", ">",
},
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"}),
},
},
},
expectedText: "",
},
{
name: "array_argument",
chunks: []string{
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "process", "{",
"items", ":", "[",
"<", "escape", ">", "a", "<", "escape", ">", ",",
"<", "escape", ">", "b", "<", "escape", ">", ",",
"<", "escape", ">", "c", "<", "escape", ">",
"]",
"}", "<", "end", "_", "function", "_", "call", ">",
},
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "process",
Arguments: testArgs(map[string]any{"items": []any{"a", "b", "c"}}),
},
},
},
expectedText: "",
},
{
name: "object_argument",
chunks: []string{
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "update", "{",
"data", ":", "{",
"name", ":", "<", "escape", ">", "test", "<", "escape", ">", ",",
"value", ":", "42",
"}",
"}", "<", "end", "_", "function", "_", "call", ">",
},
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "update",
Arguments: testArgs(map[string]any{
"data": map[string]any{"name": "test", "value": int64(42)},
}),
},
},
},
expectedText: "",
},
{
name: "empty_input",
chunks: []string{},
expectedCalls: nil,
expectedText: "",
},
{
name: "tool_call_with_no_arguments",
chunks: []string{
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "get", "_", "time", "{", "}",
"<", "end", "_", "function", "_", "call", ">",
},
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_time",
Arguments: api.NewToolCallFunctionArguments(),
},
},
},
expectedText: "",
},
{
name: "content_with_angle_brackets",
chunks: []string{
"The", " ", "result", " ", "is", " ", "a", " ", "<", "value", ">", " ", "tag",
},
expectedCalls: nil,
expectedText: "The result is a <value> tag",
},
{
name: "float_argument",
chunks: []string{
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "set", "_", "temp", "{",
"value", ":", "3", ".", "14",
"}", "<", "end", "_", "function", "_", "call", ">",
},
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "set_temp",
Arguments: testArgs(map[string]any{"value": 3.14}),
},
},
},
expectedText: "",
},
{
name: "content_after_tool_call",
chunks: []string{
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "test", "{", "}",
"<", "end", "_", "function", "_", "call", ">",
"Done", "!",
},
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "test",
Arguments: api.NewToolCallFunctionArguments(),
},
},
},
expectedText: "Done!",
},
{
name: "unicode_content_and_arguments",
chunks: []string{
"こんにちは", " ",
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "greet", "{",
"name", ":", "<", "escape", ">", "日本語", "<", "escape", ">",
"}", "<", "end", "_", "function", "_", "call", ">",
},
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "greet",
Arguments: testArgs(map[string]any{"name": "日本語"}),
},
},
},
expectedText: "こんにちは ",
},
{
name: "multiple_params_sorted",
chunks: []string{
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "search", "{",
"query", ":", "<", "escape", ">", "test", "<", "escape", ">", ",",
"limit", ":", "10", ",",
"offset", ":", "0",
"}", "<", "end", "_", "function", "_", "call", ">",
},
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "search",
Arguments: testArgs(map[string]any{
"query": "test",
"limit": int64(10),
"offset": int64(0),
}),
},
},
},
expectedText: "",
},
{
name: "nested_object_argument",
chunks: []string{
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "create", "{",
"config", ":", "{",
"settings", ":", "{",
"enabled", ":", "true", ",",
"name", ":", "<", "escape", ">", "test", "<", "escape", ">",
"}",
"}",
"}", "<", "end", "_", "function", "_", "call", ">",
},
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "create",
Arguments: testArgs(map[string]any{
"config": map[string]any{
"settings": map[string]any{
"enabled": true,
"name": "test",
},
},
}),
},
},
},
expectedText: "",
},
{
name: "partial_start_tag_in_content",
chunks: []string{
"Hello", " ", "<", "start", " ", "world",
},
expectedCalls: nil,
expectedText: "Hello <start world",
},
{
name: "parallel_tool_calls",
chunks: []string{
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "get", "_", "weather", "{",
"city", ":", "<", "escape", ">", "Paris", "<", "escape", ">",
"}", "<", "end", "_", "function", "_", "call", ">",
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "get", "_", "time", "{",
"timezone", ":", "<", "escape", ">", "UTC", "<", "escape", ">",
"}", "<", "end", "_", "function", "_", "call", ">",
},
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"}),
},
},
},
expectedText: "",
},
{
name: "content_between_tool_calls",
chunks: []string{
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "first", "{", "}",
"<", "end", "_", "function", "_", "call", ">",
"Some", " ", "text", " ", "here",
"<", "start", "_", "function", "_", "call", ">",
"call", ":", "second", "{", "}",
"<", "end", "_", "function", "_", "call", ">",
},
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Index: 0,
Name: "first",
Arguments: api.NewToolCallFunctionArguments(),
},
},
{
Function: api.ToolCallFunction{
Index: 1,
Name: "second",
Arguments: api.NewToolCallFunctionArguments(),
},
},
},
expectedText: "Some text here",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &FunctionGemmaParser{}
parser.Init(tt.tools, nil, nil)
var allContent string
var allCalls []api.ToolCall
for i, chunk := range tt.chunks {
done := i == len(tt.chunks)-1
content, _, calls, err := parser.Add(chunk, done)
assert.NoError(t, err)
allContent += content
allCalls = append(allCalls, calls...)
}
// Handle empty chunks case
if len(tt.chunks) == 0 {
content, _, calls, err := parser.Add("", true)
assert.NoError(t, err)
allContent = content
allCalls = calls
}
assert.Equal(t, tt.expectedText, allContent)
if diff := cmp.Diff(tt.expectedCalls, allCalls, argsComparer); diff != "" {
t.Errorf("calls mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestFunctionGemmaParser_HasSupport(t *testing.T) {
parser := &FunctionGemmaParser{}
assert.True(t, parser.HasToolSupport())
assert.False(t, parser.HasThinkingSupport())
}

841
model/parsers/gemma4.go Normal file
View File

@@ -0,0 +1,841 @@
package parsers
import (
"encoding/json"
"errors"
"log/slog"
"regexp"
"strings"
"unicode"
"unicode/utf8"
"github.com/ollama/ollama/api"
)
type Gemma4ParserState int
const (
Gemma4CollectingContent Gemma4ParserState = iota
Gemma4CollectingThinking
Gemma4CollectingToolCall
Gemma4IgnoringPostToolCallNoise
)
const (
gemma4ThinkingOpenTag = "<|channel>"
gemma4ThinkingCloseTag = "<channel|>"
gemma4ToolCallOpenTag = "<|tool_call>"
gemma4ToolCallCloseTag = "<tool_call|>"
gemma4ToolResponseTag = "<|tool_response>"
gemma4StringDelimiter = `<|"|>`
)
var (
gemma4QuotedStringRe = regexp.MustCompile(`(?s)<\|"\|>(.*?)<\|"\|>`)
)
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"
}
func (p *Gemma4Parser) HasToolSupport() bool {
return true
}
func (p *Gemma4Parser) HasThinkingSupport() bool {
return p.hasThinkingSupport
}
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"
p.thinkingEnabled = p.HasThinkingSupport() && (thinkValue != nil && thinkValue.Bool())
if !p.thinkingEnabled {
p.state = Gemma4CollectingContent
return tools
}
if prefill && lastMessage.Content != "" {
p.state = Gemma4CollectingContent
return tools
}
// When thinking is enabled, start in content mode but we'll switch to
// thinking when we see <|channel>. The model typically starts with
// <|channel> immediately when thinking is enabled.
p.state = Gemma4CollectingContent
return tools
}
type gemma4Event interface {
isGemma4Event()
}
type gemma4EventThinkingContent struct {
content string
}
type gemma4EventContent struct {
content string
}
type gemma4EventToolCall struct {
toolCall api.ToolCall
}
func (gemma4EventThinkingContent) isGemma4Event() {}
func (gemma4EventContent) isGemma4Event() {}
func (gemma4EventToolCall) isGemma4Event() {}
func (p *Gemma4Parser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
events := p.parseEvents(done)
var toolCalls []api.ToolCall
var contentSb strings.Builder
var thinkingSb strings.Builder
for _, event := range events {
switch event := event.(type) {
case gemma4EventToolCall:
toolCalls = append(toolCalls, event.toolCall)
case gemma4EventThinkingContent:
if p.thinkingEnabled {
thinkingSb.WriteString(event.content)
}
// When thinking is disabled, silently discard channel content
case gemma4EventContent:
contentSb.WriteString(event.content)
}
}
for i := range toolCalls {
toolCalls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentSb.String(), thinkingSb.String(), toolCalls, nil
}
func (p *Gemma4Parser) parseEvents(done bool) []gemma4Event {
var all []gemma4Event
keepLooping := true
for keepLooping {
var events []gemma4Event
events, keepLooping = p.eat(done)
if len(events) > 0 {
all = append(all, events...)
}
}
return all
}
// longestOverlap returns the longest overlap between the suffix of bufStr and
// a prefix of any of the given tags.
func longestOverlap(bufStr string, tags ...string) int {
maxOverlap := 0
for _, tag := range tags {
if o := overlap(bufStr, tag); o > maxOverlap {
maxOverlap = o
}
}
return maxOverlap
}
func (p *Gemma4Parser) eat(done bool) ([]gemma4Event, bool) {
var events []gemma4Event
bufStr := p.buffer.String()
if bufStr == "" {
return events, false
}
switch p.state {
case Gemma4CollectingContent:
// Check for thinking open tag
if idx := strings.Index(bufStr, gemma4ThinkingOpenTag); idx != -1 {
contentBefore := bufStr[:idx]
remaining := bufStr[idx+len(gemma4ThinkingOpenTag):]
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = Gemma4CollectingThinking
p.needsChannelNameStrip = true
if contentBefore = strings.TrimRightFunc(contentBefore, unicode.IsSpace); len(contentBefore) > 0 {
events = append(events, gemma4EventContent{content: contentBefore})
}
return events, true
}
// Check for tool call open tag
if idx := strings.Index(bufStr, gemma4ToolCallOpenTag); idx != -1 {
contentBefore := bufStr[:idx]
remaining := bufStr[idx+len(gemma4ToolCallOpenTag):]
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = Gemma4CollectingToolCall
if contentBefore = strings.TrimRightFunc(contentBefore, unicode.IsSpace); len(contentBefore) > 0 {
events = append(events, gemma4EventContent{content: contentBefore})
}
return events, true
}
// Check for partial tag overlap
if !done {
if overlapLen := longestOverlap(bufStr, gemma4ThinkingOpenTag, gemma4ToolCallOpenTag); overlapLen > 0 {
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, gemma4EventContent{content: unambiguous})
}
return events, false
}
}
// No tags found, emit all content
p.buffer.Reset()
if len(bufStr) > 0 {
events = append(events, gemma4EventContent{content: bufStr})
}
return events, false
case Gemma4CollectingThinking:
// Strip channel name (e.g., "thought\n") after <|channel>.
// Gemma 4 format: <|channel>thought\n...content...<channel|>
// In streaming mode, "thought" and "\n" may arrive in separate chunks.
if p.needsChannelNameStrip {
if strings.HasPrefix(bufStr, "thought\n") {
bufStr = bufStr[len("thought\n"):]
p.buffer.Reset()
p.buffer.WriteString(bufStr)
p.needsChannelNameStrip = false
} else if !done && (bufStr == "thought" || strings.HasPrefix("thought\n", bufStr)) {
// Partial match — wait for more data.
return events, false
} else {
// No match (different channel name or no newline) — don't strip.
p.needsChannelNameStrip = false
}
}
if strings.Contains(bufStr, gemma4ThinkingCloseTag) {
split := strings.SplitN(bufStr, gemma4ThinkingCloseTag, 2)
thinking := strings.TrimRightFunc(split[0], unicode.IsSpace)
remaining := strings.TrimLeftFunc(split[1], unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = Gemma4CollectingContent
if len(thinking) > 0 {
events = append(events, gemma4EventThinkingContent{content: thinking})
}
return events, true
}
// Check for partial close tag
if !done {
if overlapLen := overlap(bufStr, gemma4ThinkingCloseTag); overlapLen > 0 {
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, gemma4EventThinkingContent{content: unambiguous})
}
return events, false
}
}
// No close tag, emit thinking content (hold back trailing whitespace)
if !done {
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, gemma4EventThinkingContent{content: unambiguous})
}
} else {
p.buffer.Reset()
if len(bufStr) > 0 {
events = append(events, gemma4EventThinkingContent{content: bufStr})
}
}
return events, false
case Gemma4CollectingToolCall:
if idx := strings.Index(bufStr, gemma4ToolCallCloseTag); idx != -1 {
toolCallContent := bufStr[:idx]
remaining := bufStr[idx+len(gemma4ToolCallCloseTag):]
remaining = strings.TrimLeftFunc(remaining, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = Gemma4IgnoringPostToolCallNoise
if toolCall, err := parseGemma4ToolCall(toolCallContent, p.tools); err == nil {
events = append(events, gemma4EventToolCall{toolCall: toolCall})
} else {
slog.Warn("gemma4 tool call parsing failed", "error", err, "content", toolCallContent)
}
return events, true
}
// If done, flush any accumulated tool call content even without closing tag.
// The model may hit a stop token before emitting <tool_call|>.
if done && len(bufStr) > 0 {
p.buffer.Reset()
p.state = Gemma4CollectingContent
if toolCall, err := parseGemma4ToolCall(bufStr, p.tools); err == nil {
events = append(events, gemma4EventToolCall{toolCall: toolCall})
} else {
slog.Warn("gemma4 tool call flush on done failed", "error", err, "content", bufStr)
}
return events, false
}
// Wait for closing tag
return events, false
case Gemma4IgnoringPostToolCallNoise:
// We've observed Gemma 4 occasionally emitting extra <tool_call|> tags
// after a valid tool call. We suppress those leading control tags in this
// immediate post-tool-call state so they do not leak into assistant
// content. The tradeoff is that if the model intentionally begins its next
// content span with one of those literal strings, we will erroneously
// treat it as noise and drop it. We also suppress a leading
// <|tool_response> marker here because the updated upstream parser/template
// uses it as a post-tool-call boundary.
bufStr = strings.TrimLeftFunc(bufStr, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(bufStr)
for {
switch {
case strings.HasPrefix(bufStr, gemma4ToolCallCloseTag):
bufStr = strings.TrimLeftFunc(bufStr[len(gemma4ToolCallCloseTag):], unicode.IsSpace)
case strings.HasPrefix(bufStr, gemma4ToolResponseTag):
bufStr = strings.TrimLeftFunc(bufStr[len(gemma4ToolResponseTag):], unicode.IsSpace)
default:
p.buffer.Reset()
p.buffer.WriteString(bufStr)
goto strippedPostToolCallNoise
}
p.buffer.Reset()
p.buffer.WriteString(bufStr)
}
strippedPostToolCallNoise:
if bufStr == "" {
return events, false
}
if strings.HasPrefix(gemma4ToolCallCloseTag, bufStr) || strings.HasPrefix(gemma4ToolResponseTag, bufStr) {
if done {
p.buffer.Reset()
p.state = Gemma4CollectingContent
}
return events, false
}
p.state = Gemma4CollectingContent
return events, true
}
return events, false
}
// parseGemma4ToolCall parses a tool call in Gemma 4 format:
// call:NAME{key:value,key:value}
func parseGemma4ToolCall(content string, tools []api.Tool) (api.ToolCall, error) {
// Expected format: call:NAME{args}
if !strings.HasPrefix(content, "call:") {
return api.ToolCall{}, errors.New("expected 'call:' prefix")
}
content = content[len("call:"):]
// Find the opening brace for args
braceIdx := strings.Index(content, "{")
if braceIdx == -1 {
return api.ToolCall{}, errors.New("expected '{' in tool call")
}
toolName := strings.TrimSpace(content[:braceIdx])
argsStr := content[braceIdx:]
// Convert Gemma 4 argument format to JSON
jsonStr := gemma4ArgsToJSON(argsStr)
var args api.ToolCallFunctionArguments
if err := json.Unmarshal([]byte(jsonStr), &args); err != nil {
repairedArgs, repairErr := repairGemma4ToolCallArgs(argsStr, toolName, tools)
if repairErr != nil {
return api.ToolCall{}, errors.Join(err, repairErr)
}
args = repairedArgs
}
return api.ToolCall{
Function: api.ToolCallFunction{
Name: toolName,
Arguments: args,
},
}, nil
}
// gemma4ArgsToJSON converts Gemma 4's custom argument format to valid JSON.
func gemma4ArgsToJSON(s string) string {
var quotedStrings []string
text := gemma4QuotedStringRe.ReplaceAllStringFunc(s, func(match string) string {
submatches := gemma4QuotedStringRe.FindStringSubmatch(match)
quotedStrings = append(quotedStrings, submatches[1])
return "\x00" + string(rune(len(quotedStrings)-1)) + "\x00"
})
text = quoteGemma4BareKeys(text)
for i, value := range quotedStrings {
escaped, _ := json.Marshal(value)
text = strings.ReplaceAll(text, "\x00"+string(rune(i))+"\x00", string(escaped))
}
return text
}
func quoteGemma4BareKeys(s string) string {
var sb strings.Builder
sb.Grow(len(s) + 16)
for i := 0; i < len(s); {
if s[i] == '"' {
if end := gemma4JSONQuotedStringEnd(s, i); end != -1 {
sb.WriteString(s[i:end])
i = end
continue
}
}
if s[i] != '{' && s[i] != ',' {
sb.WriteByte(s[i])
i++
continue
}
sb.WriteByte(s[i])
i++
spaceStart := i
i = gemma4SkipSpace(s, i)
sb.WriteString(s[spaceStart:i])
keyEnd := gemma4BareKeyEnd(s, i)
if keyEnd > i && keyEnd < len(s) && s[keyEnd] == ':' {
sb.WriteByte('"')
sb.WriteString(s[i:keyEnd])
sb.WriteByte('"')
sb.WriteByte(':')
i = keyEnd + 1
continue
}
}
return sb.String()
}
func gemma4BareKeyEnd(s string, start int) int {
i := start
for i < len(s) {
r, size := utf8.DecodeRuneInString(s[i:])
if !(r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)) {
break
}
i += size
}
return i
}
// repairGemma4ToolCallArgs is a best-effort repair after strict parsing fails.
// For example, if the model emits an unclosed gemma string as the last value,
// we can repair it by closing it with the gemma string delimiter.
func repairGemma4ToolCallArgs(argsStr, toolName string, tools []api.Tool) (api.ToolCallFunctionArguments, error) {
for _, candidate := range gemma4RepairCandidates(argsStr, toolName, tools) {
jsonStr := gemma4ArgsToJSON(candidate)
var args api.ToolCallFunctionArguments
if err := json.Unmarshal([]byte(jsonStr), &args); err == nil {
return args, nil
}
}
return api.ToolCallFunctionArguments{}, errors.New("repair failed to produce valid JSON arguments")
}
func gemma4ToolProperties(toolName string, tools []api.Tool) *api.ToolPropertiesMap {
for i := range tools {
if tools[i].Function.Name == toolName {
return tools[i].Function.Parameters.Properties
}
}
return nil
}
// gemma4RepairCandidates returns the small set of repaired argument strings we
// are willing to try after strict parsing fails. Each candidate still has to
// pass the normal Gemma4-to-JSON conversion and JSON unmarshal before it is used.
func gemma4RepairCandidates(argsStr, toolName string, tools []api.Tool) []string {
seen := map[string]bool{}
var candidates []string
addCandidate := func(candidate string, allowMissingObjectClose bool) {
original := candidate
candidate = repairGemma4SingleQuotedValues(candidate)
candidate = repairGemma4MissingStringDelimiter(candidate)
if allowMissingObjectClose || candidate != original {
candidate = repairGemma4MissingObjectClose(candidate)
}
if !seen[candidate] {
candidates = append(candidates, candidate)
seen[candidate] = true
}
}
addCandidate(argsStr, false)
if raw, ok := repairGemma4RawTerminalStringValue(argsStr, toolName, tools); ok {
addCandidate(raw, true)
}
return candidates
}
// repairGemma4MissingStringDelimiter closes an unbalanced Gemma string marker.
// When the value is immediately followed by a closing brace/bracket, the marker
// is inserted before that structural close rather than after it.
func repairGemma4MissingStringDelimiter(s string) string {
if strings.Count(s, gemma4StringDelimiter)%2 == 0 {
return s
}
insertAt := gemma4TrimRightSpaceIndex(s)
if insertAt > 0 && (s[insertAt-1] == '}' || s[insertAt-1] == ']') {
insertAt--
}
var sb strings.Builder
sb.Grow(len(s) + len(gemma4StringDelimiter))
sb.WriteString(s[:insertAt])
sb.WriteString(gemma4StringDelimiter)
sb.WriteString(s[insertAt:])
return sb.String()
}
// repairGemma4MissingObjectClose adds a final object close after another repair
// has made a truncated object plausible. Callers decide when that guardrail is
// satisfied; this helper only performs the mechanical insertion.
func repairGemma4MissingObjectClose(s string) string {
trimmedStart := strings.TrimLeftFunc(s, unicode.IsSpace)
if !strings.HasPrefix(trimmedStart, "{") {
return s
}
trimmedEnd := gemma4TrimRightSpaceIndex(s)
if trimmedEnd > 0 && s[trimmedEnd-1] == '}' {
return s
}
return s[:trimmedEnd] + "}" + s[trimmedEnd:]
}
// repairGemma4SingleQuotedValues converts single-quoted argument values into
// Gemma string-delimited values. It also drops a stray Gemma delimiter that
// sometimes appears immediately after the closing single quote.
func repairGemma4SingleQuotedValues(s string) string {
var sb strings.Builder
sb.Grow(len(s))
for i := 0; i < len(s); {
if strings.HasPrefix(s[i:], gemma4StringDelimiter) {
end := strings.Index(s[i+len(gemma4StringDelimiter):], gemma4StringDelimiter)
if end == -1 {
sb.WriteString(s[i:])
break
}
end = i + len(gemma4StringDelimiter) + end + len(gemma4StringDelimiter)
sb.WriteString(s[i:end])
i = end
continue
}
if s[i] == '"' {
end := gemma4JSONQuotedStringEnd(s, i)
if end != -1 {
sb.WriteString(s[i:end])
i = end
continue
}
}
if s[i] != ':' {
sb.WriteByte(s[i])
i++
continue
}
sb.WriteByte(s[i])
i++
spaceEnd := gemma4SkipSpace(s, i)
sb.WriteString(s[i:spaceEnd])
i = spaceEnd
if i >= len(s) || s[i] != '\'' {
continue
}
value, end, ok := gemma4SingleQuotedValue(s, i)
if !ok {
continue
}
sb.WriteString(gemma4StringDelimiter)
sb.WriteString(value)
sb.WriteString(gemma4StringDelimiter)
i = end
if strings.HasPrefix(s[i:], gemma4StringDelimiter) {
i += len(gemma4StringDelimiter)
}
}
return sb.String()
}
func gemma4SingleQuotedValue(s string, start int) (string, int, bool) {
var sb strings.Builder
escaped := false
for i := start + 1; i < len(s); i++ {
if s[i] == '\'' && !escaped {
return sb.String(), i + 1, true
}
sb.WriteByte(s[i])
escaped = s[i] == '\\' && !escaped
if s[i] != '\\' {
escaped = false
}
}
return "", start, false
}
// repairGemma4RawTerminalStringValue wraps a raw value in Gemma string
// delimiters only when the tool schema says that argument is a string. This is
// deliberately schema-gated because raw text is otherwise too ambiguous.
func repairGemma4RawTerminalStringValue(argsStr, toolName string, tools []api.Tool) (string, bool) {
props := gemma4ToolProperties(toolName, tools)
if props == nil {
return "", false
}
for key, prop := range props.All() {
if !gemma4PropertyAcceptsString(prop) {
continue
}
if repaired, ok := repairGemma4RawTerminalStringValueForKey(argsStr, key, props); ok {
return repaired, true
}
}
return "", false
}
func repairGemma4RawTerminalStringValueForKey(s, key string, props *api.ToolPropertiesMap) (string, bool) {
for searchStart := 0; searchStart < len(s); {
valueStart, ok := gemma4FindValueStartForKey(s, key, searchStart)
if !ok {
return "", false
}
valueCheck := gemma4SkipSpace(s, valueStart)
if valueCheck < len(s) && gemma4ValueStartsStructured(s, valueCheck) {
searchStart = valueStart
continue
}
valueEnd := gemma4RawStringValueEnd(s, valueStart, props)
return s[:valueStart] + gemma4StringDelimiter + s[valueStart:valueEnd] + gemma4StringDelimiter + s[valueEnd:], true
}
return "", false
}
func gemma4FindValueStartForKey(s, key string, searchStart int) (int, bool) {
for i := searchStart; i < len(s); i++ {
if strings.HasPrefix(s[i:], gemma4StringDelimiter) {
end := strings.Index(s[i+len(gemma4StringDelimiter):], gemma4StringDelimiter)
if end == -1 {
return 0, false
}
i += len(gemma4StringDelimiter) + end + len(gemma4StringDelimiter) - 1
continue
}
if s[i] == '"' {
if end := gemma4JSONQuotedStringEnd(s, i); end != -1 {
i = end - 1
continue
}
}
if s[i] != '{' && s[i] != ',' {
continue
}
keyStart := gemma4SkipSpace(s, i+1)
if !strings.HasPrefix(s[keyStart:], key) {
continue
}
colon := gemma4SkipSpace(s, keyStart+len(key))
if colon < len(s) && s[colon] == ':' {
return colon + 1, true
}
}
return 0, false
}
func gemma4RawStringValueEnd(s string, start int, props *api.ToolPropertiesMap) int {
for i := start; i < len(s); i++ {
if s[i] != ',' {
continue
}
keyStart := gemma4SkipSpace(s, i+1)
keyEnd := keyStart
for keyEnd < len(s) {
r, size := utf8.DecodeRuneInString(s[keyEnd:])
if !(r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)) {
break
}
keyEnd += size
}
if keyEnd == keyStart {
continue
}
colon := gemma4SkipSpace(s, keyEnd)
if colon < len(s) && s[colon] == ':' {
if _, ok := props.Get(s[keyStart:keyEnd]); ok {
return i
}
}
}
end := gemma4TrimRightSpaceIndex(s)
if end > start && s[end-1] == '}' {
return end - 1
}
return len(s)
}
func gemma4ValueStartsStructured(s string, pos int) bool {
if pos >= len(s) {
return false
}
if strings.HasPrefix(s[pos:], gemma4StringDelimiter) {
return true
}
switch s[pos] {
case '\'', '"', '{', '[':
return true
}
return gemma4LooksLikeJSONLiteralStart(s[pos])
}
func gemma4JSONQuotedStringEnd(s string, start int) int {
escaped := false
for i := start + 1; i < len(s); i++ {
if s[i] == '"' && !escaped {
return i + 1
}
escaped = s[i] == '\\' && !escaped
if s[i] != '\\' {
escaped = false
}
}
return -1
}
func gemma4SkipSpace(s string, i int) int {
for i < len(s) {
r, size := utf8.DecodeRuneInString(s[i:])
if !unicode.IsSpace(r) {
return i
}
i += size
}
return i
}
func gemma4TrimRightSpaceIndex(s string) int {
i := len(s)
for i > 0 {
r, size := utf8.DecodeLastRuneInString(s[:i])
if !unicode.IsSpace(r) {
return i
}
i -= size
}
return i
}
func gemma4PropertyAcceptsString(prop api.ToolProperty) bool {
for _, typ := range prop.Type {
if strings.EqualFold(typ, "string") {
return true
}
}
for _, anyOf := range prop.AnyOf {
if gemma4PropertyAcceptsString(anyOf) {
return true
}
}
return false
}
func gemma4LooksLikeJSONLiteralStart(ch byte) bool {
return ch == '-' || ('0' <= ch && ch <= '9') || ch == 't' || ch == 'f' || ch == 'n'
}

1466
model/parsers/gemma4_test.go Normal file

File diff suppressed because it is too large Load Diff

578
model/parsers/glm46.go Normal file
View File

@@ -0,0 +1,578 @@
package parsers
import (
"context"
"encoding/xml"
"fmt"
"log/slog"
"strings"
"unicode"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/logutil"
)
type glm46ParserState int
const (
glm46ParserState_LookingForThinkingOpen glm46ParserState = iota
glm46ParserState_ThinkingStartedEatingWhitespace
glm46ParserState_CollectingThinking
glm46ParserState_ThinkingDoneEatingWhitespace
glm46ParserState_CollectingContent
glm46ParserState_ToolStartedEatingWhitespace
glm46ParserState_CollectingToolContent
)
const (
glm46ThinkingOpenTag = "<think>"
glm46ThinkingCloseTag = "</think>"
glm46ToolOpenTag = "<tool_call>"
glm46ToolCloseTag = "</tool_call>"
)
type GLM46Parser struct {
state glm46ParserState
buffer strings.Builder
tools []api.Tool
callIndex int
}
func (p *GLM46Parser) HasToolSupport() bool {
return true
}
func (p *GLM46Parser) HasThinkingSupport() bool {
return true
}
// func (p *GLM46Parser) Init(tools []api.Tool, lastMessage *api.Message) []api.Tool {
func (p *GLM46Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.tools = tools
p.callIndex = 0
return tools
}
type glm46Event interface {
isGLM46Event()
}
type glm46EventContent struct {
content string
}
func (glm46EventContent) isGLM46Event() {}
type glm46EventRawToolCall struct {
raw string
}
func (glm46EventRawToolCall) isGLM46Event() {}
type glm46EventThinkingContent struct {
content string
}
func (glm46EventThinkingContent) isGLM46Event() {}
func (p *GLM46Parser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
events := p.parseEvents()
var toolCalls []api.ToolCall
var contentSb strings.Builder
var thinkingSb strings.Builder
for _, event := range events {
switch event := event.(type) {
case glm46EventRawToolCall:
toolCall, err := parseGLM46ToolCall(event, p.tools)
if err != nil {
slog.Warn("glm-4.6 tool call parsing failed", "error", err)
return "", "", nil, err
}
toolCall.Function.Index = p.callIndex
p.callIndex++
toolCalls = append(toolCalls, toolCall)
case glm46EventThinkingContent:
thinkingSb.WriteString(event.content)
case glm46EventContent:
// TODO(drifkin): if the same turn contains multiple interleaved content
// events, we naively append them together here.
contentSb.WriteString(event.content)
}
}
return contentSb.String(), thinkingSb.String(), toolCalls, nil
}
func (p *GLM46Parser) parseEvents() []glm46Event {
var all []glm46Event
keepLooping := true
for keepLooping {
var events []glm46Event
events, keepLooping = p.eat()
if len(events) > 0 {
all = append(all, events...)
}
}
if len(all) > 0 {
slog.Log(context.TODO(), logutil.LevelTrace, "glm-4.6 events parsed", "events", all, "state", p.state, "buffer", p.buffer.String())
}
return all
}
// eatLeadingWhitespaceAndTransitionTo consumes leading whitespace from the buffer
// and transitions to the next state. Returns (nil, false) if only whitespace remains
// in the buffer (needs more input), or (nil, true) if we successfully transitioned.
func (p *GLM46Parser) eatLeadingWhitespaceAndTransitionTo(nextState glm46ParserState) ([]glm46Event, bool) {
trimmed := strings.TrimLeftFunc(p.buffer.String(), unicode.IsSpace)
p.buffer.Reset()
if trimmed == "" {
return nil, false // Still only whitespace, keep waiting for more input
}
p.state = nextState
p.buffer.WriteString(trimmed)
return nil, true // Successfully transitioned
}
// glm46SplitAtTag splits the buffer at the given tag, returns the content before (trimmed of trailing whitespace),
// the content after (optionally trimmed of leading whitespace), and updates the buffer
func glm46SplitAtTag(p *GLM46Parser, tag string, trimAfter bool) (string, string) {
split := strings.SplitN(p.buffer.String(), tag, 2)
before := split[0]
before = strings.TrimRightFunc(before, unicode.IsSpace)
after := split[1]
if trimAfter {
after = strings.TrimLeftFunc(after, unicode.IsSpace)
}
p.buffer.Reset()
p.buffer.WriteString(after)
return before, after
}
func (p *GLM46Parser) eat() ([]glm46Event, bool) {
var events []glm46Event
switch p.state {
case glm46ParserState_LookingForThinkingOpen:
trimmed := strings.TrimLeftFunc(p.buffer.String(), unicode.IsSpace)
if strings.HasPrefix(trimmed, glm46ThinkingOpenTag) {
// Found <think> opening tag
after := strings.TrimPrefix(trimmed, glm46ThinkingOpenTag)
after = strings.TrimLeftFunc(after, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(after)
if after == "" {
p.state = glm46ParserState_ThinkingStartedEatingWhitespace
} else {
p.state = glm46ParserState_CollectingThinking
}
return events, true
} else if strings.HasPrefix(glm46ThinkingOpenTag, trimmed) {
// Partial opening tag seen, keep accumulating
return events, false
} else if trimmed == "" {
// Only whitespace, keep accumulating
return events, false
} else {
// No thinking tag found, skip to content collection
p.state = glm46ParserState_CollectingContent
// Don't trim - we want to keep the original content
return events, true
}
case glm46ParserState_ThinkingStartedEatingWhitespace:
return p.eatLeadingWhitespaceAndTransitionTo(glm46ParserState_CollectingThinking)
case glm46ParserState_CollectingThinking:
acc := p.buffer.String()
if strings.Contains(acc, glm46ThinkingCloseTag) {
thinking, remaining := glm46SplitAtTag(p, glm46ThinkingCloseTag, true)
if len(thinking) > 0 {
events = append(events, glm46EventThinkingContent{content: thinking})
}
if remaining == "" {
p.state = glm46ParserState_ThinkingDoneEatingWhitespace
} else {
p.state = glm46ParserState_CollectingContent
}
return events, true
} else if overlapLen := overlap(acc, glm46ThinkingCloseTag); overlapLen > 0 {
// Partial closing tag - withhold it along with any trailing whitespace before it
beforePartialTag := acc[:len(acc)-overlapLen]
trailingWhitespaceLen := trailingWhitespaceLen(beforePartialTag)
ambiguousStart := len(beforePartialTag) - trailingWhitespaceLen
unambiguous := acc[:ambiguousStart]
ambiguous := acc[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, glm46EventThinkingContent{content: unambiguous})
}
return events, false
} else {
// Pure thinking content - withhold trailing whitespace (might precede closing tag)
whitespaceLen := trailingWhitespaceLen(acc)
ambiguousStart := len(acc) - whitespaceLen
unambiguous := acc[:ambiguousStart]
ambiguous := acc[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, glm46EventThinkingContent{content: unambiguous})
}
return events, false
}
case glm46ParserState_ThinkingDoneEatingWhitespace:
return p.eatLeadingWhitespaceAndTransitionTo(glm46ParserState_CollectingContent)
case glm46ParserState_CollectingContent:
if strings.Contains(p.buffer.String(), glm46ToolOpenTag) {
before, after := glm46SplitAtTag(p, glm46ToolOpenTag, true)
if len(before) > 0 {
events = append(events, glm46EventContent{content: before})
}
if after == "" {
p.state = glm46ParserState_ToolStartedEatingWhitespace
} else {
p.state = glm46ParserState_CollectingToolContent
}
return events, true
} else if overlapLen := overlap(p.buffer.String(), glm46ToolOpenTag); overlapLen > 0 {
beforePartialTag := p.buffer.String()[:len(p.buffer.String())-overlapLen]
trailingWhitespaceLen := trailingWhitespaceLen(beforePartialTag)
ambiguousStart := len(beforePartialTag) - trailingWhitespaceLen
unambiguous := p.buffer.String()[:ambiguousStart]
ambiguous := p.buffer.String()[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, glm46EventContent{content: unambiguous})
}
return events, false
} else {
whitespaceLen := trailingWhitespaceLen(p.buffer.String())
ambiguousStart := len(p.buffer.String()) - whitespaceLen
unambiguous := p.buffer.String()[:ambiguousStart]
ambiguous := p.buffer.String()[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, glm46EventContent{content: unambiguous})
}
return events, false
}
case glm46ParserState_ToolStartedEatingWhitespace:
return p.eatLeadingWhitespaceAndTransitionTo(glm46ParserState_CollectingToolContent)
case glm46ParserState_CollectingToolContent:
acc := p.buffer.String()
if strings.Contains(acc, glm46ToolCloseTag) {
toolContent, _ := glm46SplitAtTag(p, glm46ToolCloseTag, true)
if len(toolContent) == 0 {
slog.Warn("glm46 tool call closing tag found but no content before it")
}
events = append(events, glm46EventRawToolCall{raw: toolContent})
p.state = glm46ParserState_CollectingContent
return events, true
} else {
// Keep accumulating - tool calls are not streamed
// We just wait for the closing tag
return events, false
}
default:
panic("unreachable")
}
}
// GLMToolCallXML represents the structure of a GLM-4.6 tool call for XML parsing
type GLMToolCallXML struct {
XMLName xml.Name `xml:"tool_call"`
Content string `xml:",chardata"` // Function name (text nodes between tags)
Keys []string `xml:"arg_key"` // All arg_key elements in document order
Values []string `xml:"arg_value"` // All arg_value elements in document order
}
// escapeGLM46Content escapes XML entities in text content while preserving arg_key/arg_value tags
func escapeGLM46Content(s string) string {
var result strings.Builder
inTag := false
for i := range len(s) {
ch := s[i]
if ch == '<' {
// Check if this is a known tag
if strings.HasPrefix(s[i:], "<arg_key>") ||
strings.HasPrefix(s[i:], "</arg_key>") ||
strings.HasPrefix(s[i:], "<arg_value>") ||
strings.HasPrefix(s[i:], "</arg_value>") {
inTag = true
}
}
if inTag {
result.WriteByte(ch)
if ch == '>' {
inTag = false
}
} else {
// Escape special characters in text content
switch ch {
case '&':
result.WriteString("&amp;")
case '<':
result.WriteString("&lt;")
case '>':
result.WriteString("&gt;")
default:
result.WriteByte(ch)
}
}
}
return result.String()
}
// repairPhase represents the expected next tag in the repair cycle.
type repairPhase int
const (
phaseArgKeyOpen repairPhase = iota // expecting <arg_key>
phaseArgKeyClose // expecting </arg_key>
phaseArgValOpen // expecting <arg_value>
phaseArgValClose // expecting </arg_value>
phaseCount // number of phases
)
// repairGLM46XML reconstructs well-formed XML from GLM model output that may
// have missing or mismatched tags. The expected structure is:
//
// func_name
// <arg_key>key</arg_key>
// <arg_value>value</arg_value>
// ...
//
// GLM models frequently omit opening or closing tags. This function follows
// the expected tag cycle, scanning forward for each expected tag in sequence.
// When a tag is missing, it inserts the tag and consumes any text in between.
func repairGLM46XML(s string) string {
// tagCycle is the repeating sequence of tags after the function name.
tagCycle := [phaseCount]string{"<arg_key>", "</arg_key>", "<arg_value>", "</arg_value>"}
// findNextTag returns the index and identity of the earliest known tag in s.
findNextTag := func(s string) (int, string) {
bestIdx := -1
bestTag := ""
for _, tag := range tagCycle {
if idx := strings.Index(s, tag); idx != -1 && (bestIdx == -1 || idx < bestIdx) {
bestIdx = idx
bestTag = tag
}
}
return bestIdx, bestTag
}
// tagIndex returns the phase corresponding to the given tag.
tagIndex := func(tag string) repairPhase {
for i, t := range tagCycle {
if t == tag {
return repairPhase(i)
}
}
return -1
}
var result strings.Builder
idx, firstTag := findNextTag(s)
if idx == -1 {
return s
}
prefix := s[:idx]
s = s[idx:]
// If the first tag is not <arg_key>, the text before it may contain both
// the function name and key content (e.g. "weather city</arg_key>").
// Function names cannot contain space, so split at the first space.
phase := phaseArgKeyOpen
if firstTag != "<arg_key>" {
if spIdx := strings.IndexFunc(prefix, unicode.IsSpace); spIdx != -1 {
result.WriteString(prefix[:spIdx])
keyContent := strings.TrimLeftFunc(prefix[spIdx:], unicode.IsSpace)
result.WriteString("<arg_key>")
result.WriteString(keyContent)
phase = phaseArgKeyClose
} else {
result.WriteString(prefix)
}
} else {
result.WriteString(prefix)
}
// Walk through the expected tag cycle. At each step, look for the
// expected tag. If a different tag appears first, emit the missing
// tags to catch up, then continue.
for len(s) > 0 {
idx, found := findNextTag(s)
expected := tagCycle[phase]
isOpen := phase%2 == 0 // even phases are opening tags
if idx == -1 {
// No more tags — emit remaining text with fixups
if isOpen {
// Expecting an opening tag but nothing left — we're done
break
}
// Expecting a closing tag — emit text then close
result.WriteString(s)
result.WriteString(expected)
phase = (phase + 1) % phaseCount
break
}
if found == expected {
// Found the expected tag — emit any text before it, then the tag
result.WriteString(s[:idx])
result.WriteString(expected)
s = s[idx+len(expected):]
phase = (phase + 1) % phaseCount
continue
}
// Found a different tag. Insert missing tags to catch up.
foundIdx := tagIndex(found)
if isOpen && idx > 0 {
// Text before the found tag while expecting an opening tag —
// the opening tag was omitted. Emit it before the text.
result.WriteString(expected)
// Advance to the next phase (text content) and then look
// for the closing tag — but the found tag might be that
// closing tag or something further ahead. Emit text up to
// the found tag and insert any missing tags between.
result.WriteString(s[:idx])
phase = (phase + 1) % phaseCount // now expecting closing
s = s[idx:]
// Fall through to re-evaluate with the closing tag expected
continue
}
// Emit missing tags to advance from current phase to the found tag's phase
for phase != foundIdx {
tag := tagCycle[phase]
if phase%2 == 0 {
result.WriteString(tag)
} else {
// Closing tag — emit any text before the found tag first,
// but only if we're one step before the found tag
if (phase+1)%phaseCount == foundIdx && idx > 0 {
result.WriteString(s[:idx])
s = s[idx:]
idx = 0
}
result.WriteString(tag)
}
phase = (phase + 1) % phaseCount
}
// Now phase == foundIdx, re-process without advancing s
}
// If we stopped mid-pair (after an opening tag), close it
switch phase {
case phaseArgKeyClose: // after <arg_key>, expecting text/</arg_key>
result.WriteString("</arg_key>")
result.WriteString("<arg_value>")
result.WriteString("</arg_value>")
case phaseArgValOpen: // after </arg_key>, expecting <arg_value>
result.WriteString("<arg_value>")
result.WriteString("</arg_value>")
case phaseArgValClose: // after <arg_value>, expecting text/</arg_value>
result.WriteString("</arg_value>")
}
return result.String()
}
func parseGLM46ToolCall(raw glm46EventRawToolCall, tools []api.Tool) (api.ToolCall, error) {
// Escape any unescaped entities in text content
// We need to escape text between tags, but not the tags themselves
escaped := escapeGLM46Content(raw.raw)
// Wrap the content in a root element to make it valid XML
xmlString := "<tool_call>" + escaped + "</tool_call>"
// Parse XML into struct, retrying once with repaired XML if it fails
var parsed GLMToolCallXML
if err := xml.Unmarshal([]byte(xmlString), &parsed); err != nil {
parsed = GLMToolCallXML{}
repaired := "<tool_call>" + repairGLM46XML(escaped) + "</tool_call>"
if err2 := xml.Unmarshal([]byte(repaired), &parsed); err2 != nil {
return api.ToolCall{}, fmt.Errorf("failed to parse XML: %w", err)
}
}
// Extract and trim function name
functionName := strings.TrimSpace(parsed.Content)
if functionName == "" {
return api.ToolCall{}, fmt.Errorf("empty function name")
}
// Verify keys and values are paired correctly
if len(parsed.Keys) != len(parsed.Values) {
return api.ToolCall{}, fmt.Errorf("mismatched arg_key and arg_value counts: %d keys, %d values", len(parsed.Keys), len(parsed.Values))
}
// Find the matching tool to get parameter types
var matchedTool *api.Tool
for i := range tools {
if tools[i].Function.Name == functionName {
matchedTool = &tools[i]
break
}
}
// Build arguments map by pairing keys and values
toolCall := api.ToolCall{
Function: api.ToolCallFunction{
Name: functionName,
Arguments: api.NewToolCallFunctionArguments(),
},
}
for i := range parsed.Keys {
key := strings.TrimSpace(parsed.Keys[i])
value := parsed.Values[i] // Don't trim here - parseValue handles it
// Look up parameter type
var paramType api.PropertyType
if matchedTool != nil && matchedTool.Function.Parameters.Properties != nil {
if prop, ok := matchedTool.Function.Parameters.Properties.Get(key); ok {
// Handle anyOf by collecting all types from the union
if len(prop.AnyOf) > 0 {
for _, anyOfProp := range prop.AnyOf {
paramType = append(paramType, anyOfProp.Type...)
}
} else {
paramType = prop.Type
}
}
}
// Parse value with type coercion
toolCall.Function.Arguments.Set(key, parseValue(value, paramType))
}
return toolCall, nil
}

997
model/parsers/glm46_test.go Normal file
View File

@@ -0,0 +1,997 @@
package parsers
import (
"encoding/xml"
"reflect"
"testing"
"github.com/ollama/ollama/api"
)
func TestGLM46ParserStreaming(t *testing.T) {
type step struct {
input string
wantEvents []glm46Event
}
cases := []struct {
desc string
steps []step
only bool
}{
{
desc: "leading whitespace before think tag",
steps: []step{
{
input: " \n\t ",
wantEvents: []glm46Event{},
},
{
input: "<think>thinking</think>",
wantEvents: []glm46Event{glm46EventThinkingContent{content: "thinking"}},
},
},
},
{
desc: "think tag with whitespace inside",
steps: []step{
{
input: "<think> \n thinking content \n </think>regular content",
wantEvents: []glm46Event{
glm46EventThinkingContent{content: "thinking content"},
glm46EventContent{content: "regular content"},
},
},
},
},
{
desc: "tool call with leading whitespace after opening tag",
steps: []step{
{
input: "<think></think><tool_call> \n test \n </tool_call>",
wantEvents: []glm46Event{
glm46EventRawToolCall{raw: "test"},
},
},
},
},
{
desc: "simple thinking then content",
steps: []step{
{
input: "<think>I am thinking</think>Now I respond",
wantEvents: []glm46Event{
glm46EventThinkingContent{content: "I am thinking"},
glm46EventContent{content: "Now I respond"},
},
},
},
},
{
desc: "streamed thinking content",
steps: []step{
{
input: "<think>hello",
wantEvents: []glm46Event{glm46EventThinkingContent{content: "hello"}},
},
{
input: " world",
wantEvents: []glm46Event{glm46EventThinkingContent{content: " world"}},
},
{
input: "</think>content",
wantEvents: []glm46Event{
glm46EventContent{content: "content"},
},
},
},
},
{
desc: "content before tool call",
steps: []step{
{
input: "<think>Let me call a tool</think>here is text<tool_call>",
wantEvents: []glm46Event{
glm46EventThinkingContent{content: "Let me call a tool"},
glm46EventContent{content: "here is text"},
},
},
{
input: "function_name\n<arg_key>param</arg_key>\n<arg_value>value</arg_value>\n</tool_call>",
wantEvents: []glm46Event{
glm46EventRawToolCall{raw: "function_name\n<arg_key>param</arg_key>\n<arg_value>value</arg_value>"},
},
},
},
},
{
desc: "tool call with content after",
steps: []step{
{
input: "<think>thinking</think><tool_call>test</tool_call>after tool",
wantEvents: []glm46Event{
glm46EventThinkingContent{content: "thinking"},
glm46EventRawToolCall{raw: "test"},
glm46EventContent{content: "after tool"},
},
},
},
},
{
desc: "trailing whitespace between content and tool call is trimmed",
steps: []step{
{
input: "<think>thinking</think>content\n \t <tool_call>test</tool_call>",
wantEvents: []glm46Event{
glm46EventThinkingContent{content: "thinking"},
glm46EventContent{content: "content"},
glm46EventRawToolCall{raw: "test"},
},
},
},
},
{
desc: "trailing whitespace between tool call and content is trimmed",
steps: []step{
{
input: "<think>think</think><tool_call>test</tool_call>\n\t after",
wantEvents: []glm46Event{
glm46EventThinkingContent{content: "think"},
glm46EventRawToolCall{raw: "test"},
glm46EventContent{content: "after"},
},
},
},
},
{
desc: "split thinking close tag",
steps: []step{
{
input: "<think>thinking content</th",
wantEvents: []glm46Event{glm46EventThinkingContent{content: "thinking content"}},
},
{
input: "ink>after",
wantEvents: []glm46Event{
glm46EventContent{content: "after"},
},
},
},
},
{
desc: "split thinking open tag",
steps: []step{
{
input: " <thi",
wantEvents: []glm46Event{},
},
{
input: "nk>content</think>",
wantEvents: []glm46Event{glm46EventThinkingContent{content: "content"}},
},
},
},
{
desc: "split tool open tag",
steps: []step{
{
input: "<think>think</think>content<tool",
wantEvents: []glm46Event{glm46EventThinkingContent{content: "think"}, glm46EventContent{content: "content"}},
},
{
input: "_call>inside",
wantEvents: []glm46Event{},
},
{
input: "</tool_call>",
wantEvents: []glm46Event{
glm46EventRawToolCall{raw: "inside"},
},
},
},
},
{
desc: "partial thinking close tag fakeout",
steps: []step{
{
input: "<think>content</th",
wantEvents: []glm46Event{glm46EventThinkingContent{content: "content"}},
},
{
input: "ought more",
wantEvents: []glm46Event{glm46EventThinkingContent{content: "</thought more"}},
},
},
},
{
desc: "partial thinking open tag fakeout",
steps: []step{
{
input: " <thi",
wantEvents: []glm46Event{},
},
{
input: "nking is fun",
wantEvents: []glm46Event{
glm46EventContent{content: " <thinking is fun"},
},
},
},
},
{
desc: "partial tool open tag fakeout",
steps: []step{
{
input: "<think></think>content\n<tool",
wantEvents: []glm46Event{
glm46EventContent{content: "content"},
},
},
{
input: " fakeout",
wantEvents: []glm46Event{
glm46EventContent{content: "\n<tool fakeout"},
},
},
},
},
{
desc: "partial tool close tag fakeout",
steps: []step{
{
input: "<think></think><tool_call>content</tool",
wantEvents: []glm46Event{},
},
{
input: " fakeout",
wantEvents: []glm46Event{},
},
{
input: "</tool_call>",
wantEvents: []glm46Event{
glm46EventRawToolCall{raw: "content</tool fakeout"},
},
},
},
},
{
desc: "empty thinking tag",
steps: []step{
{
input: "<think></think>content here",
wantEvents: []glm46Event{
glm46EventContent{content: "content here"},
},
},
},
},
{
desc: "multiple tool calls in sequence",
steps: []step{
{
input: "<think>think</think><tool_call>first</tool_call>between<tool_call>second</tool_call>end",
wantEvents: []glm46Event{
glm46EventThinkingContent{content: "think"},
glm46EventRawToolCall{raw: "first"},
glm46EventContent{content: "between"},
glm46EventRawToolCall{raw: "second"},
glm46EventContent{content: "end"},
},
},
},
},
{
desc: "no thinking tag - direct to content",
steps: []step{
{
input: "just content here",
wantEvents: []glm46Event{
glm46EventContent{content: "just content here"},
},
},
},
},
{
desc: "no thinking tag - skip to content then tool call",
steps: []step{
{
input: "Here's the answer:<tool_call>test</tool_call>done",
wantEvents: []glm46Event{
glm46EventContent{content: "Here's the answer:"},
glm46EventRawToolCall{raw: "test"},
glm46EventContent{content: "done"},
},
},
},
},
{
desc: "no thinking tag - whitespace preserved when no tags",
steps: []step{
{
input: " \n content with leading whitespace",
wantEvents: []glm46Event{
glm46EventContent{content: " \n content with leading whitespace"},
},
},
},
},
{
desc: "whitespace after think close tag gets eaten",
steps: []step{
{
input: "<think>thinking</think> \n\t content",
wantEvents: []glm46Event{
glm46EventThinkingContent{content: "thinking"},
glm46EventContent{content: "content"},
},
},
},
},
{
desc: "whitespace after tool_call close tag gets eaten",
steps: []step{
{
input: "<think></think><tool_call>test</tool_call> \n\t content",
wantEvents: []glm46Event{
glm46EventRawToolCall{raw: "test"},
glm46EventContent{content: "content"},
},
},
},
},
{
desc: "thinking content withholds trailing whitespace (single chunk)",
steps: []step{
{
input: "<think>thinking content ",
wantEvents: []glm46Event{
glm46EventThinkingContent{content: "thinking content"},
},
},
{
input: "</think>after",
wantEvents: []glm46Event{
glm46EventContent{content: "after"},
},
},
},
},
{
desc: "thinking content withholds trailing whitespace with newlines",
steps: []step{
{
input: "<think>thinking\n\n ",
wantEvents: []glm46Event{
glm46EventThinkingContent{content: "thinking"},
},
},
{
input: "</think>content",
wantEvents: []glm46Event{
glm46EventContent{content: "content"},
},
},
},
},
{
desc: "thinking content trailing whitespace emitted when more content arrives",
steps: []step{
{
input: "<think>thinking ",
wantEvents: []glm46Event{
glm46EventThinkingContent{content: "thinking"},
},
},
{
input: "more thinking",
wantEvents: []glm46Event{
glm46EventThinkingContent{content: " more thinking"},
},
},
{
input: "</think>",
wantEvents: []glm46Event{},
},
},
},
{
desc: "thinking content withholds trailing whitespace before partial close tag",
steps: []step{
{
input: "<think>thinking </th",
wantEvents: []glm46Event{
glm46EventThinkingContent{content: "thinking"},
},
},
{
input: "ink>content",
wantEvents: []glm46Event{
glm46EventContent{content: "content"},
},
},
},
},
}
anyOnlies := false
for _, tc := range cases {
if tc.only {
anyOnlies = true
}
}
for _, tc := range cases {
if anyOnlies && !tc.only {
continue
}
t.Run(tc.desc, func(t *testing.T) {
parser := GLM46Parser{}
for i, step := range tc.steps {
parser.buffer.WriteString(step.input)
gotEvents := parser.parseEvents()
if len(gotEvents) == 0 && len(step.wantEvents) == 0 {
// avoid deep equal on empty vs. nil slices
continue
}
if !reflect.DeepEqual(gotEvents, step.wantEvents) {
t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents)
}
}
})
}
}
// TestGLMToolCallXMLOrderPreservation verifies that xml.Unmarshal preserves
// document order when collecting multiple elements with the same tag name into slices.
// This is a critical assumption for the GLM-4.6 parser's struct-based approach.
func TestGLMToolCallXMLOrderPreservation(t *testing.T) {
testCases := []struct {
name string
xml string
wantKeys []string
wantValues []string
}{
{
name: "alternating keys and values",
xml: `<tool_call>
function_name
<arg_key>first</arg_key>
<arg_value>A</arg_value>
<arg_key>second</arg_key>
<arg_value>B</arg_value>
<arg_key>third</arg_key>
<arg_value>C</arg_value>
</tool_call>`,
wantKeys: []string{"first", "second", "third"},
wantValues: []string{"A", "B", "C"},
},
{
name: "all keys then all values",
xml: `<tool_call>
function_name
<arg_key>key1</arg_key>
<arg_key>key2</arg_key>
<arg_key>key3</arg_key>
<arg_value>val1</arg_value>
<arg_value>val2</arg_value>
<arg_value>val3</arg_value>
</tool_call>`,
wantKeys: []string{"key1", "key2", "key3"},
wantValues: []string{"val1", "val2", "val3"},
},
{
name: "mixed grouping",
xml: `<tool_call>
function_name
<arg_key>a</arg_key>
<arg_value>1</arg_value>
<arg_key>b</arg_key>
<arg_key>c</arg_key>
<arg_value>2</arg_value>
<arg_value>3</arg_value>
</tool_call>`,
wantKeys: []string{"a", "b", "c"},
wantValues: []string{"1", "2", "3"},
},
{
name: "reverse order - all values then all keys",
xml: `<tool_call>
function_name
<arg_value>X</arg_value>
<arg_value>Y</arg_value>
<arg_value>Z</arg_value>
<arg_key>x</arg_key>
<arg_key>y</arg_key>
<arg_key>z</arg_key>
</tool_call>`,
wantKeys: []string{"x", "y", "z"},
wantValues: []string{"X", "Y", "Z"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var parsed GLMToolCallXML
err := xml.Unmarshal([]byte(tc.xml), &parsed)
if err != nil {
t.Fatalf("failed to unmarshal XML: %v", err)
}
if !reflect.DeepEqual(parsed.Keys, tc.wantKeys) {
t.Errorf("Keys order mismatch:\ngot: %v\nwant: %v", parsed.Keys, tc.wantKeys)
}
if !reflect.DeepEqual(parsed.Values, tc.wantValues) {
t.Errorf("Values order mismatch:\ngot: %v\nwant: %v", parsed.Values, tc.wantValues)
}
})
}
}
func TestGLM46ToolCallParsing(t *testing.T) {
type testCase struct {
name string
rawToolCall string
tools []api.Tool
wantToolCall api.ToolCall
}
cases := []testCase{
{
name: "simple tool call",
tools: []api.Tool{},
rawToolCall: `get-current-weather
<arg_key>location</arg_key>
<arg_value>New York, NY</arg_value>
<arg_key>unit</arg_key>
<arg_value>celsius</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "get-current-weather",
Arguments: args(`{"location": "New York, NY", "unit": "celsius"}`),
},
},
},
{
name: "tool call with typed parameters",
tools: []api.Tool{
tool("calculate", map[string]api.ToolProperty{
"x": {Type: api.PropertyType{"number"}},
"y": {Type: api.PropertyType{"integer"}},
"enabled": {Type: api.PropertyType{"boolean"}},
"items": {Type: api.PropertyType{"array"}},
}),
},
rawToolCall: `calculate
<arg_key>x</arg_key>
<arg_value>3.14</arg_value>
<arg_key>y</arg_key>
<arg_value>42</arg_value>
<arg_key>enabled</arg_key>
<arg_value>true</arg_value>
<arg_key>items</arg_key>
<arg_value>["a", "b", "c"]</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "calculate",
Arguments: args(`{"enabled": true, "items": ["a", "b", "c"], "x": 3.14, "y": 42}`),
},
},
},
{
name: "function name with whitespace",
tools: []api.Tool{},
rawToolCall: ` get-weather
<arg_key>city</arg_key>
<arg_value>Paris</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "get-weather",
Arguments: args(`{"city": "Paris"}`),
},
},
},
{
name: "values with special characters",
tools: []api.Tool{},
rawToolCall: `execute-command
<arg_key>command</arg_key>
<arg_value>ls && echo "done"</arg_value>
<arg_key>message</arg_key>
<arg_value>a < b and c > d</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "execute-command",
Arguments: args(`{"command": "ls && echo \"done\"", "message": "a < b and c > d"}`),
},
},
},
{
name: "unicode in function names and values",
tools: []api.Tool{},
rawToolCall: `获取天气
<arg_key>城市</arg_key>
<arg_value>北京</arg_value>
<arg_key>message</arg_key>
<arg_value>Hello! 你好! 🌟</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "获取天气",
Arguments: args(`{"message": "Hello! 你好! 🌟", "城市": "北京"}`),
},
},
},
{
name: "empty value",
tools: []api.Tool{},
rawToolCall: `test-function
<arg_key>param1</arg_key>
<arg_value></arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "test-function",
Arguments: args(`{"param1": ""}`),
},
},
},
{
name: "special chars in arg_key names",
tools: []api.Tool{},
rawToolCall: `test-function
<arg_key>param<1></arg_key>
<arg_value>value1</arg_value>
<arg_key>a&b</arg_key>
<arg_value>value2</arg_value>
<arg_key>x>y</arg_key>
<arg_value>value3</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "test-function",
Arguments: args(`{"a&b": "value2", "param<1>": "value1", "x>y": "value3"}`),
},
},
},
{
name: "multiple consecutive ampersands",
tools: []api.Tool{},
rawToolCall: `test-function
<arg_key>param</arg_key>
<arg_value>test &&&& more</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "test-function",
Arguments: args(`{"param": "test &&&& more"}`),
},
},
},
{
name: "mixed special chars together",
tools: []api.Tool{},
rawToolCall: `test-function
<arg_key>param</arg_key>
<arg_value><>&<>&</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "test-function",
Arguments: args(`{"param": "<>&<>&"}`),
},
},
},
{
name: "newlines and tabs in parameter values",
tools: []api.Tool{},
rawToolCall: `test-function
<arg_key>multiline</arg_key>
<arg_value>line1
indented line2
line3</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "test-function",
Arguments: args(`{"multiline": "line1\n\tindented line2\nline3"}`),
},
},
},
{
name: "single and double quotes in values",
tools: []api.Tool{},
rawToolCall: `test-function
<arg_key>quotes</arg_key>
<arg_value>She said "Hello's there!"</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "test-function",
Arguments: args(`{"quotes": "She said \"Hello's there!\""}`),
},
},
},
{
name: "CDATA-like content that should be treated as text",
tools: []api.Tool{},
rawToolCall: `test-function
<arg_key>cdata</arg_key>
<arg_value><![CDATA[not actual cdata]]></arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "test-function",
Arguments: args(`{"cdata": "<![CDATA[not actual cdata]]>"}`),
},
},
},
{
name: "all special XML entities",
tools: []api.Tool{},
rawToolCall: `test-function
<arg_key>entities</arg_key>
<arg_value>&lt;&gt;&amp;&apos;&quot;</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "test-function",
Arguments: args(`{"entities": "&lt;&gt;&amp;&apos;&quot;"}`),
},
},
},
{
name: "order preservation with multiple parameters",
tools: []api.Tool{},
rawToolCall: `test-function
<arg_key>first</arg_key>
<arg_value>value1</arg_value>
<arg_key>second</arg_key>
<arg_value>value2</arg_value>
<arg_key>third</arg_key>
<arg_value>value3</arg_value>
<arg_key>fourth</arg_key>
<arg_value>value4</arg_value>
<arg_key>fifth</arg_key>
<arg_value>value5</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "test-function",
Arguments: args(`{"fifth": "value5", "first": "value1", "fourth": "value4", "second": "value2", "third": "value3"}`),
},
},
},
{
name: "order preservation with identical key names but different positions",
tools: []api.Tool{},
rawToolCall: `test-function
<arg_key>param</arg_key>
<arg_value>first occurrence</arg_value>
<arg_key>other</arg_key>
<arg_value>middle</arg_value>
<arg_key>param</arg_key>
<arg_value>second occurrence</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "test-function",
// Later occurrence should overwrite earlier one
Arguments: args(`{"other": "middle", "param": "second occurrence"}`),
},
},
},
{
name: "array with mixed types",
tools: []api.Tool{
tool("process", map[string]api.ToolProperty{
"items": {Type: api.PropertyType{"array"}},
}),
},
rawToolCall: `process
<arg_key>items</arg_key>
<arg_value>[1, "hello", true, null]</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "process",
Arguments: args(`{"items": [1, "hello", true, null]}`),
},
},
},
{
name: "empty array",
tools: []api.Tool{
tool("test", map[string]api.ToolProperty{
"tags": {Type: api.PropertyType{"array"}},
}),
},
rawToolCall: `test
<arg_key>tags</arg_key>
<arg_value>[]</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "test",
Arguments: args(`{"tags": []}`),
},
},
},
{
name: "anyOf array or string - with array of objects",
tools: []api.Tool{
tool("TodoWrite", map[string]api.ToolProperty{
"todos": {AnyOf: []api.ToolProperty{{Type: api.PropertyType{"array"}}, {Type: api.PropertyType{"string"}}}},
}),
},
// <tool_call>TodoWrite
// <arg_key>todos</arg_key>
// <arg_value>[{"content": "Set up HTML file and basic structure", "id": "1", "priority": "high", "status": "pending"}, {"content": "Create 3D scene with Three.js", "id": "2", "priority": "high", "status": "pending"}, {"content": "Implement terrain generation with blocks", "id": "3", "priority": "high", "status": "pending"}, {"content": "Add player controls (movement, camera)", "id": "4", "priority": "high", "status": "pending"}, {"content": "Implement block placement/destruction", "id": "5", "priority": "medium", "status": "pending"}, {"content": "Add lighting and textures", "id": "6", "priority": "medium", "status": "pending"}, {"content": "Test and optimize performance", "id": "7", "priority": "low", "status": "pending"}]</arg_value>
// </tool_call>
rawToolCall: `TodoWrite
<arg_key>todos</arg_key>
<arg_value>[{"content": "task 1", "status": "pending", "priority": "high", "id": "1"}, {"content": "task 2", "status": "completed", "priority": "low", "id": "2"}]</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "TodoWrite",
Arguments: args(`{"todos": [{"content": "task 1", "id": "1", "priority": "high", "status": "pending"}, {"content": "task 2", "id": "2", "priority": "low", "status": "completed"}]}`),
},
},
},
{
name: "anyOf array or string - with plain string",
tools: []api.Tool{
tool("TodoWrite", map[string]api.ToolProperty{
"todos": {Type: api.PropertyType{"array", "string"}},
}),
},
rawToolCall: `TodoWrite
<arg_key>todos</arg_key>
<arg_value>Error: could not load todos</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "TodoWrite",
Arguments: args(`{"todos": "Error: could not load todos"}`),
},
},
},
{
name: "unclosed arg_value at end",
tools: []api.Tool{},
rawToolCall: `get-weather
<arg_key>city</arg_key>
<arg_value>Paris`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "get-weather",
Arguments: args(`{"city": "Paris"}`),
},
},
},
{
name: "unclosed arg_value before next arg_key",
tools: []api.Tool{},
rawToolCall: `get-weather
<arg_key>city</arg_key>
<arg_value>Paris<arg_key>unit</arg_key>
<arg_value>celsius</arg_value>`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "get-weather",
Arguments: args(`{"city": "Paris", "unit": "celsius"}`),
},
},
},
{
name: "multiple unclosed arg_values",
tools: []api.Tool{},
rawToolCall: `get-weather
<arg_key>city</arg_key>
<arg_value>Paris<arg_key>unit</arg_key>
<arg_value>celsius`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "get-weather",
Arguments: args(`{"city": "Paris", "unit": "celsius"}`),
},
},
},
{
name: "unopened arg_value after arg_key",
tools: []api.Tool{},
rawToolCall: "get-weather\n<arg_key>city</arg_key>\nNew York</arg_value>\n<arg_key>unit</arg_key>\ncelsius</arg_value>",
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "get-weather",
Arguments: args(`{"city": "New York", "unit": "celsius"}`),
},
},
},
{
name: "mixed unopened and valid arg_values",
tools: []api.Tool{},
rawToolCall: "get-weather\n<arg_key>city</arg_key>\n<arg_value>Paris</arg_value>\n<arg_key>unit</arg_key>\ncelsius</arg_value>",
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "get-weather",
Arguments: args(`{"city": "Paris", "unit": "celsius"}`),
},
},
},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotToolCall, err := parseGLM46ToolCall(glm46EventRawToolCall{raw: tc.rawToolCall}, tc.tools)
if err != nil {
t.Errorf("case %d (%s): %v", i, tc.name, err)
}
if !toolCallEqual(gotToolCall, tc.wantToolCall) {
t.Errorf("case %d (%s): got tool call %#v, want %#v", i, tc.name, gotToolCall, tc.wantToolCall)
}
})
}
}
func TestRepairGLM46XML(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{
name: "already valid",
input: `func<arg_key>k</arg_key><arg_value>v</arg_value>`,
want: `func<arg_key>k</arg_key><arg_value>v</arg_value>`,
},
{
name: "missing </arg_value> at end",
input: `func<arg_key>k</arg_key><arg_value>v`,
want: `func<arg_key>k</arg_key><arg_value>v</arg_value>`,
},
{
name: "missing </arg_value> before next arg_key",
input: `func<arg_key>a</arg_key><arg_value>1<arg_key>b</arg_key><arg_value>2</arg_value>`,
want: `func<arg_key>a</arg_key><arg_value>1</arg_value><arg_key>b</arg_key><arg_value>2</arg_value>`,
},
{
name: "no tags at all",
input: `just plain text`,
want: `just plain text`,
},
{
name: "missing <arg_value> open tag",
input: `func<arg_key>k</arg_key>v</arg_value>`,
want: `func<arg_key>k</arg_key><arg_value>v</arg_value>`,
},
{
name: "missing </arg_key> close tag",
input: `func<arg_key>k<arg_value>v</arg_value>`,
want: `func<arg_key>k</arg_key><arg_value>v</arg_value>`,
},
{
name: "missing <arg_key> open tag",
input: `func k</arg_key><arg_value>v</arg_value>`,
want: `func<arg_key>k</arg_key><arg_value>v</arg_value>`,
},
{
name: "all closing tags missing",
input: `func<arg_key>k<arg_value>v`,
want: `func<arg_key>k</arg_key><arg_value>v</arg_value>`,
},
{
name: "all opening tags missing",
input: "func k</arg_key>v</arg_value>",
want: "func<arg_key>k</arg_key><arg_value>v</arg_value>",
},
{
name: "multiple pairs with mixed missing tags",
input: `func<arg_key>a</arg_key>1</arg_value><arg_key>b<arg_value>2</arg_value>`,
want: `func<arg_key>a</arg_key><arg_value>1</arg_value><arg_key>b</arg_key><arg_value>2</arg_value>`,
},
{
name: "newlines preserved",
input: "func\n<arg_key>city</arg_key>\nNew York</arg_value>",
want: "func\n<arg_key>city</arg_key><arg_value>\nNew York</arg_value>",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := repairGLM46XML(tc.input)
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}

21
model/parsers/glm47.go Normal file
View File

@@ -0,0 +1,21 @@
package parsers
import "github.com/ollama/ollama/api"
// GLM47Parser extends GLM46Parser with thinking-aware initialization.
// GLM-4.7's prompt ends with <think> when thinking is enabled, so the parser
// must start in CollectingThinking state (the model outputs thinking content directly).
type GLM47Parser struct {
GLM46Parser
}
func (p *GLM47Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.tools = tools
p.callIndex = 0
// When thinking is enabled (nil or true), the prompt ends with <think>,
// so model output starts directly with thinking content (no opening tag).
if thinkValue == nil || thinkValue.Bool() {
p.state = glm46ParserState_CollectingThinking
}
return tools
}

187
model/parsers/glm47_test.go Normal file
View File

@@ -0,0 +1,187 @@
package parsers
import (
"reflect"
"testing"
"github.com/ollama/ollama/api"
)
func TestGLM47ParserAdd(t *testing.T) {
parser := GLM47Parser{}
parser.Init([]api.Tool{
tool("calculate", map[string]api.ToolProperty{
"count": {Type: api.PropertyType{"integer"}},
"enabled": {Type: api.PropertyType{"boolean"}},
}),
}, nil, nil)
// When thinking is enabled (thinkValue nil), the prompt ends with <think>,
// so the model output does NOT include the opening <think> tag.
content, thinking, calls, err := parser.Add("plan</think>Answer<tool_call>calculate<arg_key>count</arg_key><arg_value>3</arg_value><arg_key>enabled</arg_key><arg_value>true</arg_value></tool_call>", true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if thinking != "plan" {
t.Fatalf("expected thinking 'plan', got %q", thinking)
}
if content != "Answer" {
t.Fatalf("expected content 'Answer', got %q", content)
}
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
expectedArgs := args(`{"count": 3, "enabled": true}`)
if !toolCallEqual(api.ToolCall{Function: api.ToolCallFunction{Arguments: calls[0].Function.Arguments}}, api.ToolCall{Function: api.ToolCallFunction{Arguments: expectedArgs}}) {
t.Fatalf("expected args %#v, got %#v", expectedArgs.ToMap(), calls[0].Function.Arguments.ToMap())
}
}
func TestGLM47ParserNoThinkingContent(t *testing.T) {
parser := GLM47Parser{}
parser.Init(nil, nil, nil)
// When thinking is enabled but model has no thinking to output,
// it should output </think> immediately followed by content.
content, thinking, calls, err := parser.Add("</think>Plain answer", true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if thinking != "" {
t.Fatalf("expected empty thinking, got %q", thinking)
}
if content != "Plain answer" {
t.Fatalf("expected content 'Plain answer', got %q", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}
func TestGLM47ParserThinkingDisabled(t *testing.T) {
parser := GLM47Parser{}
// When thinking is disabled, parser stays in LookingForThinkingOpen state
parser.Init(nil, nil, &api.ThinkValue{Value: false})
// Model outputs plain content (prompt ended with </think>)
content, thinking, calls, err := parser.Add("Plain answer", true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if thinking != "" {
t.Fatalf("expected empty thinking, got %q", thinking)
}
if content != "Plain answer" {
t.Fatalf("expected content 'Plain answer', got %q", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}
func TestGLM47ParserToolCallEscaping(t *testing.T) {
toolCall, err := parseGLM46ToolCall(glm46EventRawToolCall{raw: `exec
<arg_key>expr</arg_key>
<arg_value>a < b && c > d</arg_value>`}, nil)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
expected := api.ToolCall{
Function: api.ToolCallFunction{
Name: "exec",
Arguments: args(`{"expr": "a < b && c > d"}`),
},
}
if !reflect.DeepEqual(toolCall, expected) {
t.Fatalf("expected %#v, got %#v", expected, toolCall)
}
}
func TestGLM47ParserToolCallIndexing(t *testing.T) {
parser := GLM47Parser{}
parser.Init(nil, nil, nil)
input := `plan</think>
<tool_call>first<arg_key>a</arg_key><arg_value>1</arg_value></tool_call>
<tool_call>second<arg_key>b</arg_key><arg_value>2</arg_value></tool_call>
<tool_call>third<arg_key>c</arg_key><arg_value>3</arg_value></tool_call>`
_, _, calls, err := parser.Add(input, true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
want := []api.ToolCall{
{Function: api.ToolCallFunction{Name: "first", Arguments: args(`{"a":"1"}`), Index: 0}},
{Function: api.ToolCallFunction{Name: "second", Arguments: args(`{"b":"2"}`), Index: 1}},
{Function: api.ToolCallFunction{Name: "third", Arguments: args(`{"c":"3"}`), Index: 2}},
}
if len(calls) != len(want) {
t.Fatalf("expected %d calls, got %d", len(want), len(calls))
}
for i := range want {
if !toolCallEqual(calls[i], want[i]) {
t.Fatalf("call %d mismatch: got %#v, want %#v", i, calls[i], want[i])
}
}
}
func TestGLM47ParserToolCallIndexingStreaming(t *testing.T) {
parser := GLM47Parser{}
parser.Init(nil, nil, nil)
var all []api.ToolCall
_, _, calls, err := parser.Add("plan</think><tool_call>first<arg_key>a</arg_key><arg_value>1</arg_value></tool_call><tool_call>second<arg_key>b</arg_key>", false)
if err != nil {
t.Fatalf("step 1 parse failed: %v", err)
}
all = append(all, calls...)
_, _, calls, err = parser.Add("<arg_value>2</arg_value></tool_call><tool_call>third<arg_key>c</arg_key><arg_value>3</arg_value></tool_call>", true)
if err != nil {
t.Fatalf("step 2 parse failed: %v", err)
}
all = append(all, calls...)
want := []api.ToolCall{
{Function: api.ToolCallFunction{Name: "first", Arguments: args(`{"a":"1"}`), Index: 0}},
{Function: api.ToolCallFunction{Name: "second", Arguments: args(`{"b":"2"}`), Index: 1}},
{Function: api.ToolCallFunction{Name: "third", Arguments: args(`{"c":"3"}`), Index: 2}},
}
if len(all) != len(want) {
t.Fatalf("expected %d calls, got %d", len(want), len(all))
}
for i := range want {
if !toolCallEqual(all[i], want[i]) {
t.Fatalf("call %d mismatch: got %#v, want %#v", i, all[i], want[i])
}
}
}
func TestGLM47ParserToolCallIndexResetOnInit(t *testing.T) {
parser := GLM47Parser{}
parser.Init(nil, nil, nil)
_, _, _, err := parser.Add("plan</think><tool_call>first<arg_key>a</arg_key><arg_value>1</arg_value></tool_call>", true)
if err != nil {
t.Fatalf("first parse failed: %v", err)
}
parser.Init(nil, nil, nil)
_, _, calls, err := parser.Add("plan</think><tool_call>second<arg_key>b</arg_key><arg_value>2</arg_value></tool_call>", true)
if err != nil {
t.Fatalf("second parse failed: %v", err)
}
want := api.ToolCall{
Function: api.ToolCallFunction{Name: "second", Arguments: args(`{"b":"2"}`), Index: 0},
}
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if !toolCallEqual(calls[0], want) {
t.Fatalf("got %#v, want %#v", calls[0], want)
}
}

17
model/parsers/glmocr.go Normal file
View File

@@ -0,0 +1,17 @@
package parsers
import "github.com/ollama/ollama/api"
// GlmOcrParser is the GLM46 parser with thinking disabled.
type GlmOcrParser struct {
GLM46Parser
}
func (p *GlmOcrParser) HasThinkingSupport() bool {
return false
}
func (p *GlmOcrParser) Init(tools []api.Tool, _ *api.Message, _ *api.ThinkValue) []api.Tool {
p.tools = tools
return tools
}

498
model/parsers/laguna.go Normal file
View File

@@ -0,0 +1,498 @@
package parsers
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"unicode"
"github.com/ollama/ollama/api"
)
const (
lagunaThinkingOpenTag = "<think>"
lagunaThinkingCloseTag = "</think>"
lagunaToolCallOpenTag = "<tool_call>"
lagunaToolCallCloseTag = "</tool_call>"
lagunaUserOpenTag = "<user>"
lagunaUserCloseTag = "</user>"
)
type lagunaParserState int
const (
lagunaParserStateThinking lagunaParserState = iota
lagunaParserStateContent
lagunaParserStateTool
)
type LagunaParser struct {
state lagunaParserState
buffer strings.Builder
tools []api.Tool
callIndex int
thinkingEnabled bool
thinkingSuppressed bool
allowLeadingThinkOpen bool
}
func (p *LagunaParser) HasToolSupport() bool {
return true
}
func (p *LagunaParser) HasThinkingSupport() bool {
return true
}
func (p *LagunaParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.tools = tools
p.callIndex = 0
p.buffer.Reset()
p.thinkingEnabled = thinkValue == nil || thinkValue.Bool()
p.thinkingSuppressed = thinkValue != nil && !thinkValue.Bool()
p.state = lagunaParserStateContent
p.allowLeadingThinkOpen = false
return tools
}
func (p *LagunaParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
var contentSB, thinkingSB strings.Builder
for {
progress := false
switch p.state {
case lagunaParserStateThinking:
progress, thinking = p.consumeThinking(done)
if p.thinkingEnabled {
thinkingSB.WriteString(thinking)
}
case lagunaParserStateContent:
var parsedCalls []api.ToolCall
progress, content, parsedCalls, err = p.consumeContent(done)
if err != nil {
return "", "", nil, err
}
contentSB.WriteString(content)
calls = append(calls, parsedCalls...)
case lagunaParserStateTool:
var call api.ToolCall
progress, call, err = p.consumeTool(done)
if err != nil {
return "", "", nil, err
}
if progress {
calls = append(calls, call)
}
}
if !progress {
break
}
}
return contentSB.String(), thinkingSB.String(), calls, nil
}
func (p *LagunaParser) consumeThinking(done bool) (bool, string) {
acc := p.buffer.String()
if p.allowLeadingThinkOpen {
trimmed := strings.TrimLeftFunc(acc, unicode.IsSpace)
if strings.HasPrefix(trimmed, lagunaThinkingOpenTag) {
p.buffer.Reset()
p.buffer.WriteString(strings.TrimLeftFunc(strings.TrimPrefix(trimmed, lagunaThinkingOpenTag), unicode.IsSpace))
p.allowLeadingThinkOpen = false
return true, ""
}
if strings.HasPrefix(lagunaThinkingOpenTag, trimmed) && !done {
return false, ""
}
p.allowLeadingThinkOpen = false
}
if idx := strings.Index(acc, lagunaThinkingCloseTag); idx != -1 {
thinking := acc[:idx]
after := strings.TrimLeftFunc(acc[idx+len(lagunaThinkingCloseTag):], unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(after)
p.state = lagunaParserStateContent
return true, thinking
}
if idx := strings.Index(acc, lagunaToolCallOpenTag); idx != -1 {
thinking := strings.TrimRightFunc(acc[:idx], unicode.IsSpace)
after := acc[idx+len(lagunaToolCallOpenTag):]
p.buffer.Reset()
p.buffer.WriteString(after)
p.state = lagunaParserStateTool
return true, thinking
}
if done {
p.buffer.Reset()
p.state = lagunaParserStateContent
return acc != "", acc
}
overlapLen := max(overlap(acc, lagunaThinkingCloseTag), overlap(acc, lagunaToolCallOpenTag))
trailingLen := trailingWhitespaceLen(acc)
keep := max(overlapLen, trailingLen)
if keep > 0 && keep < len(acc) {
emit := acc[:len(acc)-keep]
p.buffer.Reset()
p.buffer.WriteString(acc[len(acc)-keep:])
return emit != "", emit
}
return false, ""
}
func (p *LagunaParser) consumeContent(done bool) (bool, string, []api.ToolCall, error) {
acc := p.buffer.String()
if p.thinkingEnabled || p.thinkingSuppressed {
if idx := strings.Index(acc, lagunaThinkingOpenTag); idx != -1 {
content := acc[:idx]
after := strings.TrimLeftFunc(acc[idx+len(lagunaThinkingOpenTag):], unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(after)
p.state = lagunaParserStateThinking
p.allowLeadingThinkOpen = false
return true, content, nil, nil
}
if !done {
overlapLen := overlap(acc, lagunaThinkingOpenTag)
if overlapLen > 0 && overlapLen < len(acc) {
content := acc[:len(acc)-overlapLen]
p.buffer.Reset()
p.buffer.WriteString(acc[len(acc)-overlapLen:])
return content != "", content, nil, nil
}
}
}
if p.thinkingEnabled {
trimmed := strings.TrimLeftFunc(acc, unicode.IsSpace)
if strings.HasPrefix(trimmed, lagunaThinkingCloseTag) {
p.buffer.Reset()
p.buffer.WriteString(strings.TrimLeftFunc(strings.TrimPrefix(trimmed, lagunaThinkingCloseTag), unicode.IsSpace))
return true, "", nil, nil
}
if strings.HasPrefix(lagunaThinkingCloseTag, trimmed) && !done {
return false, "", nil, nil
}
}
if p.thinkingSuppressed {
trimmed := strings.TrimLeftFunc(acc, unicode.IsSpace)
if strings.HasPrefix(trimmed, lagunaThinkingCloseTag) {
p.buffer.Reset()
p.buffer.WriteString(strings.TrimLeftFunc(strings.TrimPrefix(trimmed, lagunaThinkingCloseTag), unicode.IsSpace))
return true, "", nil, nil
}
if strings.HasPrefix(lagunaThinkingCloseTag, trimmed) && !done {
return false, "", nil, nil
}
}
if idx := strings.Index(acc, lagunaToolCallOpenTag); idx != -1 {
content := strings.TrimRightFunc(acc[:idx], unicode.IsSpace)
after := acc[idx+len(lagunaToolCallOpenTag):]
p.buffer.Reset()
p.buffer.WriteString(after)
p.state = lagunaParserStateTool
return true, content, nil, nil
}
if idx := strings.Index(acc, lagunaUserOpenTag); idx != -1 && len(p.tools) > 0 {
before := strings.TrimRightFunc(acc[:idx], unicode.IsSpace)
afterOpen := acc[idx+len(lagunaUserOpenTag):]
if closeIdx := strings.Index(afterOpen, lagunaUserCloseTag); closeIdx != -1 {
raw := afterOpen[:closeIdx]
if call, ok := p.parseToolAlias(raw); ok {
after := strings.TrimLeftFunc(afterOpen[closeIdx+len(lagunaUserCloseTag):], unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(after)
return true, before, []api.ToolCall{call}, nil
}
} else if !done {
if idx > 0 {
p.buffer.Reset()
p.buffer.WriteString(acc[idx:])
return true, before, nil, nil
}
return false, "", nil, nil
}
}
if len(p.tools) > 0 {
if progress, content, call, ok, err := p.consumeStandaloneJSONTool(done); ok || err != nil {
if err != nil {
return false, "", nil, err
}
if progress {
return true, content, []api.ToolCall{call}, nil
}
return false, "", nil, nil
}
}
if done {
p.buffer.Reset()
return acc != "", acc, nil, nil
}
overlapLen := max(overlap(acc, lagunaToolCallOpenTag), overlap(acc, lagunaUserOpenTag))
if p.thinkingEnabled || p.thinkingSuppressed {
overlapLen = max(overlapLen, overlap(acc, lagunaThinkingOpenTag))
}
if p.thinkingSuppressed {
overlapLen = max(overlapLen, overlap(acc, lagunaThinkingCloseTag))
}
trailingLen := trailingWhitespaceLen(acc)
keep := max(overlapLen, trailingLen)
if keep > 0 && keep < len(acc) {
emit := acc[:len(acc)-keep]
p.buffer.Reset()
p.buffer.WriteString(acc[len(acc)-keep:])
return emit != "", emit, nil, nil
}
if keep == 0 && acc != "" {
p.buffer.Reset()
return true, acc, nil, nil
}
return false, "", nil, nil
}
func (p *LagunaParser) consumeStandaloneJSONTool(done bool) (progress bool, content string, call api.ToolCall, ok bool, err error) {
acc := p.buffer.String()
jsonIdx := strings.Index(acc, "{")
if jsonIdx == -1 {
return false, "", api.ToolCall{}, false, nil
}
before := strings.TrimRightFunc(acc[:jsonIdx], unicode.IsSpace)
raw := strings.TrimLeftFunc(acc[jsonIdx:], unicode.IsSpace)
if !lagunaLooksLikeJSONToolCall(raw, done) {
return false, "", api.ToolCall{}, false, nil
}
if !done && !json.Valid([]byte(strings.TrimSpace(raw))) {
if before != "" {
p.buffer.Reset()
p.buffer.WriteString(acc[jsonIdx:])
return true, before, api.ToolCall{}, true, nil
}
return false, "", api.ToolCall{}, true, nil
}
call, err = parseLagunaToolCall(raw, p.tools)
if err != nil {
return false, "", api.ToolCall{}, true, err
}
call.Function.Index = p.callIndex
p.callIndex++
p.buffer.Reset()
p.state = lagunaParserStateContent
return true, before, call, true, nil
}
func lagunaLooksLikeJSONToolCall(raw string, done bool) bool {
trimmed := strings.TrimLeftFunc(raw, unicode.IsSpace)
if !strings.HasPrefix(trimmed, "{") {
return false
}
if strings.Contains(trimmed, `"name"`) || strings.Contains(trimmed, `"arguments"`) {
return true
}
if done {
return false
}
return strings.HasPrefix(trimmed, `{"`) || strings.HasPrefix(trimmed, "{\n") || strings.HasPrefix(trimmed, "{\r\n")
}
func (p *LagunaParser) parseToolAlias(raw string) (api.ToolCall, bool) {
raw = cleanLagunaToolCallRaw(raw)
name, ok := lagunaToolCallName(raw)
if !ok {
return api.ToolCall{}, false
}
if _, ok := lagunaResolveToolName(name, p.tools); !ok {
return api.ToolCall{}, false
}
call, err := parseLagunaToolCall(raw, p.tools)
if err != nil {
return api.ToolCall{}, false
}
call.Function.Index = p.callIndex
p.callIndex++
return call, true
}
func lagunaResolveToolName(name string, tools []api.Tool) (string, bool) {
for i := range tools {
if tools[i].Function.Name == name {
return name, true
}
}
aliases := map[string]string{
"read_file": "read",
"write_file": "write",
"edit_file": "edit",
"web_fetch": "webfetch",
}
if alias, ok := aliases[name]; ok {
for i := range tools {
if tools[i].Function.Name == alias {
return alias, true
}
}
}
return name, false
}
func cleanLagunaToolCallRaw(raw string) string {
raw = strings.TrimSpace(raw)
for strings.HasPrefix(raw, lagunaToolCallOpenTag) {
raw = strings.TrimSpace(strings.TrimPrefix(raw, lagunaToolCallOpenTag))
}
if idx := strings.Index(raw, lagunaToolCallCloseTag); idx != -1 {
raw = strings.TrimSpace(raw[:idx])
}
if idx := strings.Index(raw, lagunaToolCallOpenTag); idx != -1 {
before := strings.TrimSpace(raw[:idx])
if before != "" {
return before
}
raw = strings.TrimSpace(raw[idx+len(lagunaToolCallOpenTag):])
}
return raw
}
func lagunaToolCallName(raw string) (string, bool) {
raw = cleanLagunaToolCallRaw(raw)
if strings.HasPrefix(raw, "{") {
var parsed struct {
Name string `json:"name"`
}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return "", false
}
name := strings.TrimSpace(parsed.Name)
return name, name != ""
}
nameEnd := strings.Index(raw, "<arg_key>")
if nameEnd < 0 {
nameEnd = strings.Index(raw, "{")
}
if nameEnd < 0 {
nameEnd = strings.IndexAny(raw, "\r\n")
}
if nameEnd < 0 {
nameEnd = len(raw)
}
name := strings.TrimSpace(raw[:nameEnd])
return name, name != ""
}
func (p *LagunaParser) consumeTool(done bool) (bool, api.ToolCall, error) {
acc := p.buffer.String()
if idx := strings.Index(acc, lagunaToolCallCloseTag); idx != -1 {
raw := acc[:idx]
after := strings.TrimLeftFunc(acc[idx+len(lagunaToolCallCloseTag):], unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(after)
p.state = lagunaParserStateContent
call, err := parseLagunaToolCall(raw, p.tools)
if err != nil {
return false, api.ToolCall{}, err
}
call.Function.Index = p.callIndex
p.callIndex++
return true, call, nil
}
if done && strings.TrimSpace(acc) != "" {
p.buffer.Reset()
p.state = lagunaParserStateContent
call, err := parseLagunaToolCall(acc, p.tools)
if err != nil {
return false, api.ToolCall{}, err
}
call.Function.Index = p.callIndex
p.callIndex++
return true, call, nil
}
return false, api.ToolCall{}, nil
}
var lagunaArgRE = regexp.MustCompile(`(?s)<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>`)
func parseLagunaToolCall(raw string, tools []api.Tool) (api.ToolCall, error) {
raw = cleanLagunaToolCallRaw(raw)
if strings.HasPrefix(raw, "{") {
var parsed struct {
Name string `json:"name"`
Arguments api.ToolCallFunctionArguments `json:"arguments"`
}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return api.ToolCall{}, fmt.Errorf("failed to parse Laguna JSON tool call: %w", err)
}
if parsed.Name == "" {
return api.ToolCall{}, fmt.Errorf("empty Laguna tool call name")
}
if name, ok := lagunaResolveToolName(parsed.Name, tools); ok {
parsed.Name = name
}
return api.ToolCall{
Function: api.ToolCallFunction{
Name: parsed.Name,
Arguments: parsed.Arguments,
},
}, nil
}
nameEnd := strings.Index(raw, "<arg_key>")
name := raw
argsText := ""
if nameEnd >= 0 {
name = raw[:nameEnd]
argsText = raw[nameEnd:]
} else if jsonStart := strings.Index(raw, "{"); jsonStart >= 0 {
name = raw[:jsonStart]
argsText = raw[jsonStart:]
}
name = strings.TrimSpace(name)
if resolved, ok := lagunaResolveToolName(name, tools); ok {
name = resolved
}
var matchedTool *api.Tool
for i := range tools {
if tools[i].Function.Name == name {
matchedTool = &tools[i]
break
}
}
call := api.ToolCall{
Function: api.ToolCallFunction{
Name: name,
Arguments: api.NewToolCallFunctionArguments(),
},
}
if strings.HasPrefix(strings.TrimSpace(argsText), "{") {
if err := json.Unmarshal([]byte(strings.TrimSpace(argsText)), &call.Function.Arguments); err != nil {
return api.ToolCall{}, fmt.Errorf("failed to parse Laguna JSON tool call arguments: %w", err)
}
return call, nil
}
for _, match := range lagunaArgRE.FindAllStringSubmatch(argsText, -1) {
key := strings.TrimSpace(match[1])
value := match[2]
var paramType api.PropertyType
if matchedTool != nil && matchedTool.Function.Parameters.Properties != nil {
if prop, ok := matchedTool.Function.Parameters.Properties.Get(key); ok {
if len(prop.AnyOf) > 0 {
for _, anyOfProp := range prop.AnyOf {
paramType = append(paramType, anyOfProp.Type...)
}
} else {
paramType = prop.Type
}
}
}
call.Function.Arguments.Set(key, parseValue(value, paramType))
}
return call, nil
}

View File

@@ -0,0 +1,484 @@
package parsers
import (
"testing"
"github.com/ollama/ollama/api"
)
func lagunaTestTools() []api.Tool {
props := api.NewToolPropertiesMap()
props.Set("location", api.ToolProperty{Type: api.PropertyType{"string"}})
props.Set("days", api.ToolProperty{Type: api.PropertyType{"integer"}})
return []api.Tool{{
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Properties: props,
},
},
}}
}
func TestLagunaParserToolCall(t *testing.T) {
parser := ParserForName("laguna")
if parser == nil {
t.Fatal("expected laguna parser")
}
if !parser.HasToolSupport() || !parser.HasThinkingSupport() {
t.Fatal("laguna parser should advertise tools and thinking")
}
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls, err := parser.Add("<tool_call>get_weather\n<arg_key>location</arg_key>\n<arg_value>Paris</arg_value>\n<arg_key>days</arg_key>\n<arg_value>3</arg_value>\n</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "" {
t.Fatalf("content=%q thinking=%q, want empty", content, thinking)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("name=%q, want get_weather", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("location"); got != "Paris" {
t.Fatalf("location=%v, want Paris", got)
}
if got, _ := calls[0].Function.Arguments.Get("days"); got != 3 {
t.Fatalf("days=%v, want 3", got)
}
}
func TestLagunaParserJSONToolCall(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
_, _, calls, err := parser.Add("<tool_call>\n{\"name\":\"get_weather\",\"arguments\":{\"location\":\"Paris\",\"days\":3}}\n</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("name=%q, want get_weather", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("location"); got != "Paris" {
t.Fatalf("location=%v, want Paris", got)
}
if got, _ := calls[0].Function.Arguments.Get("days"); got != float64(3) {
t.Fatalf("days=%v, want 3", got)
}
}
func TestLagunaParserStandaloneJSONToolCall(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls, err := parser.Add("{\"name\":\"get_weather\",\"arguments\":{\"location\":\"Paris\",\"days\":3}}", true)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "" {
t.Fatalf("content=%q thinking=%q", content, thinking)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("name=%q, want get_weather", calls[0].Function.Name)
}
}
func TestLagunaParserStandaloneJSONToolCallAfterLeadingContent(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls, err := parser.Add("Let me call the weather tool.\n{\"name\":\"get_weather\",\"arguments\":{\"location\":\"Paris\"}}", true)
if err != nil {
t.Fatal(err)
}
if content != "Let me call the weather tool." || thinking != "" {
t.Fatalf("content=%q thinking=%q", content, thinking)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("name=%q, want get_weather", calls[0].Function.Name)
}
}
func TestLagunaParserStreamingStandaloneJSONToolCall(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls, err := parser.Add("{\"name\":\"get_weather\",\"arguments\":{\"location\":\"San Francisco,", false)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "" || len(calls) != 0 {
t.Fatalf("first chunk content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
content, thinking, calls, err = parser.Add(" CA\"}}", true)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "" || len(calls) != 1 {
t.Fatalf("second chunk content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("name=%q, want get_weather", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("location"); got != "San Francisco, CA" {
t.Fatalf("location=%v, want San Francisco, CA", got)
}
}
func TestLagunaParserNameLineJSONToolCall(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
_, _, calls, err := parser.Add("<tool_call>get_weather\n{\"location\":\"San Francisco\"}</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("name=%q, want get_weather", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("location"); got != "San Francisco" {
t.Fatalf("location=%v, want San Francisco", got)
}
}
func TestLagunaParserNormalizesCommonToolAliases(t *testing.T) {
props := api.NewToolPropertiesMap()
props.Set("path", api.ToolProperty{Type: api.PropertyType{"string"}})
tools := []api.Tool{{
Function: api.ToolFunction{
Name: "read",
Parameters: api.ToolFunctionParameters{
Properties: props,
},
},
}}
parser := ParserForName("laguna")
parser.Init(tools, nil, nil)
_, _, calls, err := parser.Add("<tool_call>\n{\"name\":\"read_file\",\"arguments\":{\"path\":\"./go.mod\"}}\n</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "read" {
t.Fatalf("name=%q, want read", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("path"); got != "./go.mod" {
t.Fatalf("path=%v, want ./go.mod", got)
}
}
func TestLagunaParserIgnoresDuplicatedNestedToolCall(t *testing.T) {
props := api.NewToolPropertiesMap()
props.Set("name", api.ToolProperty{Type: api.PropertyType{"string"}})
tools := []api.Tool{{
Function: api.ToolFunction{
Name: "skill",
Parameters: api.ToolFunctionParameters{
Properties: props,
},
},
}}
parser := ParserForName("laguna")
parser.Init(tools, nil, nil)
_, _, calls, err := parser.Add("<tool_call>skill\n{\"name\":\"git-diff-review\"}\n<tool_call>skill\n{\"name\":\"git-diff-review\"}</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "skill" {
t.Fatalf("name=%q, want skill", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("name"); got != "git-diff-review" {
t.Fatalf("name arg=%v, want git-diff-review", got)
}
}
func TestLagunaParserThinkingThenTool(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("<think>Need current weather.</think>\n<tool_call>get_weather\n<arg_key>location</arg_key>\n<arg_value>SF</arg_value>\n</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if content != "" {
t.Fatalf("content=%q, want empty", content)
}
if thinking != "Need current weather." {
t.Fatalf("thinking=%q, want reasoning", thinking)
}
if len(calls) != 1 || calls[0].Function.Name != "get_weather" {
t.Fatalf("unexpected calls: %#v", calls)
}
}
func TestLagunaParserUserTaggedToolAlias(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls, err := parser.Add("<user>get_weather\n<arg_key>location</arg_key>\n<arg_value>San Francisco, CA</arg_value>\n</user>", true)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "" {
t.Fatalf("content=%q thinking=%q, want empty", content, thinking)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("name=%q, want get_weather", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("location"); got != "San Francisco, CA" {
t.Fatalf("location=%v, want San Francisco, CA", got)
}
}
func TestLagunaParserUserTaggedToolAliasWithLeadingContent(t *testing.T) {
parser := ParserForName("laguna")
props := api.NewToolPropertiesMap()
props.Set("path", api.ToolProperty{Type: api.PropertyType{"string"}})
tools := []api.Tool{{
Function: api.ToolFunction{
Name: "read",
Parameters: api.ToolFunctionParameters{
Properties: props,
},
},
}}
parser.Init(tools, nil, nil)
content, thinking, calls, err := parser.Add("I'll read the file for you.\n<user>read\n<arg_key>path</arg_key>\n<arg_value>/Users/test/code/myproject/go.mod</arg_value>\n</user>", true)
if err != nil {
t.Fatal(err)
}
if content != "I'll read the file for you." || thinking != "" {
t.Fatalf("content=%q thinking=%q", content, thinking)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "read" {
t.Fatalf("name=%q, want read", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("path"); got != "/Users/test/code/myproject/go.mod" {
t.Fatalf("path=%v, want /Users/test/code/myproject/go.mod", got)
}
}
func TestLagunaParserUserTaggedJSONToolCallWithLeadingContent(t *testing.T) {
parser := ParserForName("laguna")
props := api.NewToolPropertiesMap()
props.Set("command", api.ToolProperty{Type: api.PropertyType{"string"}})
tools := []api.Tool{{
Function: api.ToolFunction{
Name: "bash",
Parameters: api.ToolFunctionParameters{
Properties: props,
},
},
}}
parser.Init(tools, nil, nil)
content, thinking, calls, err := parser.Add("I'll run git diff for you.<user>\n{\"name\":\"bash\",\"arguments\":{\"command\":\"git diff main\"}}\n</user>", true)
if err != nil {
t.Fatal(err)
}
if content != "I'll run git diff for you." || thinking != "" {
t.Fatalf("content=%q thinking=%q", content, thinking)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "bash" {
t.Fatalf("name=%q, want bash", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("command"); got != "git diff main" {
t.Fatalf("command=%v, want git diff main", got)
}
}
func TestLagunaParserStreamingUserTaggedToolAliasAfterContent(t *testing.T) {
parser := ParserForName("laguna")
props := api.NewToolPropertiesMap()
props.Set("path", api.ToolProperty{Type: api.PropertyType{"string"}})
tools := []api.Tool{{
Function: api.ToolFunction{
Name: "read",
Parameters: api.ToolFunctionParameters{
Properties: props,
},
},
}}
parser.Init(tools, nil, nil)
content, thinking, calls, err := parser.Add("I'll read the file for you.<us", false)
if err != nil {
t.Fatal(err)
}
if content != "I'll read the file for you." || thinking != "" || len(calls) != 0 {
t.Fatalf("first chunk content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
content, thinking, calls, err = parser.Add("er>read\n<arg_key>path</arg_key>\n<arg_value>/Users/test/code/myproject/go.mod</arg_value>\n</user>", true)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "" {
t.Fatalf("second chunk content=%q thinking=%q", content, thinking)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "read" {
t.Fatalf("name=%q, want read", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("path"); got != "/Users/test/code/myproject/go.mod" {
t.Fatalf("path=%v, want /Users/test/code/myproject/go.mod", got)
}
}
func TestLagunaParserUserTaggedNonToolContent(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls, err := parser.Add("<user>hello</user>", true)
if err != nil {
t.Fatal(err)
}
if content != "<user>hello</user>" || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserThinkingDefaultsOn(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, nil, nil)
content, thinking, calls, err := parser.Add("<think>Need to reason.</think>\nDirect answer.", true)
if err != nil {
t.Fatal(err)
}
if content != "Direct answer." || thinking != "Need to reason." || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserThinkingDefaultsOnWhenToolsPresent(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls, err := parser.Add("<think>Need to reason.</think>\n<tool_call>get_weather\n<arg_key>location</arg_key>\n<arg_value>Paris</arg_value>\n</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if thinking != "Need to reason." || len(calls) != 1 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
if content != "" {
t.Fatalf("content=%q, want thinking block suppressed from content when default thinking is enabled", content)
}
}
func TestLagunaParserThinkingExplicitlyDisabled(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, nil, &api.ThinkValue{Value: false})
content, thinking, calls, err := parser.Add("<think>Hidden?</think>\nDirect answer.", true)
if err != nil {
t.Fatal(err)
}
if content != "Direct answer." || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserThinkingExplicitlyDisabledDropsLeadingCloseTag(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, nil, &api.ThinkValue{Value: false})
content, thinking, calls, err := parser.Add("</think>\nTokyo\n", true)
if err != nil {
t.Fatal(err)
}
if content != "Tokyo\n" || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserThinkingEnabledDropsLeadingCloseTag(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("</think>\nTokyo\n", true)
if err != nil {
t.Fatal(err)
}
if content != "Tokyo\n" || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserThinkingDefaultOnDropsLeadingCloseTag(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, nil, nil)
content, thinking, calls, err := parser.Add("</think>\nTokyo\n", true)
if err != nil {
t.Fatal(err)
}
if content != "Tokyo\n" || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserThinkingEnabledUntaggedAnswerIsContent(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("Direct answer.", true)
if err != nil {
t.Fatal(err)
}
if content != "Direct answer." || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserSplitToolTag(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("<think>Need lookup<tool_c", false)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "Need lookup" || len(calls) != 0 {
t.Fatalf("first chunk content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
content, thinking, calls, err = parser.Add("all>get_weather\n<arg_key>location</arg_key>\n<arg_value>SF</arg_value>\n</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "" || len(calls) != 1 {
t.Fatalf("second chunk content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}

691
model/parsers/lfm2.go Normal file
View File

@@ -0,0 +1,691 @@
package parsers
import (
"encoding/json"
"errors"
"log/slog"
"strconv"
"strings"
"unicode"
"github.com/ollama/ollama/api"
)
type LFM2ParserState int
const (
LFM2CollectingThinking LFM2ParserState = iota
LFM2CollectingContent
LFM2CollectingToolCalls
)
const (
lfm2ThinkingOpenTag = "<think>"
lfm2ThinkingCloseTag = "</think>"
lfm2ToolCallStartTag = "<|tool_call_start|>"
lfm2ToolCallEndTag = "<|tool_call_end|>"
)
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
toolNames map[string]struct{}
hasTools bool
}
func (p *LFM2Parser) HasToolSupport() bool {
return true
}
func (p *LFM2Parser) HasThinkingSupport() bool {
return p.hasThinkingSupport
}
func (p *LFM2Parser) setInitialState(lastMessage *api.Message, thinkValue *api.ThinkValue) {
prefill := lastMessage != nil && lastMessage.Role == "assistant"
// Check both model capability AND request preference
thinkingEnabled := p.HasThinkingSupport() && (thinkValue != nil && thinkValue.Bool())
if !thinkingEnabled {
p.state = LFM2CollectingContent
return
}
if prefill && lastMessage.Content != "" {
p.state = LFM2CollectingContent
return
}
p.state = LFM2CollectingThinking
p.needsThinkingLeadingTrim = true
}
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 != "" {
p.toolNames[tool.Function.Name] = struct{}{}
}
}
p.setInitialState(lastMessage, thinkValue)
return tools
}
type lfm2Event interface {
isLFM2Event()
}
type lfm2EventThinkingContent struct {
content string
}
type lfm2EventContent struct {
content string
}
type lfm2EventToolCall struct {
toolCall api.ToolCall
}
func (lfm2EventThinkingContent) isLFM2Event() {}
func (lfm2EventContent) isLFM2Event() {}
func (lfm2EventToolCall) isLFM2Event() {}
func (p *LFM2Parser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
events := p.parseEvents()
var toolCalls []api.ToolCall
var contentSb strings.Builder
var thinkingSb strings.Builder
for _, event := range events {
switch event := event.(type) {
case lfm2EventToolCall:
toolCalls = append(toolCalls, event.toolCall)
case lfm2EventThinkingContent:
thinkingSb.WriteString(event.content)
case lfm2EventContent:
contentSb.WriteString(event.content)
}
}
// Fallback for models that emit bare tool calls without <|tool_call_*|> wrappers.
if done && len(toolCalls) == 0 && p.hasTools {
candidate := strings.TrimSpace(contentSb.String())
if fallbackCalls, parseErr := p.parseToolCallsContent(candidate); parseErr == nil && p.toolCallsAllowed(fallbackCalls) {
contentSb.Reset()
toolCalls = append(toolCalls, fallbackCalls...)
}
}
for i := range toolCalls {
toolCalls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentSb.String(), thinkingSb.String(), toolCalls, nil
}
func (p *LFM2Parser) toolCallsAllowed(calls []api.ToolCall) bool {
if len(calls) == 0 {
return false
}
if len(p.toolNames) == 0 {
return true
}
for _, call := range calls {
if _, ok := p.toolNames[call.Function.Name]; !ok {
return false
}
}
return true
}
func (p *LFM2Parser) parseEvents() []lfm2Event {
var all []lfm2Event
keepLooping := true
for keepLooping {
var events []lfm2Event
events, keepLooping = p.eat()
if len(events) > 0 {
all = append(all, events...)
}
}
return all
}
func (p *LFM2Parser) eat() ([]lfm2Event, bool) {
var events []lfm2Event
bufStr := p.buffer.String()
if bufStr == "" {
return events, false
}
switch p.state {
case LFM2CollectingThinking:
// Strip opening <think> tag if present
if strings.HasPrefix(bufStr, lfm2ThinkingOpenTag) {
bufStr = bufStr[len(lfm2ThinkingOpenTag):]
p.needsThinkingLeadingTrim = true
p.buffer.Reset()
p.buffer.WriteString(bufStr)
}
// Trim leading whitespace after <think> tag (may span multiple chunks)
if p.needsThinkingLeadingTrim {
if trimmed := strings.TrimLeftFunc(bufStr, unicode.IsSpace); trimmed != bufStr {
bufStr = trimmed
p.buffer.Reset()
p.buffer.WriteString(bufStr)
}
// Clear flag once we have non-whitespace content or buffer is empty
if len(bufStr) > 0 {
p.needsThinkingLeadingTrim = false
}
}
if strings.Contains(bufStr, lfm2ThinkingCloseTag) { // thinking[</think>] -> content
split := strings.SplitN(bufStr, lfm2ThinkingCloseTag, 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 = LFM2CollectingContent
p.needsThinkingLeadingTrim = false
// Set flag to trim any additional whitespace that may arrive in later chunks
p.needsContentLeadingTrim = len(remaining) == 0
if len(thinking) > 0 {
events = append(events, lfm2EventThinkingContent{content: thinking})
}
return events, true
} else if overlapLen := overlap(bufStr, lfm2ThinkingCloseTag); 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, lfm2EventThinkingContent{content: unambiguous})
}
return events, false
} else { // otherwise its 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, lfm2EventThinkingContent{content: unambiguous})
}
return events, false
}
case LFM2CollectingContent:
// Trim leading whitespace after </think> tag (may span multiple chunks)
if p.needsContentLeadingTrim {
if trimmed := strings.TrimLeftFunc(bufStr, unicode.IsSpace); trimmed != bufStr {
bufStr = trimmed
p.buffer.Reset()
p.buffer.WriteString(bufStr)
}
// Clear flag once we have non-whitespace content
if len(bufStr) > 0 {
p.needsContentLeadingTrim = false
}
}
if strings.Contains(bufStr, lfm2ToolCallStartTag) { // content[<|tool_call_start|>] -> tool calls
split := strings.SplitN(bufStr, lfm2ToolCallStartTag, 2)
contentBefore := strings.TrimRightFunc(split[0], unicode.IsSpace)
remaining := split[1]
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = LFM2CollectingToolCalls
if len(contentBefore) > 0 {
events = append(events, lfm2EventContent{content: contentBefore})
}
return events, true
} else { // otherwise its content
p.buffer.Reset()
if len(bufStr) > 0 {
events = append(events, lfm2EventContent{content: bufStr})
}
return events, false
}
case LFM2CollectingToolCalls:
// Look for complete tool call JSON between tags
if idx := strings.Index(bufStr, lfm2ToolCallEndTag); idx != -1 {
toolCallContent := bufStr[:idx]
if toolCalls, err := p.parseToolCallsContent(toolCallContent); err == nil && len(toolCalls) > 0 {
remaining := bufStr[idx+len(lfm2ToolCallEndTag):]
// Check if there's another tool call
if strings.HasPrefix(remaining, lfm2ToolCallStartTag) {
remaining = remaining[len(lfm2ToolCallStartTag):]
} else {
// No more tool calls, go back to content
remaining = strings.TrimLeftFunc(remaining, unicode.IsSpace)
p.state = LFM2CollectingContent
}
p.buffer.Reset()
p.buffer.WriteString(remaining)
for _, tc := range toolCalls {
events = append(events, lfm2EventToolCall{toolCall: tc})
}
return events, true
} else if err != nil {
slog.Warn("lfm2 tool call parsing failed", "error", err, "content", toolCallContent)
}
}
return events, false
}
return events, false
}
// parseToolCallsContent parses one or more Python-style tool calls.
// Example: [func1(arg='v'), func2(x=1)]
func (p *LFM2Parser) parseToolCallsContent(content string) ([]api.ToolCall, error) {
content = strings.TrimSpace(content)
// Be tolerant of malformed outputs that include wrapper tags without proper pairing.
content = strings.TrimSpace(strings.TrimPrefix(content, lfm2ToolCallStartTag))
content = strings.TrimSpace(strings.TrimSuffix(content, lfm2ToolCallEndTag))
// Parse Python-style format: [func(arg1='val1'),func2(arg2='val2')] or func(arg1='val1')
return p.parsePythonStyleToolCalls(content)
}
// parsePythonStyleToolCalls parses one or more Python-style tool calls
// Examples: [bash(command='ls'),bash(command='pwd')] or bash(command='ls')
func (p *LFM2Parser) parsePythonStyleToolCalls(content string) ([]api.ToolCall, error) {
content = strings.TrimSpace(content)
// Strip outer brackets if present: [func(...)] -> func(...)
if strings.HasPrefix(content, "[") && strings.HasSuffix(content, "]") {
content = content[1 : len(content)-1]
}
var toolCalls []api.ToolCall
// Parse multiple function calls separated by commas at the top level
for len(content) > 0 {
content = strings.TrimSpace(content)
if content == "" {
break
}
// Skip leading comma from previous iteration
if strings.HasPrefix(content, ",") {
content = strings.TrimSpace(content[1:])
if content == "" {
break
}
}
// Find function name
parenIdx := strings.Index(content, "(")
if parenIdx == -1 {
return nil, errors.New("invalid tool call: no opening parenthesis")
}
funcName := strings.TrimSpace(content[:parenIdx])
if funcName == "" {
return nil, errors.New("invalid tool call: empty function name")
}
// Find matching closing parenthesis
closeIdx := findMatchingParen(content, parenIdx)
if closeIdx == -1 {
return nil, errors.New("invalid tool call: no matching closing parenthesis")
}
argsStr := content[parenIdx+1 : closeIdx]
args := api.NewToolCallFunctionArguments()
if argsStr != "" {
if err := parsePythonArgs(argsStr, &args); err != nil {
return nil, err
}
}
toolCalls = append(toolCalls, api.ToolCall{
Function: api.ToolCallFunction{
Name: funcName,
Arguments: args,
},
})
// Move past this function call
content = content[closeIdx+1:]
}
if len(toolCalls) == 0 {
return nil, errors.New("no tool calls found")
}
return toolCalls, nil
}
// findMatchingParen finds the index of the closing parenthesis matching the one at openIdx
// Returns -1 if not found. Handles nested parentheses and quoted strings.
func findMatchingParen(s string, openIdx int) int {
depth := 1
i := openIdx + 1
for i < len(s) && depth > 0 {
switch s[i] {
case '(':
depth++
case ')':
depth--
if depth == 0 {
return i
}
case '\'', '"':
// Skip quoted string
quote := s[i]
i++
for i < len(s) && s[i] != quote {
if s[i] == '\\' && i+1 < len(s) {
i++ // skip escaped char
}
i++
}
}
i++
}
return -1
}
// parseToolCallContent parses a single tool call (for backward compatibility with tests)
func (p *LFM2Parser) parseToolCallContent(content string) (api.ToolCall, error) {
calls, err := p.parseToolCallsContent(content)
if err != nil {
return api.ToolCall{}, err
}
if len(calls) == 0 {
return api.ToolCall{}, errors.New("no tool call found")
}
return calls[0], nil
}
// parsePythonArgs parses Python-style keyword arguments: key='value', key2="value2"
func parsePythonArgs(argsStr string, args *api.ToolCallFunctionArguments) error {
i := 0
for i < len(argsStr) {
// Skip separators and whitespace.
for i < len(argsStr) && (argsStr[i] == ',' || unicode.IsSpace(rune(argsStr[i]))) {
i++
}
if i >= len(argsStr) {
break
}
keyStart := i
for i < len(argsStr) && argsStr[i] != '=' && argsStr[i] != ',' {
i++
}
if i >= len(argsStr) || argsStr[i] != '=' {
return errors.New("invalid argument: expected '='")
}
key := strings.TrimSpace(argsStr[keyStart:i])
if key == "" {
return errors.New("invalid argument: empty key")
}
i++ // skip '='
for i < len(argsStr) && unicode.IsSpace(rune(argsStr[i])) {
i++
}
if i >= len(argsStr) {
return errors.New("invalid argument: missing value")
}
value, next, err := parsePythonArgValue(argsStr, i)
if err != nil {
return err
}
args.Set(key, value)
i = next
// Optional trailing comma before next key/value.
if i < len(argsStr) && argsStr[i] == ',' {
i++
}
}
return nil
}
func parsePythonArgValue(s string, i int) (any, int, error) {
if i >= len(s) {
return nil, i, errors.New("invalid argument: missing value")
}
// Quoted string literal.
if s[i] == '\'' || s[i] == '"' {
quote := s[i]
i++
start := i
for i < len(s) {
if s[i] == '\\' && i+1 < len(s) {
i += 2
continue
}
if s[i] == quote {
value := s[start:i]
i++
return value, i, nil
}
i++
}
return nil, i, errors.New("invalid argument: unterminated string")
}
// Unquoted literal. Consume until top-level comma.
start := i
depthParen, depthSquare, depthCurly := 0, 0, 0
inString := false
var quote byte
escaped := false
for i < len(s) {
ch := s[i]
if inString {
if escaped {
escaped = false
} else if ch == '\\' {
escaped = true
} else if ch == quote {
inString = false
}
i++
continue
}
switch ch {
case '\'', '"':
inString = true
quote = ch
case '(':
depthParen++
case ')':
if depthParen > 0 {
depthParen--
}
case '[':
depthSquare++
case ']':
if depthSquare > 0 {
depthSquare--
}
case '{':
depthCurly++
case '}':
if depthCurly > 0 {
depthCurly--
}
case ',':
if depthParen == 0 && depthSquare == 0 && depthCurly == 0 {
token := strings.TrimSpace(s[start:i])
value, err := parsePythonLiteral(token)
return value, i, err
}
}
i++
}
token := strings.TrimSpace(s[start:i])
value, err := parsePythonLiteral(token)
return value, i, err
}
func parsePythonLiteral(token string) (any, error) {
switch token {
case "":
return "", nil
case "true", "True":
return true, nil
case "false", "False":
return false, nil
case "null", "None":
return nil, nil
}
if v, err := strconv.ParseInt(token, 10, 64); err == nil {
return v, nil
}
if v, err := strconv.ParseFloat(token, 64); err == nil {
return v, nil
}
if strings.HasPrefix(token, "[") || strings.HasPrefix(token, "{") {
var parsed any
if err := json.Unmarshal([]byte(token), &parsed); err == nil {
return parsed, nil
}
if converted, err := pythonLiteralToJSON(token); err == nil {
if err := json.Unmarshal([]byte(converted), &parsed); err == nil {
return parsed, nil
}
}
}
return token, nil
}
func pythonLiteralToJSON(s string) (string, error) {
var out strings.Builder
out.Grow(len(s) + len(s)/8)
inString := false
var quote byte
escaped := false
for i := 0; i < len(s); i++ {
ch := s[i]
if inString {
if escaped {
out.WriteByte(ch)
escaped = false
continue
}
if ch == '\\' {
out.WriteByte(ch)
escaped = true
continue
}
if ch == quote {
out.WriteByte('"')
inString = false
continue
}
if quote == '\'' && ch == '"' {
out.WriteString(`\"`)
continue
}
out.WriteByte(ch)
continue
}
if ch == '\'' || ch == '"' {
inString = true
quote = ch
escaped = false
out.WriteByte('"')
continue
}
// Replace Python identifiers with JSON equivalents when outside strings.
if isIdentStart(ch) {
j := i + 1
for j < len(s) && isIdentPart(s[j]) {
j++
}
ident := s[i:j]
switch ident {
case "True":
out.WriteString("true")
case "False":
out.WriteString("false")
case "None":
out.WriteString("null")
default:
out.WriteString(ident)
}
i = j - 1
continue
}
out.WriteByte(ch)
}
if inString {
return "", errors.New("unterminated string")
}
return out.String(), nil
}
func isIdentStart(b byte) bool {
return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || b == '_'
}
func isIdentPart(b byte) bool {
return isIdentStart(b) || (b >= '0' && b <= '9')
}

1222
model/parsers/lfm2_test.go Normal file

File diff suppressed because it is too large Load Diff

343
model/parsers/ministral.go Normal file
View File

@@ -0,0 +1,343 @@
package parsers
import (
"encoding/json"
"fmt"
"strings"
"unicode"
"github.com/ollama/ollama/api"
)
type ministralParserState int
const (
ministralCollectingContent = iota
ministralCollectingThinkingContent
ministralCollectingToolName
ministralCollectingToolArgs
)
// ministralEvent represents an event emitted during parsing
type ministralEvent interface {
isMinistralEvent()
}
type ministralEventContent struct {
content string
}
type ministralEventThinking struct {
thinking string
}
type ministralEventToolCall struct {
name string
args string // raw JSON string
}
func (ministralEventContent) isMinistralEvent() {}
func (ministralEventThinking) isMinistralEvent() {}
func (ministralEventToolCall) isMinistralEvent() {}
type MinistralParser struct {
state ministralParserState
buffer strings.Builder
tools []api.Tool
callIndex int
hasThinkingSupport bool
pendingToolName string // stores tool name while collecting args
}
func (p *MinistralParser) HasToolSupport() bool {
return true
}
func (p *MinistralParser) HasThinkingSupport() bool {
return p.hasThinkingSupport
}
func (p *MinistralParser) setInitialState(lastMessage *api.Message) {
prefill := lastMessage != nil && lastMessage.Role == "assistant"
if !p.HasThinkingSupport() {
p.state = ministralCollectingContent
return
}
if prefill && lastMessage.Content != "" {
p.state = ministralCollectingContent
return
}
p.state = ministralCollectingThinkingContent
}
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
}
func toolByName(tools []api.Tool, n string) (*api.Tool, error) {
for i := range tools {
if tools[i].Function.Name == n {
return &tools[i], nil
}
}
return nil, fmt.Errorf("tool '%s' not found", n)
}
const (
ministralToolCallsTag = "[TOOL_CALLS]"
ministralThinkTag = "[THINK]"
ministralThinkEndTag = "[/THINK]"
ministralArgsTag = "[ARGS]"
)
// eat consumes the parser's buffer, and returns a list of any unambiguous
// events from the current parser state. The second return value indicates
// whether to keep looping (true when state transitions, false when waiting
// for more data).
func (p *MinistralParser) eat() ([]ministralEvent, bool) {
var events []ministralEvent
switch p.state {
case ministralCollectingContent:
bufStr := p.buffer.String()
// Check for [TOOL_CALLS] tag
if strings.Contains(bufStr, ministralToolCallsTag) {
split := strings.SplitN(bufStr, ministralToolCallsTag, 2)
before := strings.TrimRightFunc(split[0], unicode.IsSpace)
if len(before) > 0 {
events = append(events, ministralEventContent{content: before})
}
after := split[1]
p.buffer.Reset()
p.buffer.WriteString(after)
p.state = ministralCollectingToolName
return events, true
}
// Check for [THINK] tag
if strings.Contains(bufStr, ministralThinkTag) {
split := strings.SplitN(bufStr, ministralThinkTag, 2)
before := strings.TrimRightFunc(split[0], unicode.IsSpace)
if len(before) > 0 {
events = append(events, ministralEventContent{content: before})
}
after := split[1]
p.buffer.Reset()
p.buffer.WriteString(after)
p.state = ministralCollectingThinkingContent
return events, true
}
// Check for partial tag overlap with [TOOL_CALLS] or [THINK]
overlapToolCalls := overlap(bufStr, ministralToolCallsTag)
overlapThink := overlap(bufStr, ministralThinkTag)
maxOverlap := max(overlapToolCalls, overlapThink)
if maxOverlap > 0 {
// Withhold the potential partial tag
beforePartialTag := bufStr[:len(bufStr)-maxOverlap]
trailingWS := trailingWhitespaceLen(beforePartialTag)
ambiguousStart := len(beforePartialTag) - trailingWS
unambiguous := bufStr[:ambiguousStart]
ambiguous := bufStr[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, ministralEventContent{content: unambiguous})
}
return events, false
}
// No tag found: emit content but withhold trailing whitespace
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, ministralEventContent{content: unambiguous})
}
return events, false
case ministralCollectingThinkingContent:
bufStr := p.buffer.String()
if strings.Contains(bufStr, ministralThinkEndTag) {
split := strings.SplitN(bufStr, ministralThinkEndTag, 2)
thinkingContent := split[0]
after := strings.TrimLeftFunc(split[1], unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(after)
if len(thinkingContent) > 0 {
events = append(events, ministralEventThinking{thinking: thinkingContent})
}
p.state = ministralCollectingContent
return events, true
}
// Check for partial overlap with [/THINK]
if overlapLen := overlap(bufStr, ministralThinkEndTag); overlapLen > 0 {
unambiguous := bufStr[:len(bufStr)-overlapLen]
ambiguous := bufStr[len(bufStr)-overlapLen:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, ministralEventThinking{thinking: unambiguous})
}
return events, false
}
// No tag found: emit all thinking content
p.buffer.Reset()
if len(bufStr) > 0 {
events = append(events, ministralEventThinking{thinking: bufStr})
}
return events, false
case ministralCollectingToolName:
bufStr := p.buffer.String()
if strings.Contains(bufStr, ministralArgsTag) {
split := strings.SplitN(bufStr, ministralArgsTag, 2)
toolName := split[0]
after := split[1]
p.pendingToolName = toolName
p.buffer.Reset()
p.buffer.WriteString(after)
p.state = ministralCollectingToolArgs
return events, true
}
// Wait for more data
return events, false
case ministralCollectingToolArgs:
bufStr := p.buffer.String()
jsonEnd := findJSONEnd(bufStr)
if jsonEnd != -1 {
jsonStr := bufStr[:jsonEnd+1]
remaining := bufStr[jsonEnd+1:]
events = append(events, ministralEventToolCall{
name: p.pendingToolName,
args: jsonStr,
})
p.pendingToolName = ""
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = ministralCollectingContent
return events, true
}
// Wait for more data
return events, false
default:
panic("unexpected ministral event")
}
}
// parseEvents loops calling eat() until it returns false
func (p *MinistralParser) parseEvents() []ministralEvent {
var all []ministralEvent
keepLooping := true
for keepLooping {
var events []ministralEvent
events, keepLooping = p.eat()
all = append(all, events...)
}
return all
}
func (p *MinistralParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
events := p.parseEvents()
var contentBuilder, thinkingBuilder strings.Builder
var toolCalls []api.ToolCall
for _, event := range events {
switch e := event.(type) {
case ministralEventContent:
contentBuilder.WriteString(e.content)
case ministralEventThinking:
thinkingBuilder.WriteString(e.thinking)
case ministralEventToolCall:
// Validate tool exists
tool, toolErr := toolByName(p.tools, e.name)
if toolErr != nil {
return contentBuilder.String(), thinkingBuilder.String(), toolCalls, toolErr
}
// Parse JSON arguments
var args api.ToolCallFunctionArguments
if jsonErr := json.Unmarshal([]byte(e.args), &args); jsonErr != nil {
return contentBuilder.String(), thinkingBuilder.String(), toolCalls, jsonErr
}
toolCalls = append(toolCalls, api.ToolCall{
Function: api.ToolCallFunction{
Name: tool.Function.Name,
Arguments: args,
},
})
}
}
for i := range toolCalls {
toolCalls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentBuilder.String(), thinkingBuilder.String(), toolCalls, nil
}
// findJSONEnd finds the index of the closing brace that completes a JSON object.
// It properly handles nested objects, arrays, and strings (including escaped characters).
// Returns -1 if the JSON is not yet complete.
func findJSONEnd(s string) int {
depth := 0
inString := false
escaped := false
for i, r := range s {
if inString {
switch {
case escaped:
// If the previous character was a backslash, skip this character
escaped = false
case r == '\\':
// Mark the next character as escaped
escaped = true
case r == '"':
// End of string literal
inString = false
}
continue
}
switch r {
case '"':
// Start of string literal
inString = true
case '{', '[':
// Increase nesting level for objects and arrays
depth++
case '}', ']':
// Decrease nesting level
depth--
if depth == 0 {
// Reached the end of the root JSON structure
return i
}
}
}
return -1
}

View File

@@ -0,0 +1,594 @@
package parsers
import (
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
)
func TestMinistralParserStreaming(t *testing.T) {
type step struct {
input string
wantEvents []ministralEvent
}
cases := []struct {
desc string
tools []api.Tool
steps []step
think bool // whether to enable thinking support
}{
// Content streaming
{
desc: "simple content",
steps: []step{
{input: "Hello, how can I help you?", wantEvents: []ministralEvent{
ministralEventContent{content: "Hello, how can I help you?"},
}},
},
},
{
desc: "streaming content word by word",
steps: []step{
{input: "Hello,", wantEvents: []ministralEvent{ministralEventContent{content: "Hello,"}}},
{input: " how", wantEvents: []ministralEvent{ministralEventContent{content: " how"}}},
{input: " can I help?", wantEvents: []ministralEvent{ministralEventContent{content: " can I help?"}}},
},
},
// Simple tool calls
{
desc: "simple tool call",
tools: []api.Tool{{Function: api.ToolFunction{Name: "get_weather"}}},
steps: []step{
{input: `[TOOL_CALLS]get_weather[ARGS]{"location": "San Francisco"}`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "get_weather", args: `{"location": "San Francisco"}`},
}},
},
},
{
desc: "tool call with nested object",
tools: []api.Tool{{Function: api.ToolFunction{Name: "create_entities"}}},
steps: []step{
{input: `[TOOL_CALLS]create_entities[ARGS]{"entities": [{"entityType": "Person", "name": "Jack", "observations": ["Works as a baker"]}]}`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "create_entities", args: `{"entities": [{"entityType": "Person", "name": "Jack", "observations": ["Works as a baker"]}]}`},
}},
},
},
{
desc: "tool call with deeply nested objects",
tools: []api.Tool{{Function: api.ToolFunction{Name: "update_config"}}},
steps: []step{
{input: `[TOOL_CALLS]update_config[ARGS]{"settings": {"user": {"profile": {"name": "John", "age": 30}}, "theme": "dark"}}`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "update_config", args: `{"settings": {"user": {"profile": {"name": "John", "age": 30}}, "theme": "dark"}}`},
}},
},
},
{
desc: "tool call with array of objects",
tools: []api.Tool{{Function: api.ToolFunction{Name: "process_items"}}},
steps: []step{
{input: `[TOOL_CALLS]process_items[ARGS]{"items": [{"id": 1}, {"id": 2}, {"id": 3}]}`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "process_items", args: `{"items": [{"id": 1}, {"id": 2}, {"id": 3}]}`},
}},
},
},
{
desc: "tool call with escaped quotes in string",
tools: []api.Tool{{Function: api.ToolFunction{Name: "search"}}},
steps: []step{
{input: `[TOOL_CALLS]search[ARGS]{"query": "say \"hello\""}`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "search", args: `{"query": "say \"hello\""}`},
}},
},
},
{
desc: "tool call with braces inside string",
tools: []api.Tool{{Function: api.ToolFunction{Name: "format"}}},
steps: []step{
{input: `[TOOL_CALLS]format[ARGS]{"template": "Hello {name}!"}`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "format", args: `{"template": "Hello {name}!"}`},
}},
},
},
{
desc: "empty JSON object",
tools: []api.Tool{{Function: api.ToolFunction{Name: "no_args"}}},
steps: []step{
{input: `[TOOL_CALLS]no_args[ARGS]{}`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "no_args", args: `{}`},
}},
},
},
{
desc: "JSON with newlines in string",
tools: []api.Tool{{Function: api.ToolFunction{Name: "write"}}},
steps: []step{
{input: `[TOOL_CALLS]write[ARGS]{"content": "line1\nline2\nline3"}`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "write", args: `{"content": "line1\nline2\nline3"}`},
}},
},
},
{
desc: "backslash in string value",
tools: []api.Tool{{Function: api.ToolFunction{Name: "path"}}},
steps: []step{
{input: `[TOOL_CALLS]path[ARGS]{"dir": "C:\\Users\\test"}`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "path", args: `{"dir": "C:\\Users\\test"}`},
}},
},
},
// Content after tool call
{
desc: "content after tool call",
tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}},
steps: []step{
// NOTE: It's unclear if this is valid Ministral output, but the parser
// currently treats text after a tool call as regular content. This test
// documents that behavior so we notice if it changes.
{input: `[TOOL_CALLS]test[ARGS]{"a": 1}some content after`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "test", args: `{"a": 1}`},
ministralEventContent{content: "some content after"},
}},
},
},
// Multiple tool calls
{
desc: "multiple tool calls in sequence",
tools: []api.Tool{
{Function: api.ToolFunction{Name: "get_weather"}},
{Function: api.ToolFunction{Name: "get_time"}},
},
steps: []step{
{input: `[TOOL_CALLS]get_weather[ARGS]{"location": "NYC"}[TOOL_CALLS]get_time[ARGS]{"timezone": "EST"}`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "get_weather", args: `{"location": "NYC"}`},
ministralEventToolCall{name: "get_time", args: `{"timezone": "EST"}`},
}},
},
},
{
desc: "multiple tool calls streamed separately",
tools: []api.Tool{
{Function: api.ToolFunction{Name: "tool_a"}},
{Function: api.ToolFunction{Name: "tool_b"}},
},
steps: []step{
{input: `[TOOL_CALLS]tool_a[ARGS]{"x": 1}`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "tool_a", args: `{"x": 1}`},
}},
{input: `[TOOL_CALLS]tool_b[ARGS]{"y": 2}`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "tool_b", args: `{"y": 2}`},
}},
},
},
// Streaming tool calls
{
desc: "streaming tool call with nested objects",
tools: []api.Tool{{Function: api.ToolFunction{Name: "create_entities"}}},
steps: []step{
{input: "[TOOL_CALLS]create_entities[ARGS]", wantEvents: []ministralEvent{}},
{input: `{"entities": [{"entityType": "Person",`, wantEvents: []ministralEvent{}},
{input: ` "name": "Jack",`, wantEvents: []ministralEvent{}},
{input: ` "observations": ["Works`, wantEvents: []ministralEvent{}},
{input: ` as a baker"]}`, wantEvents: []ministralEvent{}},
{input: `]}`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "create_entities", args: `{"entities": [{"entityType": "Person", "name": "Jack", "observations": ["Works as a baker"]}]}`},
}},
},
},
{
desc: "streaming with incomplete JSON waits for completion",
tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}},
steps: []step{
{input: "[TOOL_CALLS]test[ARGS]{", wantEvents: []ministralEvent{}},
{input: `"a": {`, wantEvents: []ministralEvent{}},
{input: `"b": 1`, wantEvents: []ministralEvent{}},
{input: `}`, wantEvents: []ministralEvent{}},
{input: `}`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "test", args: `{"a": {"b": 1}}`},
}},
},
},
// Partial tag handling
{
desc: "partial tool tag fakeout",
steps: []step{
{input: "abc[TOOL", wantEvents: []ministralEvent{ministralEventContent{content: "abc"}}},
{input: " not a tag", wantEvents: []ministralEvent{ministralEventContent{content: "[TOOL not a tag"}}},
},
},
{
desc: "tool call tag split across chunks",
tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}},
steps: []step{
{input: "[TOOL_", wantEvents: []ministralEvent{}},
{input: "CALLS]test[ARGS]{}", wantEvents: []ministralEvent{
ministralEventToolCall{name: "test", args: `{}`},
}},
},
},
{
desc: "content before tool call",
tools: []api.Tool{{Function: api.ToolFunction{Name: "get_weather"}}},
steps: []step{
{input: "hello [TOOL_CALLS]get_weather[ARGS]{}", wantEvents: []ministralEvent{
ministralEventContent{content: "hello"},
ministralEventToolCall{name: "get_weather", args: `{}`},
}},
},
},
{
desc: "whitespace between content and tool call is trimmed",
tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}},
steps: []step{
{input: "content \n [TOOL_CALLS]test[ARGS]{}", wantEvents: []ministralEvent{
ministralEventContent{content: "content"},
ministralEventToolCall{name: "test", args: `{}`},
}},
},
},
{
desc: "tabs and newlines before tool call are trimmed",
tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}},
steps: []step{
{input: "content\t\n\t[TOOL_CALLS]test[ARGS]{}", wantEvents: []ministralEvent{
ministralEventContent{content: "content"},
ministralEventToolCall{name: "test", args: `{}`},
}},
},
},
{
desc: "non-breaking space before tool call is trimmed",
tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}},
steps: []step{
// \u00a0 is non-breaking space, which unicode.IsSpace considers whitespace
{input: "content\u00a0[TOOL_CALLS]test[ARGS]{}", wantEvents: []ministralEvent{
ministralEventContent{content: "content"},
ministralEventToolCall{name: "test", args: `{}`},
}},
},
},
{
desc: "whitespace before THINK tag is trimmed",
steps: []step{
{input: "content \n [THINK]thinking[/THINK]after", wantEvents: []ministralEvent{
ministralEventContent{content: "content"},
ministralEventThinking{thinking: "thinking"},
ministralEventContent{content: "after"},
}},
},
},
{
desc: "trailing whitespace withheld then emitted",
steps: []step{
{input: "Hello ", wantEvents: []ministralEvent{ministralEventContent{content: "Hello"}}},
{input: "world", wantEvents: []ministralEvent{ministralEventContent{content: " world"}}},
},
},
{
desc: "trailing newline withheld then emitted",
steps: []step{
{input: "Hello\n", wantEvents: []ministralEvent{ministralEventContent{content: "Hello"}}},
{input: "world", wantEvents: []ministralEvent{ministralEventContent{content: "\nworld"}}},
},
},
// Thinking support
{
desc: "thinking content",
think: true,
steps: []step{
{input: "thinking here[/THINK]", wantEvents: []ministralEvent{
ministralEventThinking{thinking: "thinking here"},
}},
{input: "content after", wantEvents: []ministralEvent{
ministralEventContent{content: "content after"},
}},
},
},
{
desc: "thinking with whitespace after end tag",
think: true,
steps: []step{
{input: "my thoughts[/THINK] \n response", wantEvents: []ministralEvent{
ministralEventThinking{thinking: "my thoughts"},
ministralEventContent{content: "response"},
}},
},
},
{
desc: "non-breaking space after think end tag is trimmed",
think: true,
steps: []step{
// \u00a0 is non-breaking space
{input: "thinking[/THINK]\u00a0response", wantEvents: []ministralEvent{
ministralEventThinking{thinking: "thinking"},
ministralEventContent{content: "response"},
}},
},
},
{
desc: "partial think end tag",
think: true,
steps: []step{
{input: "thinking[/THI", wantEvents: []ministralEvent{ministralEventThinking{thinking: "thinking"}}},
{input: "NK]after", wantEvents: []ministralEvent{ministralEventContent{content: "after"}}},
},
},
{
desc: "think tag fakeout",
think: true,
steps: []step{
{input: "thinking[/THI", wantEvents: []ministralEvent{ministralEventThinking{thinking: "thinking"}}},
{input: "not end tag", wantEvents: []ministralEvent{ministralEventThinking{thinking: "[/THInot end tag"}}},
},
},
{
desc: "thinking then tool call",
think: true,
tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}},
steps: []step{
{input: "let me think[/THINK][TOOL_CALLS]test[ARGS]{}", wantEvents: []ministralEvent{
ministralEventThinking{thinking: "let me think"},
ministralEventToolCall{name: "test", args: `{}`},
}},
},
},
// Content then THINK tag transition
{
desc: "content then think tag",
steps: []step{
{input: "content[THINK]thinking[/THINK]more", wantEvents: []ministralEvent{
ministralEventContent{content: "content"},
ministralEventThinking{thinking: "thinking"},
ministralEventContent{content: "more"},
}},
},
},
// Unicode handling
{
desc: "unicode content",
steps: []step{
{input: "你好 🌍 مرحبا", wantEvents: []ministralEvent{
ministralEventContent{content: "你好 🌍 مرحبا"},
}},
},
},
{
desc: "unicode in tool args",
tools: []api.Tool{{Function: api.ToolFunction{Name: "greet"}}},
steps: []step{
{input: `[TOOL_CALLS]greet[ARGS]{"message": "你好 🌍"}`, wantEvents: []ministralEvent{
ministralEventToolCall{name: "greet", args: `{"message": "你好 🌍"}`},
}},
},
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
parser := MinistralParser{}
parser.hasThinkingSupport = tc.think
parser.Init(tc.tools, nil, nil)
for i, step := range tc.steps {
parser.buffer.WriteString(step.input)
gotEvents := parser.parseEvents()
if len(gotEvents) == 0 && len(step.wantEvents) == 0 {
// avoid deep equal on empty vs. nil slices
continue
}
if !reflect.DeepEqual(gotEvents, step.wantEvents) {
t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents)
}
}
})
}
}
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{}
p.Init([]api.Tool{{Function: api.ToolFunction{Name: "known_tool"}}}, nil, nil)
_, _, _, err := p.Add(`[TOOL_CALLS]unknown_tool[ARGS]{"a": 1}`, true)
if err == nil {
t.Fatal("expected error for unknown tool")
}
})
t.Run("invalid JSON returns error", func(t *testing.T) {
p := &MinistralParser{}
p.Init([]api.Tool{{Function: api.ToolFunction{Name: "test"}}}, nil, nil)
_, _, _, err := p.Add(`[TOOL_CALLS]test[ARGS]{invalid json}`, true)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
})
}
func TestFindJSONEnd(t *testing.T) {
tests := []struct {
name string
input string
expected int
}{
{
name: "simple object",
input: `{"a": 1}`,
expected: 7,
},
{
name: "nested object",
input: `{"a": {"b": 2}}`,
expected: 14,
},
{
name: "array inside object",
input: `{"items": [1, 2, 3]}`,
expected: 19,
},
{
name: "braces in string",
input: `{"template": "Hello {name}!"}`,
expected: 28,
},
{
name: "escaped quotes",
input: `{"msg": "say \"hi\""}`,
expected: 20,
},
{
name: "incomplete object",
input: `{"a": {"b": 1}`,
expected: -1,
},
{
name: "deeply nested",
input: `{"a": {"b": {"c": {"d": 1}}}}`,
expected: 28,
},
{
name: "object with trailing content",
input: `{"a": 1} extra`,
expected: 7,
},
{
name: "array",
input: `[{"a": 1}, {"b": 2}]`,
expected: 19,
},
{
name: "escaped backslash before quote",
input: `{"path": "C:\\"}`,
expected: 15,
},
{
name: "empty string",
input: "",
expected: -1,
},
{
name: "no opening brace",
input: "hello world",
expected: -1,
},
{
name: "only opening brace",
input: "{",
expected: -1,
},
{
name: "unclosed string",
input: `{"key": "unclosed`,
expected: -1,
},
{
name: "double escaped backslash then quote",
input: `{"path": "C:\\\\"}`,
expected: 17,
},
{
name: "unicode in key and value",
input: `{"키": "값"}`,
expected: 13,
},
{
name: "nested arrays",
input: `{"matrix": [[1, 2], [3, 4]]}`,
expected: 27,
},
{
name: "mixed nesting",
input: `{"a": [{"b": {"c": [1, 2, 3]}}]}`,
expected: 31,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := findJSONEnd(tt.input)
if result != tt.expected {
t.Errorf("findJSONEnd(%q) = %d, want %d", tt.input, result, tt.expected)
}
})
}
}
func TestMinistralParser_HasToolSupport(t *testing.T) {
p := &MinistralParser{}
if !p.HasToolSupport() {
t.Error("expected HasToolSupport to return true")
}
}
func TestMinistralParser_HasThinkingSupport(t *testing.T) {
p := &MinistralParser{hasThinkingSupport: false}
if p.HasThinkingSupport() {
t.Error("expected HasThinkingSupport to return false")
}
p = &MinistralParser{hasThinkingSupport: true}
if !p.HasThinkingSupport() {
t.Error("expected HasThinkingSupport to return true")
}
}

View File

@@ -0,0 +1,188 @@
package parsers
import (
"strings"
"unicode"
"github.com/ollama/ollama/api"
)
type Nemotron3NanoParserState int
const (
Nemotron3NanoCollectingThinking Nemotron3NanoParserState = iota
Nemotron3NanoSkipWhitespaceAfterThinking
Nemotron3NanoCollectingContent
)
const (
nemotronThinkOpen = "<think>"
nemotronThinkClose = "</think>"
nemotronToolCallOpen = "<tool_call>"
)
type Nemotron3NanoParser struct {
state Nemotron3NanoParserState
buffer strings.Builder
toolParser *Qwen3CoderParser
maybeThinkingOpenAtBOL bool
skipThinkingLeadingWS bool
}
func (p *Nemotron3NanoParser) HasToolSupport() bool { return true }
func (p *Nemotron3NanoParser) HasThinkingSupport() bool { return true }
func (p *Nemotron3NanoParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.toolParser = &Qwen3CoderParser{}
p.toolParser.Init(tools, nil, nil)
p.buffer.Reset()
p.maybeThinkingOpenAtBOL = false
p.skipThinkingLeadingWS = false
thinkingEnabled := thinkValue == nil || thinkValue.Bool()
prefill := lastMessage != nil && lastMessage.Role == "assistant"
if !thinkingEnabled || (prefill && lastMessage.Content != "") {
p.state = Nemotron3NanoCollectingContent
} else {
p.state = Nemotron3NanoCollectingThinking
p.maybeThinkingOpenAtBOL = true
}
return tools
}
func (p *Nemotron3NanoParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
if p.state == Nemotron3NanoCollectingContent {
return p.toolParser.Add(s, done)
}
if p.state == Nemotron3NanoSkipWhitespaceAfterThinking {
s = strings.TrimLeftFunc(s, unicode.IsSpace)
if s == "" {
return "", "", nil, nil
}
p.state = Nemotron3NanoCollectingContent
return p.toolParser.Add(s, done)
}
// Nemotron3NanoCollectingThinking - buffer and look for end markers
p.buffer.WriteString(s)
if p.skipThinkingLeadingWS {
trimmed := strings.TrimLeftFunc(p.buffer.String(), unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(trimmed)
if trimmed == "" {
return "", "", nil, nil
}
p.skipThinkingLeadingWS = false
}
if p.stripOpeningThinkTag() {
return p.Add("", done)
}
if p.maybeThinkingOpenAtBOL {
bufStr := p.buffer.String()
trimmed := strings.TrimLeftFunc(bufStr, unicode.IsSpace)
if trimmed == "" || overlap(trimmed, nemotronThinkOpen) == len(trimmed) {
if len(trimmed) != len(bufStr) {
p.buffer.Reset()
p.buffer.WriteString(trimmed)
}
return "", "", nil, nil
}
}
bufStr := p.buffer.String()
// Look for end of thinking: </think> or <tool_call> (model may skip </think>)
thinkIdx := strings.Index(bufStr, nemotronThinkClose)
toolIdx := strings.Index(bufStr, nemotronToolCallOpen)
var endIdx int = -1
var remainder string
if thinkIdx != -1 && (toolIdx == -1 || thinkIdx < toolIdx) {
endIdx = thinkIdx
remainder = strings.TrimLeftFunc(bufStr[thinkIdx+len(nemotronThinkClose):], unicode.IsSpace)
} else if toolIdx != -1 {
endIdx = toolIdx
remainder = bufStr[toolIdx:] // Include <tool_call> tag
}
if endIdx != -1 {
thinking = strings.TrimRightFunc(bufStr[:endIdx], unicode.IsSpace)
p.buffer.Reset()
if remainder == "" {
p.state = Nemotron3NanoSkipWhitespaceAfterThinking
} else {
p.state = Nemotron3NanoCollectingContent
content, _, calls, err = p.toolParser.Add(remainder, done)
}
return content, thinking, calls, err
}
// No end marker - emit unambiguous thinking
thinking = p.emitThinking(bufStr)
return "", thinking, nil, nil
}
// emitThinking returns unambiguous thinking content, keeping potential partial tags in buffer
func (p *Nemotron3NanoParser) emitThinking(bufStr string) string {
// Check for partial </think> or <tool_call> at end
thinkOverlap := overlap(bufStr, nemotronThinkClose)
toolOverlap := overlap(bufStr, nemotronToolCallOpen)
maxOverlap := max(thinkOverlap, toolOverlap)
if maxOverlap > 0 {
unambiguous := bufStr[:len(bufStr)-maxOverlap]
unambiguous = strings.TrimRightFunc(unambiguous, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(bufStr[len(bufStr)-maxOverlap:])
return unambiguous
}
// No partial tags - emit all but trailing whitespace
wsLen := trailingWhitespaceLen(bufStr)
if wsLen > 0 {
unambiguous := bufStr[:len(bufStr)-wsLen]
p.buffer.Reset()
p.buffer.WriteString(bufStr[len(bufStr)-wsLen:])
return unambiguous
}
// Nothing to hold back
p.buffer.Reset()
return bufStr
}
func (p *Nemotron3NanoParser) stripOpeningThinkTag() bool {
if !p.maybeThinkingOpenAtBOL {
return false
}
bufStr := p.buffer.String()
trimmed := strings.TrimLeftFunc(bufStr, unicode.IsSpace)
if trimmed == "" {
p.buffer.Reset()
return false
}
if strings.HasPrefix(trimmed, nemotronThinkOpen) {
p.buffer.Reset()
p.buffer.WriteString(strings.TrimLeftFunc(trimmed[len(nemotronThinkOpen):], unicode.IsSpace))
p.maybeThinkingOpenAtBOL = false
p.skipThinkingLeadingWS = true
return true
}
if overlap(trimmed, nemotronThinkOpen) == len(trimmed) {
if len(trimmed) != len(bufStr) {
p.buffer.Reset()
p.buffer.WriteString(trimmed)
}
return false
}
p.maybeThinkingOpenAtBOL = false
return false
}

View File

@@ -0,0 +1,434 @@
package parsers
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
)
// TestNemotron3NanoParser tests Nemotron-specific behavior (thinking support).
// Tool call parsing is tested in qwen3coder_test.go since Nemotron delegates to Qwen3CoderParser.
func TestNemotron3NanoParser(t *testing.T) {
tests := []struct {
name string
input string
thinkValue *api.ThinkValue
expectedContent string
expectedThinking string
expectedCalls []api.ToolCall
}{
{
name: "thinking then content",
input: "Let me think about this...</think>\nHere is my answer.",
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "Let me think about this...",
expectedContent: "Here is my answer.",
},
{
name: "thinking with newlines",
input: "Step 1: Analyze\nStep 2: Process\nStep 3: Conclude</think>\nThe answer is 42.",
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "Step 1: Analyze\nStep 2: Process\nStep 3: Conclude",
expectedContent: "The answer is 42.",
},
{
name: "thinking then tool call",
input: "I should check the weather...</think>\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>",
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "I should check the weather...",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
},
},
},
{
name: "thinking content then tool call",
input: "Let me think...</think>\nI'll check for you.\n<tool_call>\n<function=search>\n<parameter=query>\ntest\n</parameter>\n</function>\n</tool_call>",
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "Let me think...",
expectedContent: "I'll check for you.",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "search",
Arguments: testArgs(map[string]any{"query": "test"}),
},
},
},
},
{
name: "empty thinking block - immediate close",
input: "</think>\nHere is my answer.",
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "",
expectedContent: "Here is my answer.",
},
{
name: "thinking disabled but model outputs think close anyway",
input: "</think>\nSome content after spurious tag.",
thinkValue: &api.ThinkValue{Value: false},
expectedContent: "</think>\nSome content after spurious tag.",
},
{
name: "thinking with only whitespace after close tag",
input: "My thoughts...</think> \n\t\n Content here.",
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "My thoughts...",
expectedContent: "Content here.",
},
{
name: "leading open think tag is ignored",
input: "<think>\nLet me think about this...</think>\nHere is my answer.",
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "Let me think about this...",
expectedContent: "Here is my answer.",
},
{
name: "empty explicit think block is ignored",
input: "<think></think>\nHere is my answer.",
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "",
expectedContent: "Here is my answer.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &Nemotron3NanoParser{}
p.Init(nil, nil, tt.thinkValue)
content, thinking, calls, err := p.Add(tt.input, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Drain remaining content
finalContent, finalThinking, finalCalls, err := p.Add("", true)
if err != nil {
t.Fatalf("unexpected error on done: %v", err)
}
content += finalContent
thinking += finalThinking
calls = append(calls, finalCalls...)
if diff := cmp.Diff(content, tt.expectedContent); diff != "" {
t.Errorf("content mismatch (-got +want):\n%s", diff)
}
if diff := cmp.Diff(thinking, tt.expectedThinking); diff != "" {
t.Errorf("thinking mismatch (-got +want):\n%s", diff)
}
if diff := cmp.Diff(calls, tt.expectedCalls, argsComparer); diff != "" {
t.Errorf("calls mismatch (-got +want):\n%s", diff)
}
})
}
}
// TestNemotron3NanoParser_Streaming tests streaming behavior for thinking support.
// Tool call streaming is tested in qwen3coder_test.go.
func TestNemotron3NanoParser_Streaming(t *testing.T) {
tests := []struct {
name string
chunks []string
thinkValue *api.ThinkValue
expectedContent string
expectedThinking string
expectedCalls []api.ToolCall
}{
{
name: "streaming thinking then content - granular",
chunks: []string{"Let", " me", " th", "ink", " about", " this", "...", "<", "/", "think", ">", "\n", "Here", " is", " my", " answer", "."},
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "Let me think about this...",
expectedContent: "Here is my answer.",
},
{
name: "streaming thinking with newlines - granular",
chunks: []string{"Step", " 1", ":", " Ana", "lyze\n", "Step", " 2", ":", " Pro", "cess", "</", "thi", "nk>", "\n", "The", " ans", "wer."},
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "Step 1: Analyze\nStep 2: Process",
expectedContent: "The answer.",
},
{
name: "thinking close tag split character by character",
chunks: []string{"I", "'", "m", " ", "t", "h", "i", "n", "k", "i", "n", "g", ".", ".", ".", "<", "/", "t", "h", "i", "n", "k", ">", "\n", "D", "o", "n", "e", "!"},
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "I'm thinking...",
expectedContent: "Done!",
},
{
name: "multiple whitespace after think tag - separate chunks",
chunks: []string{"Thinking...", "</think>", "\n", "\n", " ", "Content here."},
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "Thinking...",
expectedContent: "Content here.",
},
{
name: "thinking then content then tool call - streaming",
chunks: []string{"Ana", "lyzing", " your", " request", "...", "</", "think", ">\n", "I'll", " check", " that", " for", " you", ".", "\n", "<tool", "_call", ">\n", "<function", "=search", ">\n", "<parameter", "=query", ">\n", "test", " query", "\n</", "parameter", ">\n", "</function", ">\n", "</tool", "_call", ">"},
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "Analyzing your request...",
expectedContent: "I'll check that for you.",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "search",
Arguments: testArgs(map[string]any{"query": "test query"}),
},
},
},
},
{
name: "empty thinking block",
chunks: []string{"</think>", "\n", "Just content."},
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "",
expectedContent: "Just content.",
},
{
name: "tool call immediately after think close - no content",
chunks: []string{"Analyzing...", "</think>", "\n", "<tool_call>", "\n<function=test>\n</function>\n", "</tool_call>"},
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "Analyzing...",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "test",
Arguments: api.NewToolCallFunctionArguments(),
},
},
},
},
{
name: "leading open think tag split across chunks",
chunks: []string{"<th", "ink>", "\nThink first", "</think>", "\nDone."},
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "Think first",
expectedContent: "Done.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &Nemotron3NanoParser{}
p.Init(nil, nil, tt.thinkValue)
var allContent string
var allThinking string
var allCalls []api.ToolCall
for _, chunk := range tt.chunks {
content, thinking, calls, err := p.Add(chunk, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
allContent += content
allThinking += thinking
allCalls = append(allCalls, calls...)
}
// Drain
content, thinking, calls, err := p.Add("", true)
if err != nil {
t.Fatalf("unexpected error on done: %v", err)
}
allContent += content
allThinking += thinking
allCalls = append(allCalls, calls...)
if diff := cmp.Diff(allContent, tt.expectedContent); diff != "" {
t.Errorf("content mismatch (-got +want):\n%s", diff)
}
if diff := cmp.Diff(allThinking, tt.expectedThinking); diff != "" {
t.Errorf("thinking mismatch (-got +want):\n%s", diff)
}
if diff := cmp.Diff(allCalls, tt.expectedCalls, argsComparer); diff != "" {
t.Errorf("calls mismatch (-got +want):\n%s", diff)
}
})
}
}
func TestNemotron3NanoParser_HasToolSupport(t *testing.T) {
p := &Nemotron3NanoParser{}
if !p.HasToolSupport() {
t.Error("expected HasToolSupport to return true")
}
}
func TestNemotron3NanoParser_HasThinkingSupport(t *testing.T) {
p := &Nemotron3NanoParser{}
if !p.HasThinkingSupport() {
t.Error("expected HasThinkingSupport to return true")
}
}
func TestNemotron3NanoParser_Init(t *testing.T) {
t.Run("starts in thinking state when enabled", func(t *testing.T) {
p := &Nemotron3NanoParser{}
p.Init(nil, nil, &api.ThinkValue{Value: true})
if p.state != Nemotron3NanoCollectingThinking {
t.Errorf("expected state Nemotron3NanoCollectingThinking, got %v", p.state)
}
})
t.Run("starts in content state when thinking disabled", func(t *testing.T) {
p := &Nemotron3NanoParser{}
p.Init(nil, nil, &api.ThinkValue{Value: false})
if p.state != Nemotron3NanoCollectingContent {
t.Errorf("expected state Nemotron3NanoCollectingContent, got %v", p.state)
}
})
t.Run("starts in thinking state when nil thinkValue", func(t *testing.T) {
p := &Nemotron3NanoParser{}
p.Init(nil, nil, nil)
if p.state != Nemotron3NanoCollectingThinking {
t.Errorf("expected state Nemotron3NanoCollectingThinking, got %v", p.state)
}
})
t.Run("starts in content state with assistant prefill", func(t *testing.T) {
p := &Nemotron3NanoParser{}
prefill := &api.Message{Role: "assistant", Content: "Starting..."}
p.Init(nil, prefill, &api.ThinkValue{Value: true})
if p.state != Nemotron3NanoCollectingContent {
t.Errorf("expected state Nemotron3NanoCollectingContent, got %v", p.state)
}
})
t.Run("reinit clears buffered state", func(t *testing.T) {
p := &Nemotron3NanoParser{}
p.Init(nil, nil, &api.ThinkValue{Value: true})
if _, _, _, err := p.Add("thinking in progress", false); err != nil {
t.Fatalf("unexpected error: %v", err)
}
p.Init(nil, nil, &api.ThinkValue{Value: false})
content, thinking, calls, err := p.Add("content only", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "content only" {
t.Fatalf("expected content after reinit, got %q", content)
}
if thinking != "" {
t.Fatalf("expected no thinking after reinit, got %q", thinking)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls after reinit, got %v", calls)
}
})
}
func TestNemotron3NanoParser_WithTools(t *testing.T) {
tools := []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"city": {Type: api.PropertyType{"string"}},
}),
},
},
},
}
p := &Nemotron3NanoParser{}
returnedTools := p.Init(tools, nil, nil)
if diff := cmp.Diff(returnedTools, tools, toolsComparer); diff != "" {
t.Errorf("tools mismatch (-got +want):\n%s", diff)
}
// Parse a tool call
input := "<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>"
_, _, calls, err := p.Add(input, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expectedCalls := []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
},
}
if diff := cmp.Diff(calls, expectedCalls, argsComparer); diff != "" {
t.Errorf("calls mismatch (-got +want):\n%s", diff)
}
}
// TestNemotron3NanoParser_ToolCallWithoutThinkClose tests the case where thinking is enabled
// but the model outputs content + tool call WITHOUT the </think> tag.
// The parser should still parse the tool call (content before is treated as thinking).
func TestNemotron3NanoParser_ToolCallWithoutThinkClose(t *testing.T) {
chunks := []string{
"Let", " me", " analyze", " this", ".", "\n",
"<tool_call>", "\n",
"<function=get_weather>", "\n",
"<parameter=city>", "Paris", "</parameter>", "\n",
"</function>", "\n",
"</tool_call>",
}
p := &Nemotron3NanoParser{}
p.Init(nil, nil, &api.ThinkValue{Value: true}) // thinking ENABLED but model doesn't output </think>
var allContent string
var allThinking string
var allCalls []api.ToolCall
for _, chunk := range chunks {
content, thinking, calls, err := p.Add(chunk, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
allContent += content
allThinking += thinking
allCalls = append(allCalls, calls...)
}
// Drain
content, thinking, calls, err := p.Add("", true)
if err != nil {
t.Fatalf("unexpected error on done: %v", err)
}
allContent += content
allThinking += thinking
allCalls = append(allCalls, calls...)
// The parser was in thinking mode, so text before <tool_call> is emitted as thinking.
expectedThinking := "Let me analyze this."
expectedCalls := []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
},
}
if allContent != "" {
t.Errorf("expected no content (text was streamed as thinking), got: %q", allContent)
}
if diff := cmp.Diff(allThinking, expectedThinking); diff != "" {
t.Errorf("thinking mismatch (-got +want):\n%s", diff)
}
if diff := cmp.Diff(allCalls, expectedCalls, argsComparer); diff != "" {
t.Errorf("calls mismatch (-got +want):\n%s", diff)
}
}

472
model/parsers/olmo3.go Normal file
View File

@@ -0,0 +1,472 @@
package parsers
import (
"context"
"fmt"
"log/slog"
"regexp"
"strconv"
"strings"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/logutil"
)
type olmo3ParserState int
const (
olmo3StateContent olmo3ParserState = iota
olmo3StateToolCalls
olmo3StateToolCallsDone
)
const (
olmo3FuncCallsOpenTag = "<function_calls>"
olmo3FuncCallsCloseTag = "</function_calls>"
)
type Olmo3Parser struct {
state olmo3ParserState
buffer strings.Builder
callIndex int
}
func (p *Olmo3Parser) HasToolSupport() bool {
return true
}
func (p *Olmo3Parser) HasThinkingSupport() bool {
return false
}
func (p *Olmo3Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.state = olmo3StateContent
p.callIndex = 0
return tools
}
type olmo3ParserEvent interface {
isOlmo3ParserEvent()
}
type olmo3ParserEventContent struct {
content string
}
type olmo3ParserEventToolCalls struct {
calls []api.ToolCall
}
func (olmo3ParserEventContent) isOlmo3ParserEvent() {}
func (olmo3ParserEventToolCalls) isOlmo3ParserEvent() {}
func (p *Olmo3Parser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
if done {
// Drain any remaining content
bufStr := p.buffer.String()
p.buffer.Reset()
if p.state == olmo3StateContent && len(bufStr) > 0 {
return bufStr, "", nil, nil
}
return "", "", nil, nil
}
events := p.parseEvents()
var contentSb strings.Builder
var allCalls []api.ToolCall
for _, event := range events {
switch event := event.(type) {
case olmo3ParserEventContent:
contentSb.WriteString(event.content)
case olmo3ParserEventToolCalls:
allCalls = append(allCalls, event.calls...)
}
}
for i := range allCalls {
allCalls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentSb.String(), "", allCalls, nil
}
func (p *Olmo3Parser) parseEvents() []olmo3ParserEvent {
var all []olmo3ParserEvent
keepLooping := true
for keepLooping {
var events []olmo3ParserEvent
events, keepLooping = p.eat()
if len(events) > 0 {
all = append(all, events...)
}
}
if len(all) > 0 {
slog.Log(context.TODO(), logutil.LevelTrace, "olmo3 events parsed", "events", all, "state", p.state, "buffer", p.buffer.String())
}
return all
}
func (p *Olmo3Parser) eat() ([]olmo3ParserEvent, bool) {
var events []olmo3ParserEvent
bufStr := p.buffer.String()
if bufStr == "" {
return events, false
}
switch p.state {
case olmo3StateContent:
if strings.Contains(bufStr, olmo3FuncCallsOpenTag) {
// Found <function_calls> tag
split := strings.SplitN(bufStr, olmo3FuncCallsOpenTag, 2)
content := split[0]
remaining := split[1]
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = olmo3StateToolCalls
if len(content) > 0 {
events = append(events, olmo3ParserEventContent{content: content})
}
return events, true
} else if overlapLen := overlap(bufStr, olmo3FuncCallsOpenTag); overlapLen > 0 {
// Partial <function_calls> tag - withhold ambiguous content
unambiguous := bufStr[:len(bufStr)-overlapLen]
ambiguous := bufStr[len(bufStr)-overlapLen:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, olmo3ParserEventContent{content: unambiguous})
}
return events, false
} else {
// Regular content - emit all
p.buffer.Reset()
if len(bufStr) > 0 {
events = append(events, olmo3ParserEventContent{content: bufStr})
}
return events, false
}
case olmo3StateToolCalls:
if strings.Contains(bufStr, olmo3FuncCallsCloseTag) {
// Found </function_calls> tag
split := strings.SplitN(bufStr, olmo3FuncCallsCloseTag, 2)
toolCallsStr := split[0]
remaining := split[1]
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = olmo3StateToolCallsDone
// Parse the function calls
calls, err := parseOlmo3FunctionCalls(toolCallsStr)
if err != nil {
slog.Log(context.TODO(), logutil.LevelTrace, "failed to parse olmo3 function calls", "error", err, "content", toolCallsStr)
} else if len(calls) > 0 {
events = append(events, olmo3ParserEventToolCalls{calls: calls})
}
return events, true
} else if overlapLen := overlap(bufStr, olmo3FuncCallsCloseTag); overlapLen > 0 {
// Partial </function_calls> tag - wait for more
return events, false
}
// Still collecting tool calls, wait for close tag
return events, false
case olmo3StateToolCallsDone:
// After tool calls, emit remaining content
p.buffer.Reset()
p.state = olmo3StateContent
if len(bufStr) > 0 {
events = append(events, olmo3ParserEventContent{content: bufStr})
}
return events, false
}
return events, false
}
// parseOlmo3FunctionCalls parses function calls in Python-esque format:
// func_name(arg1="value1", arg2=123)
// Multiple calls are separated by newlines
func parseOlmo3FunctionCalls(s string) ([]api.ToolCall, error) {
var calls []api.ToolCall
s = strings.TrimSpace(s)
if s == "" {
return calls, nil
}
// Split by newlines for multiple function calls
lines := strings.Split(s, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
call, err := parseOlmo3SingleFunctionCall(line)
if err != nil {
return nil, fmt.Errorf("failed to parse function call %q: %w", line, err)
}
calls = append(calls, call)
}
return calls, nil
}
// Regex to match function call: func_name(args)
var funcCallRegex = regexp.MustCompile(`^(\w+)\((.*)\)$`)
func parseOlmo3SingleFunctionCall(s string) (api.ToolCall, error) {
matches := funcCallRegex.FindStringSubmatch(s)
if matches == nil {
return api.ToolCall{}, fmt.Errorf("invalid function call format")
}
funcName := matches[1]
argsStr := matches[2]
args, err := parseOlmo3Arguments(argsStr)
if err != nil {
return api.ToolCall{}, fmt.Errorf("failed to parse arguments: %w", err)
}
return api.ToolCall{
Function: api.ToolCallFunction{
Name: funcName,
Arguments: args,
},
}, nil
}
// parseOlmo3Arguments parses comma-separated key=value pairs
// Handles nested parentheses, brackets, braces, and quoted strings
func parseOlmo3Arguments(s string) (api.ToolCallFunctionArguments, error) {
args := api.NewToolCallFunctionArguments()
s = strings.TrimSpace(s)
if s == "" {
return args, nil
}
// Split by commas, but respect nested structures and quotes
parts := splitArguments(s)
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Find the first = sign
eqIdx := strings.Index(part, "=")
if eqIdx == -1 {
return api.ToolCallFunctionArguments{}, fmt.Errorf("invalid argument format: %s", part)
}
key := strings.TrimSpace(part[:eqIdx])
valueStr := strings.TrimSpace(part[eqIdx+1:])
value, err := parseOlmo3Value(valueStr)
if err != nil {
return api.ToolCallFunctionArguments{}, fmt.Errorf("failed to parse value for %s: %w", key, err)
}
args.Set(key, value)
}
return args, nil
}
// splitArguments splits arguments by commas, respecting quotes and nested structures
func splitArguments(s string) []string {
var parts []string
var current strings.Builder
depth := 0
inString := false
stringChar := byte(0)
escaped := false
for i := range s {
c := s[i]
if escaped {
current.WriteByte(c)
escaped = false
continue
}
if c == '\\' && inString {
current.WriteByte(c)
escaped = true
continue
}
if (c == '"' || c == '\'') && !inString {
inString = true
stringChar = c
current.WriteByte(c)
continue
}
if c == stringChar && inString {
inString = false
stringChar = 0
current.WriteByte(c)
continue
}
if !inString {
switch c {
case '(', '[', '{':
depth++
current.WriteByte(c)
case ')', ']', '}':
depth--
current.WriteByte(c)
case ',':
if depth == 0 {
parts = append(parts, current.String())
current.Reset()
continue
}
current.WriteByte(c)
default:
current.WriteByte(c)
}
} else {
current.WriteByte(c)
}
}
if current.Len() > 0 {
parts = append(parts, current.String())
}
return parts
}
// parseOlmo3Value parses a value which can be a string, number, boolean, null, array, or object
func parseOlmo3Value(s string) (any, error) {
s = strings.TrimSpace(s)
// Check for quoted string
if (strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`)) ||
(strings.HasPrefix(s, `'`) && strings.HasSuffix(s, `'`)) {
// Remove quotes and unescape
inner := s[1 : len(s)-1]
return unescapeString(inner), nil
}
// Check for boolean
if s == "true" || s == "True" {
return true, nil
}
if s == "false" || s == "False" {
return false, nil
}
// Check for null/None
if s == "null" || s == "None" || s == "nil" {
return nil, nil
}
// Check for number
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
return i, nil
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
return f, nil
}
// Check for array [...]
if strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]") {
return parseOlmo3Array(s[1 : len(s)-1])
}
// Check for object {...}
if strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}") {
return parseOlmo3Object(s[1 : len(s)-1])
}
// Default to string without quotes
return s, nil
}
func parseOlmo3Array(s string) ([]any, error) {
s = strings.TrimSpace(s)
if s == "" {
return []any{}, nil
}
parts := splitArguments(s)
var arr []any
for _, part := range parts {
val, err := parseOlmo3Value(part)
if err != nil {
return nil, err
}
arr = append(arr, val)
}
return arr, nil
}
func parseOlmo3Object(s string) (map[string]any, error) {
s = strings.TrimSpace(s)
if s == "" {
return map[string]any{}, nil
}
// Objects use key: value or "key": value format
obj := make(map[string]any)
parts := splitArguments(s)
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Find colon separator
colonIdx := strings.Index(part, ":")
if colonIdx == -1 {
return nil, fmt.Errorf("invalid object entry: %s", part)
}
keyStr := strings.TrimSpace(part[:colonIdx])
valueStr := strings.TrimSpace(part[colonIdx+1:])
// Remove quotes from key if present
if (strings.HasPrefix(keyStr, `"`) && strings.HasSuffix(keyStr, `"`)) ||
(strings.HasPrefix(keyStr, `'`) && strings.HasSuffix(keyStr, `'`)) {
keyStr = keyStr[1 : len(keyStr)-1]
}
val, err := parseOlmo3Value(valueStr)
if err != nil {
return nil, fmt.Errorf("failed to parse value for key %s: %w", keyStr, err)
}
obj[keyStr] = val
}
return obj, nil
}
func unescapeString(s string) string {
// Handle common escape sequences
s = strings.ReplaceAll(s, `\\`, "\x00") // Placeholder for backslash
s = strings.ReplaceAll(s, `\"`, `"`)
s = strings.ReplaceAll(s, `\'`, `'`)
s = strings.ReplaceAll(s, `\n`, "\n")
s = strings.ReplaceAll(s, `\t`, "\t")
s = strings.ReplaceAll(s, `\r`, "\r")
s = strings.ReplaceAll(s, "\x00", `\`) // Restore backslash
return s
}

485
model/parsers/olmo3_test.go Normal file
View File

@@ -0,0 +1,485 @@
package parsers
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
)
func TestOlmo3Parser(t *testing.T) {
tests := []struct {
name string
input string
expectedContent string
expectedThinking string
expectedCalls []api.ToolCall
}{
{
name: "simple content",
input: "Hello, how can I help you?",
expectedContent: "Hello, how can I help you?",
},
{
name: "simple tool call",
input: `<function_calls>get_weather(location="San Francisco")</function_calls>`,
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"location": "San Francisco"}),
},
},
},
},
{
name: "content then tool call",
input: `Let me check the weather.<function_calls>get_weather(location="NYC")</function_calls>`,
expectedContent: "Let me check the weather.",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"location": "NYC"}),
},
},
},
},
{
name: "tool call with multiple arguments",
input: `<function_calls>book_flight(from="SFO", to="NYC", date="2024-01-15")</function_calls>`,
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "book_flight",
Arguments: testArgs(map[string]any{
"from": "SFO",
"to": "NYC",
"date": "2024-01-15",
}),
},
},
},
},
{
name: "multiple tool calls",
input: `<function_calls>get_weather(location="San Francisco")
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"}),
},
},
},
},
{
name: "tool call with numeric argument",
input: `<function_calls>set_temperature(value=72)</function_calls>`,
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "set_temperature",
Arguments: testArgs(map[string]any{"value": int64(72)}),
},
},
},
},
{
name: "tool call with float argument",
input: `<function_calls>set_price(amount=19.99)</function_calls>`,
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "set_price",
Arguments: testArgs(map[string]any{"amount": 19.99}),
},
},
},
},
{
name: "tool call with boolean argument",
input: `<function_calls>toggle_setting(enabled=true)</function_calls>`,
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "toggle_setting",
Arguments: testArgs(map[string]any{"enabled": true}),
},
},
},
},
{
name: "tool call with null argument",
input: `<function_calls>clear_value(field=null)</function_calls>`,
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "clear_value",
Arguments: testArgs(map[string]any{"field": nil}),
},
},
},
},
{
name: "tool call with array argument",
input: `<function_calls>process_items(items=["apple", "banana", "cherry"])</function_calls>`,
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "process_items",
Arguments: testArgs(map[string]any{"items": []any{"apple", "banana", "cherry"}}),
},
},
},
},
{
name: "tool call with dict argument",
input: `<function_calls>update_config(settings={"theme": "dark", "fontSize": 14})</function_calls>`,
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "update_config",
Arguments: testArgs(map[string]any{
"settings": map[string]any{
"theme": "dark",
"fontSize": int64(14),
},
}),
},
},
},
},
{
name: "tool call with nested dict",
input: `<function_calls>create_request(data={"user": {"name": "John", "age": 30}, "active": true})</function_calls>`,
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "create_request",
Arguments: testArgs(map[string]any{
"data": map[string]any{
"user": map[string]any{
"name": "John",
"age": int64(30),
},
"active": true,
},
}),
},
},
},
},
{
name: "tool call with no arguments",
input: `<function_calls>get_current_time()</function_calls>`,
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_current_time",
Arguments: testArgs(map[string]any{}),
},
},
},
},
{
name: "tool call with single quotes",
input: `<function_calls>search(query='hello world')</function_calls>`,
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "search",
Arguments: testArgs(map[string]any{"query": "hello world"}),
},
},
},
},
{
name: "tool call with escaped quotes",
input: `<function_calls>search(query="say \"hello\"")</function_calls>`,
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "search",
Arguments: testArgs(map[string]any{"query": `say "hello"`}),
},
},
},
},
{
name: "tool call with mixed argument types",
input: `<function_calls>create_user(name="John", age=30, active=true)</function_calls>`,
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "create_user",
Arguments: testArgs(map[string]any{
"name": "John",
"age": int64(30),
"active": true,
}),
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &Olmo3Parser{}
p.Init(nil, nil, nil)
content, thinking, calls, err := p.Add(tt.input, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Drain remaining content
finalContent, finalThinking, finalCalls, err := p.Add("", true)
if err != nil {
t.Fatalf("unexpected error on done: %v", err)
}
content += finalContent
thinking += finalThinking
calls = append(calls, finalCalls...)
if diff := cmp.Diff(content, tt.expectedContent); diff != "" {
t.Errorf("content mismatch (-got +want):\n%s", diff)
}
if diff := cmp.Diff(thinking, tt.expectedThinking); diff != "" {
t.Errorf("thinking mismatch (-got +want):\n%s", diff)
}
if diff := cmp.Diff(calls, tt.expectedCalls, argsComparer); diff != "" {
t.Errorf("calls mismatch (-got +want):\n%s", diff)
}
})
}
}
func TestOlmo3Parser_Streaming(t *testing.T) {
tests := []struct {
name string
chunks []string
expectedContent string
expectedCalls []api.ToolCall
}{
{
name: "streaming content",
chunks: []string{"Hello, ", "how ", "can I help?"},
expectedContent: "Hello, how can I help?",
},
{
name: "streaming tool call",
chunks: []string{"<function_", "calls>get_weather", "(location=\"SF\")", "</function_calls>"},
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"location": "SF"}),
},
},
},
},
{
name: "streaming content then tool call",
chunks: []string{"Let me check.", "<function_calls>", "get_weather(location=\"NYC\")", "</function_calls>"},
expectedContent: "Let me check.",
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"location": "NYC"}),
},
},
},
},
{
name: "tool call tag split across chunks",
chunks: []string{"<func", "tion_calls>test()</function_calls>"},
expectedCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "test",
Arguments: testArgs(map[string]any{}),
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &Olmo3Parser{}
p.Init(nil, nil, nil)
var allContent string
var allCalls []api.ToolCall
for _, chunk := range tt.chunks {
content, _, calls, err := p.Add(chunk, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
allContent += content
allCalls = append(allCalls, calls...)
}
// Drain
content, _, calls, err := p.Add("", true)
if err != nil {
t.Fatalf("unexpected error on done: %v", err)
}
allContent += content
allCalls = append(allCalls, calls...)
if diff := cmp.Diff(allContent, tt.expectedContent); diff != "" {
t.Errorf("content mismatch (-got +want):\n%s", diff)
}
if diff := cmp.Diff(allCalls, tt.expectedCalls, argsComparer); diff != "" {
t.Errorf("calls mismatch (-got +want):\n%s", diff)
}
})
}
}
func TestOlmo3Parser_HasToolSupport(t *testing.T) {
p := &Olmo3Parser{}
if !p.HasToolSupport() {
t.Error("expected HasToolSupport to return true")
}
}
func TestOlmo3Parser_HasThinkingSupport(t *testing.T) {
p := &Olmo3Parser{}
if p.HasThinkingSupport() {
t.Error("expected HasThinkingSupport to return false")
}
}
func TestParseOlmo3FunctionCalls(t *testing.T) {
tests := []struct {
name string
input string
expected []api.ToolCall
wantErr bool
}{
{
name: "simple call",
input: `get_weather(location="SF")`,
expected: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"location": "SF"}),
},
},
},
},
{
name: "multiple args",
input: `send_email(to="user@example.com", subject="Hello", body="Test message")`,
expected: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "send_email",
Arguments: testArgs(map[string]any{
"to": "user@example.com",
"subject": "Hello",
"body": "Test message",
}),
},
},
},
},
{
name: "multiple calls with newlines",
input: `get_weather(location="SF")
get_time(timezone="PST")`,
expected: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"location": "SF"}),
},
},
{
Function: api.ToolCallFunction{
Name: "get_time",
Arguments: testArgs(map[string]any{"timezone": "PST"}),
},
},
},
},
{
name: "empty input",
input: "",
expected: nil,
},
{
name: "whitespace only",
input: " \n ",
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
calls, err := parseOlmo3FunctionCalls(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("parseOlmo3FunctionCalls() error = %v, wantErr %v", err, tt.wantErr)
return
}
if diff := cmp.Diff(calls, tt.expected, argsComparer); diff != "" {
t.Errorf("calls mismatch (-got +want):\n%s", diff)
}
})
}
}
func TestParseOlmo3Value(t *testing.T) {
tests := []struct {
name string
input string
expected any
}{
{"string double quotes", `"hello"`, "hello"},
{"string single quotes", `'hello'`, "hello"},
{"integer", "42", int64(42)},
{"negative integer", "-10", int64(-10)},
{"float", "3.14", 3.14},
{"boolean true", "true", true},
{"boolean True", "True", true},
{"boolean false", "false", false},
{"null", "null", nil},
{"None", "None", nil},
{"empty array", "[]", []any{}},
{"array with strings", `["a", "b"]`, []any{"a", "b"}},
{"array with numbers", "[1, 2, 3]", []any{int64(1), int64(2), int64(3)}},
{"empty object", "{}", map[string]any{}},
{"simple object", `{"name": "John"}`, map[string]any{"name": "John"}},
{"object with number", `{"age": 30}`, map[string]any{"age": int64(30)}},
{"object with multiple keys", `{"a": 1, "b": 2}`, map[string]any{"a": int64(1), "b": int64(2)}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseOlmo3Value(tt.input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if diff := cmp.Diff(result, tt.expected); diff != "" {
t.Errorf("value mismatch (-got +want):\n%s", diff)
}
})
}
}

View File

@@ -0,0 +1,170 @@
package parsers
import (
"context"
"log/slog"
"strings"
"unicode"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/logutil"
)
type olmo3ThinkParserState int
const (
olmo3CollectingThink olmo3ThinkParserState = iota
olmo3CollectingContent
)
const (
olmo3ThinkCloseTag = "</think>"
)
type Olmo3ThinkParser struct {
state olmo3ThinkParserState
buffer strings.Builder
}
func (p *Olmo3ThinkParser) HasToolSupport() bool {
return false
}
func (p *Olmo3ThinkParser) HasThinkingSupport() bool {
return true
}
func (p *Olmo3ThinkParser) setInitialState(lastMessage *api.Message) {
prefill := lastMessage != nil && lastMessage.Role == "assistant"
// If prefilling with content, skip to content collection
if prefill && lastMessage.Content != "" {
p.state = olmo3CollectingContent
return
}
// Model always thinks first (the <think> tag is injected in the prompt)
p.state = olmo3CollectingThink
}
func (p *Olmo3ThinkParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.setInitialState(lastMessage)
return tools
}
// Event types for internal parser communication
type olmo3Event interface {
isOlmo3Event()
}
type olmo3EventThinkContent struct {
content string
}
type olmo3EventContent struct {
content string
}
func (olmo3EventThinkContent) isOlmo3Event() {}
func (olmo3EventContent) isOlmo3Event() {}
func (p *Olmo3ThinkParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
events := p.parseEvents()
var contentSb strings.Builder
var thinkingSb strings.Builder
for _, event := range events {
switch event := event.(type) {
case olmo3EventThinkContent:
thinkingSb.WriteString(event.content)
case olmo3EventContent:
contentSb.WriteString(event.content)
}
}
return contentSb.String(), thinkingSb.String(), nil, nil
}
func (p *Olmo3ThinkParser) parseEvents() []olmo3Event {
var all []olmo3Event
keepLooping := true
for keepLooping {
var events []olmo3Event
events, keepLooping = p.eat()
if len(events) > 0 {
all = append(all, events...)
}
}
if len(all) > 0 {
slog.Log(context.TODO(), logutil.LevelTrace, "olmo3 events parsed", "events", all, "state", p.state, "buffer", p.buffer.String())
}
return all
}
func (p *Olmo3ThinkParser) eat() ([]olmo3Event, bool) {
var events []olmo3Event
bufStr := p.buffer.String()
if bufStr == "" {
return events, false
}
switch p.state {
case olmo3CollectingThink:
if strings.Contains(bufStr, olmo3ThinkCloseTag) {
// Found complete </think> tag
split := strings.SplitN(bufStr, olmo3ThinkCloseTag, 2)
thinking := strings.TrimRightFunc(split[0], unicode.IsSpace)
remaining := strings.TrimLeftFunc(split[1], unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = olmo3CollectingContent
if len(thinking) > 0 {
events = append(events, olmo3EventThinkContent{content: thinking})
}
return events, true
} else if overlapLen := overlap(bufStr, olmo3ThinkCloseTag); overlapLen > 0 {
// Partial </think> tag - withhold ambiguous content
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, olmo3EventThinkContent{content: unambiguous})
}
return events, false
} else {
// Regular thinking content - withhold trailing whitespace in case </think> follows
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, olmo3EventThinkContent{content: unambiguous})
}
return events, false
}
case olmo3CollectingContent:
// Emit all content directly
p.buffer.Reset()
if len(bufStr) > 0 {
events = append(events, olmo3EventContent{content: bufStr})
}
return events, false
}
return events, false
}

View File

@@ -0,0 +1,390 @@
package parsers
import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
)
func TestOlmo3ThinkParser(t *testing.T) {
tests := []struct {
name string
input string
expectedContent string
expectedThinking string
lastMessage *api.Message
}{
{
name: "thinking_only",
input: "I need to think about this.</think>Here is my response.",
expectedContent: "Here is my response.",
expectedThinking: "I need to think about this.",
},
{
name: "thinking_with_newlines",
input: "Let me think step by step.\n\n1. First point\n2. Second point</think>The answer is 42.",
expectedContent: "The answer is 42.",
expectedThinking: "Let me think step by step.\n\n1. First point\n2. Second point",
},
{
name: "thinking_then_content",
input: "Deep thinking here.</think>Here is my detailed response with multiple sentences. I have thought carefully.",
expectedContent: "Here is my detailed response with multiple sentences. I have thought carefully.",
expectedThinking: "Deep thinking here.",
},
{
name: "empty_thinking",
input: "</think>Just content here.",
expectedContent: "Just content here.",
expectedThinking: "",
},
{
name: "prefill_skips_thinking",
input: "Continuing from previous content.",
expectedContent: "Continuing from previous content.",
lastMessage: &api.Message{
Role: "assistant",
Content: "Previous content",
},
},
{
name: "thinking_with_whitespace",
input: " Some thinking </think> Content here ",
expectedContent: "Content here ",
expectedThinking: " Some thinking",
},
{
name: "real_model_output_with_newlines",
input: "Yes, that should work. Let me go with that response.\n\n</think>\n\nHi! I'm all set and ready to assist. How about you? How are you today? 😊",
expectedThinking: "Yes, that should work. Let me go with that response.",
expectedContent: "Hi! I'm all set and ready to assist. How about you? How are you today? 😊",
},
// Edge cases
{
name: "nested_think_tags_in_thinking",
input: "I'm thinking <think>nested</think> more thinking</think>Final content.",
expectedContent: "more thinking</think>Final content.",
expectedThinking: "I'm thinking <think>nested",
},
{
name: "multiple_think_close_tags",
input: "First thinking</think>Content</think>More content.",
expectedContent: "Content</think>More content.",
expectedThinking: "First thinking",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &Olmo3ThinkParser{}
parser.Init(nil, tt.lastMessage, nil)
content, thinking, toolCalls, err := parser.Add(tt.input, true)
if err != nil {
t.Fatalf("Add() error = %v", err)
}
if diff := cmp.Diff(tt.expectedContent, content); diff != "" {
t.Errorf("content mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.expectedThinking, thinking); diff != "" {
t.Errorf("thinking mismatch (-want +got):\n%s", diff)
}
// No tool calls expected
if len(toolCalls) > 0 {
t.Errorf("expected no tool calls, got %d", len(toolCalls))
}
})
}
}
func TestOlmo3ThinkParser_Streaming(t *testing.T) {
parser := &Olmo3ThinkParser{}
parser.Init(nil, nil, nil)
chunks := []string{
"I am ",
"thinking about",
" this.</think>Here ",
"is the response.",
}
var finalContent, finalThinking strings.Builder
for i, chunk := range chunks {
done := i == len(chunks)-1
content, thinking, _, err := parser.Add(chunk, done)
if err != nil {
t.Fatalf("Add() error on chunk %d: %v", i, err)
}
finalContent.WriteString(content)
finalThinking.WriteString(thinking)
}
expectedContent := "Here is the response."
expectedThinking := "I am thinking about this."
if finalContent.String() != expectedContent {
t.Errorf("expected content %q, got %q", expectedContent, finalContent.String())
}
if finalThinking.String() != expectedThinking {
t.Errorf("expected thinking %q, got %q", expectedThinking, finalThinking.String())
}
}
func TestOlmo3ThinkParser_StreamingEdgeCases(t *testing.T) {
tests := []struct {
name string
chunks []string
expectedContent string
expectedThinking string
}{
{
name: "thinking_tag_split_across_chunks",
chunks: []string{
"This is thinking content",
"</think>",
"This is content.",
},
expectedContent: "This is content.",
expectedThinking: "This is thinking content",
},
{
name: "thinking_tag_split_mid_token",
chunks: []string{
"Thinking?</",
"think>",
"Content here.",
},
expectedContent: "Content here.",
expectedThinking: "Thinking?",
},
{
name: "thinking_tag_split_at_angle_bracket",
chunks: []string{
"Thinking<",
"/think>",
"Content.",
},
expectedContent: "Content.",
expectedThinking: "Thinking",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &Olmo3ThinkParser{}
parser.Init(nil, nil, nil)
var finalContent, finalThinking strings.Builder
for i, chunk := range tt.chunks {
done := i == len(tt.chunks)-1
content, thinking, _, err := parser.Add(chunk, done)
if err != nil {
t.Fatalf("Add() error on chunk %d: %v", i, err)
}
finalContent.WriteString(content)
finalThinking.WriteString(thinking)
}
if finalContent.String() != tt.expectedContent {
t.Errorf("expected content %q, got %q", tt.expectedContent, finalContent.String())
}
if finalThinking.String() != tt.expectedThinking {
t.Errorf("expected thinking %q, got %q", tt.expectedThinking, finalThinking.String())
}
})
}
}
// TestOlmo3ThinkParser_ThinkBoundary tests streaming thinking content
// where thinking chunks come in succession before the </think> tag
func TestOlmo3ThinkParser_ThinkBoundary(t *testing.T) {
tests := []struct {
name string
chunks []string
expectedThinking string
expectedContent string
}{
{
name: "multiple_thinking_chunks",
chunks: []string{
"First part of thinking. ",
"Second part of thinking. ",
"Third part.</think>",
"Content here.",
},
expectedThinking: "First part of thinking. Second part of thinking. Third part.",
expectedContent: "Content here.",
},
{
name: "thinking_chunks_with_newlines",
chunks: []string{
"Step 1: Analyze the problem.\n",
"Step 2: Consider options.\n",
"Step 3: Make decision.</think>",
"Here is my answer.",
},
expectedThinking: "Step 1: Analyze the problem.\nStep 2: Consider options.\nStep 3: Make decision.",
expectedContent: "Here is my answer.",
},
{
name: "single_char_thinking_chunks",
chunks: []string{
"H", "e", "l", "l", "o", "</think>", "World",
},
expectedThinking: "Hello",
expectedContent: "World",
},
{
name: "thinking_with_special_chars",
chunks: []string{
"Let me think... ",
"Option A: $100 ",
"Option B: €200</think>",
"I recommend Option A.",
},
expectedThinking: "Let me think... Option A: $100 Option B: €200",
expectedContent: "I recommend Option A.",
},
{
name: "long_thinking_multiple_chunks",
chunks: []string{
"This is a very long thinking process. ",
"I need to consider many factors. ",
"First, let me look at the data. ",
"The numbers show interesting patterns. ",
"Based on my analysis, ",
"I can conclude that...</think>",
"The answer is 42.",
},
expectedThinking: "This is a very long thinking process. I need to consider many factors. First, let me look at the data. The numbers show interesting patterns. Based on my analysis, I can conclude that...",
expectedContent: "The answer is 42.",
},
{
name: "thinking_ends_exactly_at_chunk_boundary",
chunks: []string{
"Thinking content",
"</think>",
"Content",
},
expectedThinking: "Thinking content",
expectedContent: "Content",
},
{
name: "empty_chunks_between_thinking",
chunks: []string{
"Start thinking",
"",
" middle ",
"",
"end</think>",
"Content",
},
expectedThinking: "Start thinking middle end",
expectedContent: "Content",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &Olmo3ThinkParser{}
parser.Init(nil, nil, nil)
var finalContent, finalThinking strings.Builder
for i, chunk := range tt.chunks {
done := i == len(tt.chunks)-1
content, thinking, _, err := parser.Add(chunk, done)
if err != nil {
t.Fatalf("Add() error on chunk %d: %v", i, err)
}
finalContent.WriteString(content)
finalThinking.WriteString(thinking)
}
if finalThinking.String() != tt.expectedThinking {
t.Errorf("thinking mismatch:\nexpected: %q\ngot: %q", tt.expectedThinking, finalThinking.String())
}
if finalContent.String() != tt.expectedContent {
t.Errorf("content mismatch:\nexpected: %q\ngot: %q", tt.expectedContent, finalContent.String())
}
})
}
}
// TestOlmo3ThinkParser_StateTransitions tests that state transitions work correctly
func TestOlmo3ThinkParser_StateTransitions(t *testing.T) {
t.Run("thinking_to_content", func(t *testing.T) {
parser := &Olmo3ThinkParser{}
parser.Init(nil, nil, nil)
if parser.state != olmo3CollectingThink {
t.Errorf("initial state should be olmo3CollectingThink, got %v", parser.state)
}
parser.Add("thinking</think>content", true)
if parser.state != olmo3CollectingContent {
t.Errorf("state after </think> should be olmo3CollectingContent, got %v", parser.state)
}
})
}
func TestOlmo3ThinkParser_HasToolSupport(t *testing.T) {
parser := &Olmo3ThinkParser{}
if parser.HasToolSupport() {
t.Error("Olmo3ThinkParser should NOT support tools")
}
}
func TestOlmo3ThinkParser_HasThinkingSupport(t *testing.T) {
parser := &Olmo3ThinkParser{}
if !parser.HasThinkingSupport() {
t.Error("Olmo3ThinkParser should support thinking")
}
}
func TestOlmo3ThinkParser_Init(t *testing.T) {
parser := &Olmo3ThinkParser{}
tools := []api.Tool{
{Function: api.ToolFunction{Name: "test_tool"}},
}
lastMessage := &api.Message{Role: "assistant", Content: "previous"}
returnedTools := parser.Init(tools, lastMessage, nil)
if len(returnedTools) != len(tools) {
t.Errorf("expected %d tools returned, got %d", len(tools), len(returnedTools))
}
// Should be in content collection mode due to prefill
if parser.state != olmo3CollectingContent {
t.Errorf("expected state olmo3CollectingContent, got %v", parser.state)
}
}
func TestOlmo3ThinkParser_InitWithoutPrefill(t *testing.T) {
parser := &Olmo3ThinkParser{}
parser.Init(nil, nil, nil)
// Should be in thinking collection mode (model always thinks first)
if parser.state != olmo3CollectingThink {
t.Errorf("expected state olmo3CollectingThink, got %v", parser.state)
}
}

161
model/parsers/parsers.go Normal file
View File

@@ -0,0 +1,161 @@
package parsers
import (
"strings"
"unicode"
"unicode/utf8"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/harmony"
)
type Parser interface {
// Init initializes the parser with tools, optional last message for chat prefill, and think value
// Returns processed tools if the parser needs to modify them (e.g., harmony renames them)
Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool
// Add processes streamed content and returns parsed content, thinking, and tool calls
// The done flag indicates if this is the last chunk (used for draining accumulators)
Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error)
HasToolSupport() bool
HasThinkingSupport() bool
}
type ParserConstructor func() Parser
type ParserRegistry struct {
constructors map[string]ParserConstructor
}
func (r *ParserRegistry) Register(name string, constructor ParserConstructor) {
r.constructors[name] = constructor
}
var registry = ParserRegistry{
constructors: make(map[string]ParserConstructor),
}
func Register(name string, constructor ParserConstructor) {
registry.Register(name, constructor)
}
func ParserForName(name string) Parser {
if parser, ok := registry.constructors[name]; ok {
return parser()
}
var p Parser
switch name {
case "qwen3":
p = &Qwen3Parser{hasThinkingSupport: false, defaultThinking: false}
case "qwen3-thinking":
p = &Qwen3Parser{hasThinkingSupport: true, defaultThinking: true}
case "qwen3.5":
p = &Qwen35Parser{}
case "qwen3-coder":
p = &Qwen3CoderParser{}
case "qwen3-vl-instruct":
p = &Qwen3VLParser{hasThinkingSupport: false}
case "qwen3-vl-thinking":
p = &Qwen3VLParser{hasThinkingSupport: true}
case "ministral":
p = &MinistralParser{hasThinkingSupport: false}
case "passthrough":
return &PassthroughParser{}
case "harmony":
return harmony.NewHarmonyMessageHandler()
case "cogito":
return &CogitoParser{}
case "deepseek3":
return &DeepSeek3Parser{hasThinkingSupport: true}
case "olmo3":
return &Olmo3Parser{}
case "olmo3-think":
return &Olmo3ThinkParser{}
case "nemotron-3-nano":
return &Nemotron3NanoParser{}
case "functiongemma":
return &FunctionGemmaParser{}
case "glm-4.7":
return &GLM47Parser{}
case "gemma4":
return &Gemma4Parser{hasThinkingSupport: true}
case "gemma4-no-thinking":
return &Gemma4Parser{hasThinkingSupport: false}
case "glm-ocr":
return &GlmOcrParser{}
case "lfm2":
return &LFM2Parser{hasThinkingSupport: false}
case "lfm2-thinking":
return &LFM2Parser{hasThinkingSupport: true}
case "laguna":
return &LagunaParser{}
default:
return nil
}
return p
}
type PassthroughParser struct{}
func (p *PassthroughParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
return tools // passthrough doesn't modify tools
}
func (p *PassthroughParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
return s, "", nil, nil
}
func (p *PassthroughParser) HasToolSupport() bool {
return false
}
func (p *PassthroughParser) HasThinkingSupport() bool {
return false
}
func splitAtTag(sb *strings.Builder, tag string, trimAfter bool) (string, string) {
split := strings.SplitN(sb.String(), tag, 2)
if len(split) == 1 {
sb.Reset()
return split[0], ""
}
before := split[0]
before = strings.TrimRightFunc(before, unicode.IsSpace)
after := split[1]
if trimAfter {
after = strings.TrimLeftFunc(after, unicode.IsSpace)
}
sb.Reset()
sb.WriteString(after)
return before, after // return events
}
// overlap returns the longest overlap between the suffix of s and the prefix of delim
func overlap(s, delim string) int {
max := min(len(delim), len(s))
for i := max; i > 0; i-- {
if strings.HasSuffix(s, delim[:i]) {
return i
}
}
return 0
}
// trailingWhitespaceLen returns the length in bytes of trailing whitespace in s
func trailingWhitespaceLen(s string) int {
remaining := s
total := 0
for len(remaining) > 0 {
r, size := utf8.DecodeLastRuneInString(remaining)
// if it's an invalid utf8 rune, assume it isn't whitespace
if r == utf8.RuneError && size == 1 {
break
}
if !unicode.IsSpace(r) {
break
}
total += size
remaining = remaining[:len(remaining)-size]
}
return total
}

View File

@@ -0,0 +1,264 @@
package parsers
import (
"strings"
"testing"
"github.com/ollama/ollama/api"
)
type mockParser struct {
name string
}
func (m *mockParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
return tools
}
func (m *mockParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
return "mock:" + s, "", nil, nil
}
func (m *mockParser) HasToolSupport() bool {
return false
}
func (m *mockParser) HasThinkingSupport() bool {
return false
}
func TestRegisterCustomParser(t *testing.T) {
// Register a custom parser
Register("custom-parser", func() Parser {
return &mockParser{name: "custom"}
})
// Retrieve it
parser := ParserForName("custom-parser")
if parser == nil {
t.Fatal("expected parser to be registered")
}
// Test it works
content, _, _, err := parser.Add("test", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "mock:test" {
t.Errorf("expected 'mock:test', got %q", content)
}
}
func TestBuiltInParsersStillWork(t *testing.T) {
tests := []struct {
name string
}{
{"passthrough"},
{"qwen3"},
{"qwen3-thinking"},
{"qwen3-coder"},
{"lfm2"},
{"lfm2-thinking"},
{"qwen3.5"},
{"harmony"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := ParserForName(tt.name)
if parser == nil {
t.Fatalf("expected built-in parser %q to exist", tt.name)
}
})
}
}
func TestOverrideBuiltInParser(t *testing.T) {
// Override a built-in parser
Register("passthrough", func() Parser {
return &mockParser{name: "override"}
})
// Should get the override
parser := ParserForName("passthrough")
if parser == nil {
t.Fatal("expected parser to exist")
}
// Test it's the override
content, _, _, err := parser.Add("test", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "mock:test" {
t.Errorf("expected 'mock:test' from override, got %q", content)
}
}
func TestUnknownParserReturnsNil(t *testing.T) {
parser := ParserForName("nonexistent-parser")
if parser != nil {
t.Error("expected nil for unknown parser")
}
}
func TestSplitAtTag(t *testing.T) {
tests := []struct {
name string
input string
tag string
trimAfter bool
wantBefore string
wantAfter string
wantSB string // expected content of strings.Builder after operation
}{
{
name: "basic split with trimAfter true",
input: "hello <!-- split --> world",
tag: "<!-- split -->",
trimAfter: true,
wantBefore: "hello",
wantAfter: "world",
wantSB: "world",
},
{
name: "basic split with trimAfter false",
input: "hello <!-- split --> world",
tag: "<!-- split -->",
trimAfter: false,
wantBefore: "hello",
wantAfter: " world",
wantSB: " world",
},
{
name: "tag at beginning with trimAfter true",
input: "<!-- split -->world",
tag: "<!-- split -->",
trimAfter: true,
wantBefore: "",
wantAfter: "world",
wantSB: "world",
},
{
name: "tag at beginning with trimAfter false",
input: "<!-- split --> world",
tag: "<!-- split -->",
trimAfter: false,
wantBefore: "",
wantAfter: " world",
wantSB: " world",
},
{
name: "tag at end with trimAfter true",
input: "hello <!-- split -->",
tag: "<!-- split -->",
trimAfter: true,
wantBefore: "hello",
wantAfter: "",
wantSB: "",
},
{
name: "tag at end with trimAfter false",
input: "hello <!-- split -->",
tag: "<!-- split -->",
trimAfter: false,
wantBefore: "hello",
wantAfter: "",
wantSB: "",
},
{
name: "multiple tags splits at first occurrence",
input: "hello <!-- split --> world <!-- split --> end",
tag: "<!-- split -->",
trimAfter: true,
wantBefore: "hello",
wantAfter: "world <!-- split --> end",
wantSB: "world <!-- split --> end",
},
{
name: "tag not present",
input: "hello world",
tag: "<!-- split -->",
trimAfter: true,
wantBefore: "hello world",
wantAfter: "",
wantSB: "",
},
{
name: "empty input",
input: "",
tag: "<!-- split -->",
trimAfter: true,
wantBefore: "",
wantAfter: "",
wantSB: "",
},
{
name: "only whitespace before tag",
input: " \t\n<!-- split -->world",
tag: "<!-- split -->",
trimAfter: true,
wantBefore: "",
wantAfter: "world",
wantSB: "world",
},
{
name: "only whitespace after tag with trimAfter true",
input: "hello<!-- split --> \t\n",
tag: "<!-- split -->",
trimAfter: true,
wantBefore: "hello",
wantAfter: "",
wantSB: "",
},
{
name: "only whitespace after tag with trimAfter false",
input: "hello<!-- split --> \t\n",
tag: "<!-- split -->",
trimAfter: false,
wantBefore: "hello",
wantAfter: " \t\n",
wantSB: " \t\n",
},
{
name: "complex whitespace trimming",
input: " hello \t\n <!-- split --> \n\t world ",
tag: "<!-- split -->",
trimAfter: true,
wantBefore: " hello",
wantAfter: "world ",
wantSB: "world ",
},
{
name: "tag with special characters",
input: "text <tag attr=\"value\"> more text",
tag: "<tag attr=\"value\">",
trimAfter: true,
wantBefore: "text",
wantAfter: "more text",
wantSB: "more text",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sb := &strings.Builder{}
sb.WriteString(tt.input)
before, after := splitAtTag(sb, tt.tag, tt.trimAfter)
// Check return values
if before != tt.wantBefore {
t.Errorf("splitAtTag() before = %q, want %q", before, tt.wantBefore)
}
if after != tt.wantAfter {
t.Errorf("splitAtTag() after = %q, want %q", after, tt.wantAfter)
}
// Check strings.Builder state
if sb.String() != tt.wantSB {
t.Errorf("strings.Builder after split = %q, want %q", sb.String(), tt.wantSB)
}
})
}
}

353
model/parsers/qwen3.go Normal file
View File

@@ -0,0 +1,353 @@
package parsers
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"unicode"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/logutil"
)
type qwen3ParserState int
const (
qwen3ParserStateLookingForThinkingOpen qwen3ParserState = iota
qwen3ParserStateThinkingStartedEatingWhitespace
qwen3ParserStateCollectingThinking
qwen3ParserStateThinkingDoneEatingWhitespace
qwen3ParserStateCollectingContent
qwen3ParserStateToolStartedEatingWhitespace
qwen3ParserStateCollectingToolContent
)
const (
qwen3ThinkingOpenTag = "<think>"
qwen3ThinkingCloseTag = "</think>"
qwen3ToolOpenTag = "<tool_call>"
qwen3ToolCloseTag = "</tool_call>"
)
// Qwen3Parser parses Qwen3 output to extract thinking and tool calls.
// Qwen3 prompts end with <think> when thinking is enabled, so output begins
// with thinking content directly (without an opening tag).
type Qwen3Parser struct {
state qwen3ParserState
buffer strings.Builder
tools []api.Tool
callIndex int
hasThinkingSupport bool
defaultThinking bool
maybeThinkingOpenAtBOL bool
}
func (p *Qwen3Parser) HasToolSupport() bool {
return true
}
func (p *Qwen3Parser) HasThinkingSupport() bool {
return p.hasThinkingSupport
}
func (p *Qwen3Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.tools = tools
p.buffer.Reset()
p.callIndex = 0
thinkingEnabled := thinkValue != nil && thinkValue.Bool()
if thinkValue == nil {
thinkingEnabled = p.defaultThinking
}
if p.hasThinkingSupport && thinkingEnabled {
p.state = qwen3ParserStateCollectingThinking
p.maybeThinkingOpenAtBOL = true
} else {
p.state = qwen3ParserStateCollectingContent
p.maybeThinkingOpenAtBOL = false
}
return tools
}
type qwen3Event interface {
isQwen3Event()
}
type qwen3EventContent struct {
content string
}
func (qwen3EventContent) isQwen3Event() {}
type qwen3EventRawToolCall struct {
raw string
}
func (qwen3EventRawToolCall) isQwen3Event() {}
type qwen3EventThinkingContent struct {
content string
}
func (qwen3EventThinkingContent) isQwen3Event() {}
func (p *Qwen3Parser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
events := p.parseEvents()
var contentSb strings.Builder
var thinkingSb strings.Builder
for _, event := range events {
switch event := event.(type) {
case qwen3EventRawToolCall:
toolCall, err := parseQwen3ToolCall(event, p.tools)
if err != nil {
slog.Warn("qwen3 tool call parsing failed", "error", err)
return "", "", nil, err
}
toolCall.Function.Index = p.callIndex
p.callIndex++
calls = append(calls, toolCall)
case qwen3EventThinkingContent:
thinkingSb.WriteString(event.content)
case qwen3EventContent:
contentSb.WriteString(event.content)
}
}
return contentSb.String(), thinkingSb.String(), calls, nil
}
func (p *Qwen3Parser) parseEvents() []qwen3Event {
var all []qwen3Event
keepLooping := true
for keepLooping {
var events []qwen3Event
events, keepLooping = p.eat()
if len(events) > 0 {
all = append(all, events...)
}
}
if len(all) > 0 {
slog.Log(context.TODO(), logutil.LevelTrace, "qwen3 events parsed", "events", all, "state", p.state, "buffer", p.buffer.String())
}
return all
}
func (p *Qwen3Parser) eatLeadingWhitespaceAndTransitionTo(nextState qwen3ParserState) ([]qwen3Event, bool) {
trimmed := strings.TrimLeftFunc(p.buffer.String(), unicode.IsSpace)
p.buffer.Reset()
if trimmed == "" {
return nil, false
}
p.state = nextState
p.buffer.WriteString(trimmed)
return nil, true
}
func (p *Qwen3Parser) splitAtTag(tag string, trimAfter bool) (string, string) {
return splitAtTag(&p.buffer, tag, trimAfter)
}
func (p *Qwen3Parser) eat() ([]qwen3Event, bool) {
var events []qwen3Event
switch p.state {
case qwen3ParserStateLookingForThinkingOpen:
trimmed := strings.TrimLeftFunc(p.buffer.String(), unicode.IsSpace)
if strings.HasPrefix(trimmed, qwen3ThinkingOpenTag) {
after := strings.TrimPrefix(trimmed, qwen3ThinkingOpenTag)
after = strings.TrimLeftFunc(after, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(after)
if after == "" {
p.state = qwen3ParserStateThinkingStartedEatingWhitespace
} else {
p.state = qwen3ParserStateCollectingThinking
}
return events, true
} else if strings.HasPrefix(qwen3ThinkingOpenTag, trimmed) {
return events, false
} else if trimmed == "" {
return events, false
}
p.state = qwen3ParserStateCollectingContent
return events, true
case qwen3ParserStateThinkingStartedEatingWhitespace:
return p.eatLeadingWhitespaceAndTransitionTo(qwen3ParserStateCollectingThinking)
case qwen3ParserStateCollectingThinking:
acc := p.buffer.String()
// Some qwen3 checkpoints emit an explicit opening <think> tag even
// though the prompt already ended with <think>. Strip exactly one
// leading opening tag if present.
if p.maybeThinkingOpenAtBOL {
trimmed := strings.TrimLeftFunc(acc, unicode.IsSpace)
if strings.HasPrefix(trimmed, qwen3ThinkingOpenTag) {
after := strings.TrimPrefix(trimmed, qwen3ThinkingOpenTag)
after = strings.TrimLeftFunc(after, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(after)
if after == "" {
return events, false
}
p.maybeThinkingOpenAtBOL = false
return events, true
}
if strings.HasPrefix(qwen3ThinkingOpenTag, trimmed) {
return events, false
}
p.maybeThinkingOpenAtBOL = false
}
thinkingCloseIdx := strings.Index(acc, qwen3ThinkingCloseTag)
toolOpenIdx := strings.Index(acc, qwen3ToolOpenTag)
// If a tool call starts before </think>, treat that as the end of thinking
// for parsing purposes and continue in tool-call mode.
if toolOpenIdx != -1 && (thinkingCloseIdx == -1 || toolOpenIdx < thinkingCloseIdx) {
before, after := p.splitAtTag(qwen3ToolOpenTag, true)
if len(before) > 0 {
events = append(events, qwen3EventThinkingContent{content: before})
}
if after == "" {
p.state = qwen3ParserStateToolStartedEatingWhitespace
} else {
p.state = qwen3ParserStateCollectingToolContent
}
return events, true
}
if strings.Contains(acc, qwen3ThinkingCloseTag) {
thinking, remaining := p.splitAtTag(qwen3ThinkingCloseTag, true)
if len(thinking) > 0 {
events = append(events, qwen3EventThinkingContent{content: thinking})
}
if remaining == "" {
p.state = qwen3ParserStateThinkingDoneEatingWhitespace
} else {
p.state = qwen3ParserStateCollectingContent
}
return events, true
} else if overlapLen := max(overlap(acc, qwen3ThinkingCloseTag), overlap(acc, qwen3ToolOpenTag)); overlapLen > 0 {
beforePartialTag := acc[:len(acc)-overlapLen]
trailingWsLen := trailingWhitespaceLen(beforePartialTag)
ambiguousStart := len(beforePartialTag) - trailingWsLen
unambiguous := acc[:ambiguousStart]
ambiguous := acc[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, qwen3EventThinkingContent{content: unambiguous})
}
return events, false
}
whitespaceLen := trailingWhitespaceLen(acc)
ambiguousStart := len(acc) - whitespaceLen
unambiguous := acc[:ambiguousStart]
ambiguous := acc[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, qwen3EventThinkingContent{content: unambiguous})
}
return events, false
case qwen3ParserStateThinkingDoneEatingWhitespace:
return p.eatLeadingWhitespaceAndTransitionTo(qwen3ParserStateCollectingContent)
case qwen3ParserStateCollectingContent:
acc := p.buffer.String()
if strings.Contains(acc, qwen3ToolOpenTag) {
before, after := p.splitAtTag(qwen3ToolOpenTag, true)
if len(before) > 0 {
events = append(events, qwen3EventContent{content: before})
}
if after == "" {
p.state = qwen3ParserStateToolStartedEatingWhitespace
} else {
p.state = qwen3ParserStateCollectingToolContent
}
return events, true
} else if overlapLen := overlap(acc, qwen3ToolOpenTag); overlapLen > 0 {
beforePartialTag := acc[:len(acc)-overlapLen]
trailingWsLen := trailingWhitespaceLen(beforePartialTag)
ambiguousStart := len(beforePartialTag) - trailingWsLen
unambiguous := acc[:ambiguousStart]
ambiguous := acc[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, qwen3EventContent{content: unambiguous})
}
return events, false
}
whitespaceLen := trailingWhitespaceLen(acc)
ambiguousStart := len(acc) - whitespaceLen
unambiguous := acc[:ambiguousStart]
ambiguous := acc[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, qwen3EventContent{content: unambiguous})
}
return events, false
case qwen3ParserStateToolStartedEatingWhitespace:
return p.eatLeadingWhitespaceAndTransitionTo(qwen3ParserStateCollectingToolContent)
case qwen3ParserStateCollectingToolContent:
acc := p.buffer.String()
if strings.Contains(acc, qwen3ToolCloseTag) {
toolContent, _ := p.splitAtTag(qwen3ToolCloseTag, true)
if len(toolContent) == 0 {
slog.Warn("qwen3 tool call closing tag found but no content before it")
}
events = append(events, qwen3EventRawToolCall{raw: toolContent})
p.state = qwen3ParserStateCollectingContent
return events, true
}
return events, false
default:
panic("unreachable")
}
}
func parseQwen3ToolCall(raw qwen3EventRawToolCall, tools []api.Tool) (api.ToolCall, error) {
var parsed struct {
Name string `json:"name"`
Arguments api.ToolCallFunctionArguments `json:"arguments"`
}
if err := json.Unmarshal([]byte(raw.raw), &parsed); err != nil {
return api.ToolCall{}, fmt.Errorf("failed to parse JSON: %w", err)
}
if parsed.Name == "" {
return api.ToolCall{}, fmt.Errorf("empty function name")
}
_ = tools // qwen3 uses direct JSON args and does not require schema coercion here.
toolCall := api.ToolCall{
Function: api.ToolCallFunction{
Name: parsed.Name,
Arguments: parsed.Arguments,
},
}
return toolCall, nil
}

247
model/parsers/qwen35.go Normal file
View File

@@ -0,0 +1,247 @@
package parsers
import (
"context"
"log/slog"
"strings"
"unicode"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/logutil"
)
type qwen35ParserState int
const (
qwen35ParserStateCollectingThinking qwen35ParserState = iota
qwen35ParserStateThinkingDoneEatingWhitespace
qwen35ParserStateCollectingContent
)
const (
qwen35ThinkingOpenTag = "<think>"
qwen35ThinkingCloseTag = "</think>"
qwen35ToolCallOpenTag = "<tool_call>"
)
// Qwen35Parser handles qwen3.5 reasoning extraction and delegates post-thinking
// content (including XML tool calls) to Qwen3CoderParser.
type Qwen35Parser struct {
toolParser Qwen3CoderParser
state qwen35ParserState
buffer strings.Builder
// Some checkpoints may emit an explicit leading <think> even when the
// prompt already opened thinking. Strip at most one such tag.
allowLeadingThinkOpenTag bool
}
func (p *Qwen35Parser) HasToolSupport() bool {
return true
}
func (p *Qwen35Parser) HasThinkingSupport() bool {
return true
}
func (p *Qwen35Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.buffer.Reset()
p.toolParser = Qwen3CoderParser{}
p.toolParser.Init(tools, nil, nil)
thinkingEnabled := thinkValue != nil && thinkValue.Bool()
if thinkValue == nil {
thinkingEnabled = true
}
assistantPrefill := lastMessage != nil && lastMessage.Role == "assistant" && lastMessage.Content != ""
if thinkingEnabled && !assistantPrefill {
p.state = qwen35ParserStateCollectingThinking
p.allowLeadingThinkOpenTag = true
} else {
p.state = qwen35ParserStateCollectingContent
p.allowLeadingThinkOpenTag = false
}
return tools
}
type qwen35Event interface {
isQwen35Event()
}
type qwen35EventContent struct {
content string
}
func (qwen35EventContent) isQwen35Event() {}
type qwen35EventThinkingContent struct {
content string
}
func (qwen35EventThinkingContent) isQwen35Event() {}
func (p *Qwen35Parser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
events := p.parseEvents()
var contentSb strings.Builder
var thinkingSb strings.Builder
for _, event := range events {
switch event := event.(type) {
case qwen35EventContent:
parsedContent, _, parsedCalls, err := p.toolParser.Add(event.content, done)
if err != nil {
slog.Warn("qwen3.5 tool call parsing failed", "error", err)
return "", "", nil, err
}
contentSb.WriteString(parsedContent)
calls = append(calls, parsedCalls...)
case qwen35EventThinkingContent:
thinkingSb.WriteString(event.content)
}
}
return contentSb.String(), thinkingSb.String(), calls, nil
}
func (p *Qwen35Parser) parseEvents() []qwen35Event {
var all []qwen35Event
keepLooping := true
for keepLooping {
var events []qwen35Event
events, keepLooping = p.eat()
if len(events) > 0 {
all = append(all, events...)
}
}
if len(all) > 0 {
slog.Log(context.TODO(), logutil.LevelTrace, "qwen3.5 events parsed", "events", all, "state", p.state, "buffer", p.buffer.String())
}
return all
}
func (p *Qwen35Parser) splitAtTag(tag string, trimAfter bool) (string, string) {
return splitAtTag(&p.buffer, tag, trimAfter)
}
func (p *Qwen35Parser) eatLeadingWhitespaceAndTransitionTo(nextState qwen35ParserState) ([]qwen35Event, bool) {
trimmed := strings.TrimLeftFunc(p.buffer.String(), unicode.IsSpace)
p.buffer.Reset()
if trimmed == "" {
return nil, false
}
p.state = nextState
p.buffer.WriteString(trimmed)
return nil, true
}
// maybeConsumeLeadingThinkOpenTag handles a single optional leading <think> tag.
// Returns (handled, shouldContinueParsingNow).
func (p *Qwen35Parser) maybeConsumeLeadingThinkOpenTag(acc string) (bool, bool) {
if !p.allowLeadingThinkOpenTag {
return false, false
}
trimmed := strings.TrimLeftFunc(acc, unicode.IsSpace)
if strings.HasPrefix(trimmed, qwen35ThinkingOpenTag) {
after := strings.TrimPrefix(trimmed, qwen35ThinkingOpenTag)
after = strings.TrimLeftFunc(after, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(after)
if after == "" {
return true, false
}
p.allowLeadingThinkOpenTag = false
return true, true
}
if strings.HasPrefix(qwen35ThinkingOpenTag, trimmed) {
return true, false
}
p.allowLeadingThinkOpenTag = false
return false, false
}
func (p *Qwen35Parser) eat() ([]qwen35Event, bool) {
var events []qwen35Event
switch p.state {
case qwen35ParserStateCollectingThinking:
acc := p.buffer.String()
if handled, continueNow := p.maybeConsumeLeadingThinkOpenTag(acc); handled {
return events, continueNow
}
if strings.Contains(acc, qwen35ThinkingCloseTag) {
thinking, remaining := p.splitAtTag(qwen35ThinkingCloseTag, true)
if len(thinking) > 0 {
events = append(events, qwen35EventThinkingContent{content: thinking})
}
if remaining == "" {
p.state = qwen35ParserStateThinkingDoneEatingWhitespace
} else {
p.state = qwen35ParserStateCollectingContent
}
return events, true
} else if overlapLen := max(overlap(acc, qwen35ThinkingCloseTag), overlap(acc, qwen35ToolCallOpenTag)); overlapLen > 0 {
beforePartialTag := acc[:len(acc)-overlapLen]
trailingWsLen := trailingWhitespaceLen(beforePartialTag)
ambiguousStart := len(beforePartialTag) - trailingWsLen
unambiguous := acc[:ambiguousStart]
ambiguous := acc[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, qwen35EventThinkingContent{content: unambiguous})
}
return events, false
} else if strings.Contains(acc, qwen35ToolCallOpenTag) {
// qwen3.5:9b model forgets sometimes to use </think> tag before the <tool_call> block starts
// this condition ends the Think block and continues with the <tool_call> when the tag
// is found
thinking, tooling := p.splitAtTag(qwen35ToolCallOpenTag, true)
p.buffer.Reset()
p.buffer.WriteString(thinking + qwen35ThinkingCloseTag + qwen35ToolCallOpenTag + tooling)
return events, true
}
whitespaceLen := trailingWhitespaceLen(acc)
ambiguousStart := len(acc) - whitespaceLen
unambiguous := acc[:ambiguousStart]
ambiguous := acc[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, qwen35EventThinkingContent{content: unambiguous})
}
return events, false
case qwen35ParserStateThinkingDoneEatingWhitespace:
return p.eatLeadingWhitespaceAndTransitionTo(qwen35ParserStateCollectingContent)
case qwen35ParserStateCollectingContent:
if p.buffer.Len() == 0 {
return events, false
}
content := p.buffer.String()
p.buffer.Reset()
if len(content) > 0 {
events = append(events, qwen35EventContent{content: content})
}
return events, false
default:
slog.Warn("qwen3.5 parser entered unknown state; resetting to content mode", "state", p.state)
p.state = qwen35ParserStateCollectingContent
return events, false
}
}

View File

@@ -0,0 +1,515 @@
package parsers
import (
"testing"
"github.com/ollama/ollama/api"
)
func TestQwen35ParserXMLToolCall(t *testing.T) {
parser := ParserForName("qwen3.5")
if parser == nil {
t.Fatal("expected qwen3.5 parser")
}
tools := []api.Tool{
{
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Properties: func() *api.ToolPropertiesMap {
props := api.NewToolPropertiesMap()
props.Set("location", api.ToolProperty{Type: api.PropertyType{"string"}})
props.Set("days", api.ToolProperty{Type: api.PropertyType{"integer"}})
return props
}(),
},
},
},
}
parser.Init(tools, nil, &api.ThinkValue{Value: false})
input := "<tool_call><function=get_weather><parameter=location>\nSan Francisco\n</parameter><parameter=days>\n3\n</parameter></function></tool_call>"
content, thinking, calls, err := parser.Add(input, true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if content != "" {
t.Fatalf("expected empty content, got %q", content)
}
if thinking != "" {
t.Fatalf("expected empty thinking, got %q", thinking)
}
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("expected tool name %q, got %q", "get_weather", calls[0].Function.Name)
}
location, ok := calls[0].Function.Arguments.Get("location")
if !ok || location != "San Francisco" {
t.Fatalf("expected location %q, got %v", "San Francisco", location)
}
days, ok := calls[0].Function.Arguments.Get("days")
if !ok || days != 3 {
t.Fatalf("expected days %d, got %v", 3, days)
}
}
func TestQwen35ParserThinkingWithExplicitOpeningTag(t *testing.T) {
parser := ParserForName("qwen3.5")
if parser == nil {
t.Fatal("expected qwen3.5 parser")
}
parser.Init(nil, nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("<think>\nLet me think...</think>Answer.", true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if thinking != "Let me think..." {
t.Fatalf("expected thinking %q, got %q", "Let me think...", thinking)
}
if content != "Answer." {
t.Fatalf("expected content %q, got %q", "Answer.", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}
func TestQwen35ParserAssistantPrefillStartsInContent(t *testing.T) {
parser := ParserForName("qwen3.5")
if parser == nil {
t.Fatal("expected qwen3.5 parser")
}
last := &api.Message{Role: "assistant", Content: "Prefilled response start"}
parser.Init(nil, last, nil)
content, thinking, calls, err := parser.Add(" and continued", true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if thinking != "" {
t.Fatalf("expected no thinking for assistant prefill continuation, got %q", thinking)
}
if content != " and continued" {
t.Fatalf("expected content %q, got %q", " and continued", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}
func TestQwen35ParserToolCallEmittedInThinkingIsParsed(t *testing.T) {
parser := ParserForName("qwen3.5")
if parser == nil {
t.Fatal("expected qwen3.5 parser")
}
tools := []api.Tool{
{
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Properties: func() *api.ToolPropertiesMap {
props := api.NewToolPropertiesMap()
props.Set("location", api.ToolProperty{Type: api.PropertyType{"string"}})
return props
}(),
},
},
},
}
parser.Init(tools, nil, &api.ThinkValue{Value: true})
input := `Need weather lookup<tool_call><function=get_weather><parameter=location>
SF
</parameter></function></tool_call>`
content, thinking, calls, err := parser.Add(input, true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if content != "" {
t.Fatalf("expected empty content, got %q", content)
}
if thinking != "Need weather lookup" {
t.Fatalf("expected thinking %q, got %q", "Need weather lookup", thinking)
}
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("expected tool name %q, got %q", "get_weather", calls[0].Function.Name)
}
location, ok := calls[0].Function.Arguments.Get("location")
if !ok || location != "SF" {
t.Fatalf("expected location %q, got %v", "SF", location)
}
}
func TestQwen35ParserToolCallEmittedInThinkingIsParsedWhenToolCallTagIsSplitAcrossChunks(t *testing.T) {
parser := ParserForName("qwen3.5")
if parser == nil {
t.Fatal("expected qwen3.5 parser")
}
tools := []api.Tool{
{
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Properties: func() *api.ToolPropertiesMap {
props := api.NewToolPropertiesMap()
props.Set("location", api.ToolProperty{Type: api.PropertyType{"string"}})
return props
}(),
},
},
},
}
parser.Init(tools, nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("Need weather lookup<tool_c", false)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if content != "" {
t.Fatalf("expected empty content, got %q", content)
}
if thinking != "Need weather lookup" {
t.Fatalf("expected thinking %q, got %q", "Need weather lookup", thinking)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls in first chunk, got %d", len(calls))
}
content, thinking, calls, err = parser.Add(`all><function=get_weather><parameter=location>
SF
</parameter></function></tool_call>`, true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if content != "" {
t.Fatalf("expected empty content, got %q", content)
}
if thinking != "" {
t.Fatalf("expected no additional thinking, got %q", thinking)
}
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("expected tool name %q, got %q", "get_weather", calls[0].Function.Name)
}
location, ok := calls[0].Function.Arguments.Get("location")
if !ok || location != "SF" {
t.Fatalf("expected location %q, got %v", "SF", location)
}
}
func TestQwen35ParserFakeoutPartialToolCallThenThinkCloseAcrossChunks(t *testing.T) {
parser := ParserForName("qwen3.5")
if parser == nil {
t.Fatal("expected qwen3.5 parser")
}
tools := []api.Tool{
{
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Properties: func() *api.ToolPropertiesMap {
props := api.NewToolPropertiesMap()
props.Set("location", api.ToolProperty{Type: api.PropertyType{"string"}})
return props
}(),
},
},
},
}
parser.Init(tools, nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("Need weather lookup<tool_c", false)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if content != "" {
t.Fatalf("expected empty content, got %q", content)
}
if thinking != "Need weather lookup" {
t.Fatalf("expected thinking %q, got %q", "Need weather lookup", thinking)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls in first chunk, got %d", len(calls))
}
content, thinking, calls, err = parser.Add("</thi", false)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if content != "" {
t.Fatalf("expected empty content, got %q", content)
}
if thinking != "<tool_c" {
t.Fatalf("expected thinking %q, got %q", "<tool_c", thinking)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls in second chunk, got %d", len(calls))
}
content, thinking, calls, err = parser.Add("nk>", true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if content != "" {
t.Fatalf("expected empty content, got %q", content)
}
if thinking != "" {
t.Fatalf("expected no additional thinking in third chunk, got %q", thinking)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls in third chunk, got %d", len(calls))
}
}
func TestQwen35ParserToolCallAfterThinkingCloseIsParsed(t *testing.T) {
parser := ParserForName("qwen3.5")
if parser == nil {
t.Fatal("expected qwen3.5 parser")
}
tools := []api.Tool{
{
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Properties: func() *api.ToolPropertiesMap {
props := api.NewToolPropertiesMap()
props.Set("location", api.ToolProperty{Type: api.PropertyType{"string"}})
return props
}(),
},
},
},
}
parser.Init(tools, nil, &api.ThinkValue{Value: true})
input := `Need weather lookup</think><tool_call><function=get_weather><parameter=location>
SF
</parameter></function></tool_call>`
content, thinking, calls, err := parser.Add(input, true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if content != "" {
t.Fatalf("expected empty content, got %q", content)
}
if thinking != "Need weather lookup" {
t.Fatalf("expected thinking %q, got %q", "Need weather lookup", thinking)
}
if len(calls) != 1 {
t.Fatalf("expected 1 tool call after </think>, got %d", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("expected tool name %q, got %q", "get_weather", calls[0].Function.Name)
}
location, ok := calls[0].Function.Arguments.Get("location")
if !ok || location != "SF" {
t.Fatalf("expected location %q, got %v", "SF", location)
}
}
func TestQwen35ParserThinkingDisabledPassesContentThrough(t *testing.T) {
parser := ParserForName("qwen3.5")
if parser == nil {
t.Fatal("expected qwen3.5 parser")
}
parser.Init(nil, nil, &api.ThinkValue{Value: false})
content, thinking, calls, err := parser.Add("Plain answer without think close tag.", true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if thinking != "" {
t.Fatalf("expected empty thinking, got %q", thinking)
}
if content != "Plain answer without think close tag." {
t.Fatalf("expected content %q, got %q", "Plain answer without think close tag.", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}
func TestQwen35ParserThinkingDisabledWithCloseTagTreatsAsContent(t *testing.T) {
parser := ParserForName("qwen3.5")
if parser == nil {
t.Fatal("expected qwen3.5 parser")
}
parser.Init(nil, nil, &api.ThinkValue{Value: false})
content, thinking, calls, err := parser.Add("</think>Some content after spurious tag.", true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if thinking != "" {
t.Fatalf("expected empty thinking, got %q", thinking)
}
if content != "</think>Some content after spurious tag." {
t.Fatalf("expected content %q, got %q", "</think>Some content after spurious tag.", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}
func TestQwen35ParserLeadingThinkCloseProducesContent(t *testing.T) {
parser := ParserForName("qwen3.5")
if parser == nil {
t.Fatal("expected qwen3.5 parser")
}
parser.Init(nil, nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("</think>The final answer.", true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if thinking != "" {
t.Fatalf("expected empty thinking, got %q", thinking)
}
if content != "The final answer." {
t.Fatalf("expected content %q, got %q", "The final answer.", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}
func TestQwen35ParserStreamingSplitThinkCloseTag(t *testing.T) {
parser := ParserForName("qwen3.5")
if parser == nil {
t.Fatal("expected qwen3.5 parser")
}
parser.Init(nil, nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("Reasoning text</thi", false)
if err != nil {
t.Fatalf("parse failed on first chunk: %v", err)
}
if thinking != "Reasoning text" {
t.Fatalf("expected thinking %q, got %q", "Reasoning text", thinking)
}
if content != "" {
t.Fatalf("expected empty content, got %q", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
content, thinking, calls, err = parser.Add("nk>The final answer.", true)
if err != nil {
t.Fatalf("parse failed on second chunk: %v", err)
}
if thinking != "" {
t.Fatalf("expected no additional thinking on second chunk, got %q", thinking)
}
if content != "The final answer." {
t.Fatalf("expected content %q, got %q", "The final answer.", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}
func TestQwen35ParserStreamingEatsWhitespaceAfterThinkClose(t *testing.T) {
parser := ParserForName("qwen3.5")
if parser == nil {
t.Fatal("expected qwen3.5 parser")
}
parser.Init(nil, nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("Reasoning</think>", false)
if err != nil {
t.Fatalf("parse failed on first chunk: %v", err)
}
if thinking != "Reasoning" {
t.Fatalf("expected thinking %q, got %q", "Reasoning", thinking)
}
if content != "" {
t.Fatalf("expected empty content, got %q", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
content, thinking, calls, err = parser.Add("\n \t", false)
if err != nil {
t.Fatalf("parse failed on whitespace chunk: %v", err)
}
if thinking != "" {
t.Fatalf("expected no thinking on whitespace chunk, got %q", thinking)
}
if content != "" {
t.Fatalf("expected whitespace after </think> to be eaten, got content %q", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
content, thinking, calls, err = parser.Add("The final answer.", true)
if err != nil {
t.Fatalf("parse failed on content chunk: %v", err)
}
if thinking != "" {
t.Fatalf("expected no additional thinking, got %q", thinking)
}
if content != "The final answer." {
t.Fatalf("expected content %q, got %q", "The final answer.", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}
func TestQwen35ParserThinkingTruncatedWithoutCloseTag(t *testing.T) {
parser := ParserForName("qwen3.5")
if parser == nil {
t.Fatal("expected qwen3.5 parser")
}
parser.Init(nil, nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("Reasoning that never closes", true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if thinking != "Reasoning that never closes" {
t.Fatalf("expected thinking %q, got %q", "Reasoning that never closes", thinking)
}
if content != "" {
t.Fatalf("expected empty content, got %q", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}

318
model/parsers/qwen3_test.go Normal file
View File

@@ -0,0 +1,318 @@
package parsers
import (
"testing"
"github.com/ollama/ollama/api"
)
func TestQwen3ParserThinkingEnabled(t *testing.T) {
parser := &Qwen3Parser{hasThinkingSupport: true, defaultThinking: true}
parser.Init(nil, nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("Let me think...</think>Answer.", true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if thinking != "Let me think..." {
t.Fatalf("expected thinking %q, got %q", "Let me think...", thinking)
}
if content != "Answer." {
t.Fatalf("expected content %q, got %q", "Answer.", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}
func TestQwen3ParserThinkingEnabledWithExplicitOpeningTag(t *testing.T) {
parser := &Qwen3Parser{hasThinkingSupport: true, defaultThinking: true}
parser.Init(nil, nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("<think>\nLet me think...</think>Answer.", true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if thinking != "Let me think..." {
t.Fatalf("expected thinking %q, got %q", "Let me think...", thinking)
}
if content != "Answer." {
t.Fatalf("expected content %q, got %q", "Answer.", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}
func TestQwen3ParserThinkingEnabledWithSplitOpeningTag(t *testing.T) {
parser := &Qwen3Parser{hasThinkingSupport: true, defaultThinking: true}
parser.Init(nil, nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("<thi", false)
if err != nil {
t.Fatalf("parse failed on first chunk: %v", err)
}
if content != "" || thinking != "" || len(calls) != 0 {
t.Fatalf("expected no output for first chunk, got content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
content, thinking, calls, err = parser.Add("nk>Let me think...</think>Answer.", true)
if err != nil {
t.Fatalf("parse failed on second chunk: %v", err)
}
if thinking != "Let me think..." {
t.Fatalf("expected thinking %q, got %q", "Let me think...", thinking)
}
if content != "Answer." {
t.Fatalf("expected content %q, got %q", "Answer.", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}
func TestQwen3ParserThinkingDisabled(t *testing.T) {
parser := &Qwen3Parser{hasThinkingSupport: false, defaultThinking: false}
parser.Init(nil, nil, &api.ThinkValue{Value: false})
content, thinking, calls, err := parser.Add("Direct answer", true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if thinking != "" {
t.Fatalf("expected no thinking, got %q", thinking)
}
if content != "Direct answer" {
t.Fatalf("expected content %q, got %q", "Direct answer", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}
func TestQwen3ParserNilThinkDefaultsToContentForInstructParser(t *testing.T) {
parser := &Qwen3Parser{hasThinkingSupport: false, defaultThinking: false}
parser.Init(nil, nil, nil)
content, thinking, calls, err := parser.Add("Direct answer", true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if thinking != "" {
t.Fatalf("expected no thinking, got %q", thinking)
}
if content != "Direct answer" {
t.Fatalf("expected content %q, got %q", "Direct answer", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}
func TestQwen3ParserToolCall(t *testing.T) {
parser := &Qwen3Parser{hasThinkingSupport: false, defaultThinking: false}
parser.Init(nil, nil, &api.ThinkValue{Value: false})
input := "<tool_call>{\"name\":\"get_weather\",\"arguments\":{\"location\":\"San Francisco\",\"unit\":\"celsius\"}}</tool_call>"
content, thinking, calls, err := parser.Add(input, true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if content != "" {
t.Fatalf("expected empty content, got %q", content)
}
if thinking != "" {
t.Fatalf("expected empty thinking, got %q", thinking)
}
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("expected tool name %q, got %q", "get_weather", calls[0].Function.Name)
}
location, ok := calls[0].Function.Arguments.Get("location")
if !ok || location != "San Francisco" {
t.Fatalf("expected location %q, got %v", "San Francisco", location)
}
unit, ok := calls[0].Function.Arguments.Get("unit")
if !ok || unit != "celsius" {
t.Fatalf("expected unit %q, got %v", "celsius", unit)
}
}
func TestQwen3ParserThinkingWithToolCallBeforeThinkingClose(t *testing.T) {
parser := &Qwen3Parser{hasThinkingSupport: true, defaultThinking: true}
parser.Init(nil, nil, &api.ThinkValue{Value: true})
input := "Let me think<tool_call>{\"name\":\"get_weather\",\"arguments\":{\"location\":\"San Francisco\",\"unit\":\"celsius\"}}</tool_call>"
content, thinking, calls, err := parser.Add(input, true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if content != "" {
t.Fatalf("expected empty content, got %q", content)
}
if thinking != "Let me think" {
t.Fatalf("expected thinking %q, got %q", "Let me think", thinking)
}
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("expected tool name %q, got %q", "get_weather", calls[0].Function.Name)
}
}
func TestQwen3ParserThinkingWithSplitToolOpenTag(t *testing.T) {
parser := &Qwen3Parser{hasThinkingSupport: true, defaultThinking: true}
parser.Init(nil, nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("Let me think<tool_ca", false)
if err != nil {
t.Fatalf("parse failed on first chunk: %v", err)
}
if content != "" || thinking != "Let me think" || len(calls) != 0 {
t.Fatalf(
"expected content=%q thinking=%q calls=%d, got content=%q thinking=%q calls=%d",
"",
"Let me think",
0,
content,
thinking,
len(calls),
)
}
content, thinking, calls, err = parser.Add("ll>{\"name\":\"get_weather\",\"arguments\":{\"location\":\"SF\"}}</tool_call>", true)
if err != nil {
t.Fatalf("parse failed on second chunk: %v", err)
}
if content != "" {
t.Fatalf("expected empty content, got %q", content)
}
if thinking != "" {
t.Fatalf("expected no additional thinking on second chunk, got %q", thinking)
}
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("expected tool name %q, got %q", "get_weather", calls[0].Function.Name)
}
}
func TestQwen35ParserRespectsNoThink(t *testing.T) {
parser := ParserForName("qwen3.5")
if parser == nil {
t.Fatal("expected qwen3.5 parser")
}
parser.Init(nil, nil, &api.ThinkValue{Value: false})
content, thinking, calls, err := parser.Add("Hello! How can I help you today?", true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if thinking != "" {
t.Fatalf("expected no thinking, got %q", thinking)
}
if content != "Hello! How can I help you today?" {
t.Fatalf("expected content %q, got %q", "Hello! How can I help you today?", content)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls, got %d", len(calls))
}
}
func TestQwen3ParserToolCallIndexing(t *testing.T) {
parser := &Qwen3Parser{hasThinkingSupport: false, defaultThinking: false}
parser.Init(nil, nil, &api.ThinkValue{Value: false})
input := `<tool_call>{"name":"first","arguments":{"a":"1"}}</tool_call>
<tool_call>{"name":"second","arguments":{"b":"2"}}</tool_call>
<tool_call>{"name":"third","arguments":{"c":"3"}}</tool_call>`
_, _, calls, err := parser.Add(input, true)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
want := []api.ToolCall{
{Function: api.ToolCallFunction{Name: "first", Arguments: args(`{"a":"1"}`), Index: 0}},
{Function: api.ToolCallFunction{Name: "second", Arguments: args(`{"b":"2"}`), Index: 1}},
{Function: api.ToolCallFunction{Name: "third", Arguments: args(`{"c":"3"}`), Index: 2}},
}
if len(calls) != len(want) {
t.Fatalf("expected %d calls, got %d", len(want), len(calls))
}
for i := range want {
if !toolCallEqual(calls[i], want[i]) {
t.Fatalf("call %d mismatch: got %#v, want %#v", i, calls[i], want[i])
}
}
}
func TestQwen3ParserToolCallIndexingStreaming(t *testing.T) {
parser := &Qwen3Parser{hasThinkingSupport: false, defaultThinking: false}
parser.Init(nil, nil, &api.ThinkValue{Value: false})
var all []api.ToolCall
_, _, calls, err := parser.Add(`<tool_call>{"name":"first","arguments":{"a":"1"}}</tool_call><tool_call>{"name":"second","arguments":{"b":"2"}`, false)
if err != nil {
t.Fatalf("step 1 parse failed: %v", err)
}
all = append(all, calls...)
_, _, calls, err = parser.Add(`}</tool_call><tool_call>{"name":"third","arguments":{"c":"3"}}</tool_call>`, true)
if err != nil {
t.Fatalf("step 2 parse failed: %v", err)
}
all = append(all, calls...)
want := []api.ToolCall{
{Function: api.ToolCallFunction{Name: "first", Arguments: args(`{"a":"1"}`), Index: 0}},
{Function: api.ToolCallFunction{Name: "second", Arguments: args(`{"b":"2"}`), Index: 1}},
{Function: api.ToolCallFunction{Name: "third", Arguments: args(`{"c":"3"}`), Index: 2}},
}
if len(all) != len(want) {
t.Fatalf("expected %d calls, got %d", len(want), len(all))
}
for i := range want {
if !toolCallEqual(all[i], want[i]) {
t.Fatalf("call %d mismatch: got %#v, want %#v", i, all[i], want[i])
}
}
}
func TestQwen3ParserToolCallIndexResetOnInit(t *testing.T) {
parser := &Qwen3Parser{hasThinkingSupport: false, defaultThinking: false}
parser.Init(nil, nil, &api.ThinkValue{Value: false})
_, _, _, err := parser.Add(`<tool_call>{"name":"first","arguments":{"a":"1"}}</tool_call>`, true)
if err != nil {
t.Fatalf("first parse failed: %v", err)
}
parser.Init(nil, nil, &api.ThinkValue{Value: false})
_, _, calls, err := parser.Add(`<tool_call>{"name":"second","arguments":{"b":"2"}}</tool_call>`, true)
if err != nil {
t.Fatalf("second parse failed: %v", err)
}
want := api.ToolCall{
Function: api.ToolCallFunction{Name: "second", Arguments: args(`{"b":"2"}`), Index: 0},
}
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if !toolCallEqual(calls[0], want) {
t.Fatalf("got %#v, want %#v", calls[0], want)
}
}

445
model/parsers/qwen3coder.go Normal file
View File

@@ -0,0 +1,445 @@
package parsers
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"log/slog"
"math"
"regexp"
"strconv"
"strings"
"unicode"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/logutil"
)
type qwenParserState int
const (
toolOpenTag = "<tool_call>"
toolCloseTag = "</tool_call>"
)
const (
qwenParserState_LookingForToolStart qwenParserState = iota
qwenParserState_CollectingToolContent
)
type Qwen3CoderParser struct {
state qwenParserState
acc strings.Builder
tools []api.Tool
callIndex int
}
func (p *Qwen3CoderParser) HasToolSupport() bool {
return true
}
func (p *Qwen3CoderParser) HasThinkingSupport() bool {
return false
}
func (p *Qwen3CoderParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.tools = tools
p.callIndex = 0
return tools // Qwen doesn't modify tools
}
func (p *Qwen3CoderParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.acc.WriteString(s)
events := p.parseEvents()
var toolCalls []api.ToolCall
var sb strings.Builder
for _, event := range events {
switch event := event.(type) {
case qwenEventRawToolCall:
toolCall, err := parseToolCall(event, p.tools)
if err != nil {
slog.Warn("qwen tool call parsing failed", "error", err)
return "", "", nil, err
}
toolCall.Function.Index = p.callIndex
p.callIndex++
toolCalls = append(toolCalls, toolCall)
case qwenEventContent:
// TODO(drifkin): if the same turn contains multiple interleaved content
// events, we naively append them together here. See the note below about
// `qwenEvent`s for more details
sb.WriteString(event.content)
}
}
return sb.String(), "", toolCalls, nil
}
func (p *Qwen3CoderParser) parseEvents() []qwenEvent {
var all []qwenEvent
keepLooping := true
for keepLooping {
var events []qwenEvent
events, keepLooping = eat(p)
if len(events) > 0 {
all = append(all, events...)
}
}
if len(all) > 0 {
slog.Log(context.TODO(), logutil.LevelTrace, "qwen events parsed", "events", all, "state", p.state, "acc", p.acc.String())
}
return all
}
// we use some internal event types in order to communicate between `Add` and
// `eat`. We do this to support interleaving content and parallel tool calls in
// the parser, even though qwen3-coder isn't supposed to do this. Our API
// doesn't currently support models outputting multiple messages in a turn, so
// we wouldn't be able to represent it yet, but there's no reason to prevent the
// parser from supporting it, especially for future models if they end up using
// a similar format.
type qwenEvent interface {
isQwenEvent()
}
type qwenEventRawToolCall struct {
raw string
}
type qwenEventContent struct {
content string
}
func (qwenEventContent) isQwenEvent() {}
func (qwenEventRawToolCall) isQwenEvent() {}
// eat consumes the parser's buffer, and returns a list of any unambiguous
// events from the current parser state. If the parser transitions to another
// state, it may have additional events to emit on the next call, which is what
// the second return value indicates
func eat(p *Qwen3CoderParser) ([]qwenEvent, bool) {
var events []qwenEvent
switch p.state {
case qwenParserState_LookingForToolStart:
if strings.Contains(p.acc.String(), toolOpenTag) {
// we found a full tool open tag, so we can emit the content before the
// tag, being sure to trim any trailing whitespace
split := strings.SplitN(p.acc.String(), toolOpenTag, 2)
before := split[0]
before = strings.TrimRightFunc(before, unicode.IsSpace)
if len(before) > 0 {
events = append(events, qwenEventContent{content: before})
}
after := split[1]
p.acc.Reset()
p.acc.WriteString(after)
p.state = qwenParserState_CollectingToolContent
return events, true
} else if overlap := overlap(p.acc.String(), toolOpenTag); overlap > 0 {
// we found a partial tool open tag, so we can emit the unambiguous part,
// which is the (trailing-whitespace trimmed) content before the partial
// tool open tag
beforePartialTag := p.acc.String()[:len(p.acc.String())-overlap]
trailingWhitespaceLen := trailingWhitespaceLen(beforePartialTag)
ambiguousStart := len(beforePartialTag) - trailingWhitespaceLen
unambiguous := p.acc.String()[:ambiguousStart]
ambiguous := p.acc.String()[ambiguousStart:]
p.acc.Reset()
p.acc.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, qwenEventContent{content: unambiguous})
}
return events, false
} else {
// we found content that is entirely not a tool call. We should withhold
// any trailing whitespace in case this is the end of the content
whitespaceLen := trailingWhitespaceLen(p.acc.String())
ambiguousStart := len(p.acc.String()) - whitespaceLen
unambiguous := p.acc.String()[:ambiguousStart]
ambiguous := p.acc.String()[ambiguousStart:]
p.acc.Reset()
p.acc.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, qwenEventContent{content: unambiguous})
}
return events, false
}
case qwenParserState_CollectingToolContent:
if strings.Contains(p.acc.String(), toolCloseTag) {
split := strings.SplitN(p.acc.String(), toolCloseTag, 2)
before := split[0]
if len(before) == 0 {
slog.Warn("qwen tool call closing tag found but no content before it")
}
// remove any whitespace between the tool call and any content after it
after := strings.TrimLeftFunc(split[1], unicode.IsSpace)
p.acc.Reset()
p.acc.WriteString(after)
events = append(events, qwenEventRawToolCall{raw: before})
p.state = qwenParserState_LookingForToolStart
return events, true
} else {
// note that we don't need to check the overlap here because we only plan
// on parsing the tool call once we see the full closing tag. We don't
// stream back the unparsed tool content, so there's no need to be eager
// here
return events, false
}
default:
panic("unreachable")
}
}
type XMLFunctionCall struct {
XMLName xml.Name `xml:"function"`
Name string `xml:"name,attr"`
Parameters []XMLParameter `xml:"parameter"`
}
type XMLParameter struct {
Name string `xml:"name,attr"`
Value string `xml:",chardata"`
}
// parseToolCall parses a raw tool call string into an api.ToolCall.
// The raw string follows an xml-like format, here's an example:
//
// <function=get_current_temperature>
// <parameter=location>
// San Francisco
// </parameter>
// <parameter=unit>
// celsius
// </parameter>
// </function>
func parseToolCall(raw qwenEventRawToolCall, tools []api.Tool) (api.ToolCall, error) {
toolCall := api.ToolCall{}
xmlString := transformToXML(raw.raw)
var functionCall XMLFunctionCall
err := xml.Unmarshal([]byte(xmlString), &functionCall)
if err != nil {
return api.ToolCall{}, err
}
toolCall.Function = api.ToolCallFunction{
Name: functionCall.Name,
}
// Find the matching tool to get parameter types
var matchedTool *api.Tool
for i := range tools {
if tools[i].Function.Name == functionCall.Name {
matchedTool = &tools[i]
break
}
}
toolCall.Function.Arguments = api.NewToolCallFunctionArguments()
for _, parameter := range functionCall.Parameters {
// Look up the parameter type if we found the tool
var paramType api.PropertyType
if matchedTool != nil && matchedTool.Function.Parameters.Properties != nil {
if prop, ok := matchedTool.Function.Parameters.Properties.Get(parameter.Name); ok {
// Handle anyOf by collecting all types from the union
if len(prop.AnyOf) > 0 {
for _, anyOfProp := range prop.AnyOf {
paramType = append(paramType, anyOfProp.Type...)
}
} else {
paramType = prop.Type
}
}
}
toolCall.Function.Arguments.Set(parameter.Name, parseValue(parameter.Value, paramType))
}
return toolCall, nil
}
// parseValue converts a raw string value to the appropriate type based on the parameter type specification.
//
// For union types (multiple types in PropertyType, which we support but doesn't
// seem as though the reference parser does type coercion with those types in
// mind) we use a type precedence approach:
// 1. null - checked first regardless of declared types (matches reference implementation)
// 2. boolean - only "true"/"false" are valid booleans
// 3. integer - must parse as a whole number
// 4. number - must parse as numeric (returns int if no decimal part)
// 5. array - must parse as valid JSON array
// 6. object - must parse as valid JSON object
// 7. string - always succeeds (least specific type)
//
// This precedence ensures we return the most specific type that successfully parses,
// following the principle of least surprise. For example, with PropertyType{"string", "number"},
// "123" becomes 123 (number), while "hello" becomes "hello" (string).
func parseValue(raw string, paramType api.PropertyType) any {
// first remove a single leading newlines, and a single trailing newline (if
// they exist). This follows the reference implementation
raw = strings.TrimPrefix(raw, "\n")
raw = strings.TrimSuffix(raw, "\n")
// Check for null first (case-insensitive) - this takes precedence over any type
if strings.ToLower(raw) == "null" {
return nil
}
// If no type is specified, default to string
if len(paramType) == 0 {
return raw
}
// Check if any of the specified types match, using type precedence
// Order: boolean -> integer -> number -> array -> object -> string
typeSet := make(map[string]bool)
for _, t := range paramType {
typeSet[t] = true
}
// Try boolean first (most restrictive)
if typeSet["boolean"] {
lower := strings.ToLower(raw)
switch lower {
case "true":
return true
case "false":
return false
}
// If not a valid boolean but boolean is the only type, return false (matching reference)
if len(paramType) == 1 {
return false
}
// Otherwise try other types
}
// Try integer
if typeSet["integer"] {
if i, err := strconv.ParseInt(raw, 10, 64); err == nil {
// Return as int if it fits in int32, otherwise int64
if i >= math.MinInt32 && i <= math.MaxInt32 {
return int(i)
}
return i
}
// If integer is the only type and parsing failed, fall back to string
if len(paramType) == 1 {
return raw
}
}
// Try number (float)
if typeSet["number"] {
if f, err := strconv.ParseFloat(raw, 64); err == nil {
// If the number has no decimal part, return as int (matching reference)
if f == math.Trunc(f) {
i := int64(f)
if i >= math.MinInt32 && i <= math.MaxInt32 {
return int(i)
}
return i
}
return f
}
// If number is the only type and parsing failed, fall back to string
if len(paramType) == 1 {
return raw
}
}
// Try array
if typeSet["array"] {
var arr []any
if err := json.Unmarshal([]byte(raw), &arr); err == nil {
return arr
}
// If array is the only type and parsing failed, fall back to string
if len(paramType) == 1 {
return raw
}
}
// Try object
if typeSet["object"] {
var obj map[string]any
if err := json.Unmarshal([]byte(raw), &obj); err == nil {
return obj
}
// If object is the only type and parsing failed, fall back to string
if len(paramType) == 1 {
return raw
}
}
// String always succeeds (or if "string" is in the type set)
if typeSet["string"] {
return raw
}
// If we get here, none of the types matched and string wasn't an option
// We return string as a fallback. The reference implementation will attempt
// to parse the value as a python literal, but we purposefully don't support
// that
return raw
}
var (
qwenTagRegex = regexp.MustCompile(`<(\w+)=([^>]+)>`)
qwenXMLTagRegex = regexp.MustCompile(`</?(?:function|parameter)(?:\s+name="[^"]*")?>`)
)
// transformToXML transforms a raw qwen tool call with xml-like tags into valid
// xml so that it can be parsed by any xml parser
func transformToXML(raw string) string {
// take the form `<tag=abc>` and transform it to `<tag name="abc">`, taking
// care to properly escape the string that becomes the attribute value
transformed := qwenTagRegex.ReplaceAllStringFunc(raw, func(match string) string {
groups := qwenTagRegex.FindStringSubmatch(match)
tag := groups[1]
var escapedValue strings.Builder
_ = xml.EscapeText(&escapedValue, []byte(groups[2])) // error is always nil for strings.Builder
return fmt.Sprintf(`<%s name="%s">`, tag, escapedValue.String())
})
// Walk the resulting string, escaping any character data that sits between the
// xml tags we just emitted
var out strings.Builder
lastIdx := 0
for _, loc := range qwenXMLTagRegex.FindAllStringIndex(transformed, -1) {
if loc[0] > lastIdx {
escapeTextNode(&out, transformed[lastIdx:loc[0]])
}
out.WriteString(transformed[loc[0]:loc[1]])
lastIdx = loc[1]
}
if lastIdx < len(transformed) {
escapeTextNode(&out, transformed[lastIdx:])
}
return out.String()
}
// escapeTextNode escapes XML character data without altering other characters
// like newlines or tabs (which is why we don't use xml.EscapeText for this)
func escapeTextNode(sb *strings.Builder, s string) {
for _, r := range s {
switch r {
case '&':
sb.WriteString("&amp;")
case '<':
sb.WriteString("&lt;")
case '>':
sb.WriteString("&gt;")
default:
sb.WriteRune(r)
}
}
}

File diff suppressed because it is too large Load Diff

261
model/parsers/qwen3vl.go Normal file
View File

@@ -0,0 +1,261 @@
package parsers
import (
"context"
"encoding/json"
"log/slog"
"strings"
"unicode"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/logutil"
)
// TODO: call the init function
const (
CollectingThinkingContent qwenParserState = iota
CollectingContent
CollectingToolContent
ThinkingDoneEatingWhitespace
ToolCallDoneEatingWhitespace
)
const (
thinkingCloseTag = "</think>"
)
type Qwen3VLParser struct {
state qwenParserState
buffer strings.Builder
tools []api.Tool
callIndex int
hasThinkingSupport bool
}
func (p *Qwen3VLParser) HasToolSupport() bool {
return true
}
func (p *Qwen3VLParser) HasThinkingSupport() bool {
return p.hasThinkingSupport
}
func (p *Qwen3VLParser) setInitialState(lastMessage *api.Message) {
prefill := lastMessage != nil && lastMessage.Role == "assistant"
if !p.HasThinkingSupport() {
p.state = CollectingContent
return
}
if prefill && lastMessage.Content != "" {
p.state = CollectingContent
return
}
p.state = CollectingThinkingContent
}
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
}
type qwenEventThinkingContent struct {
content string
}
func (qwenEventThinkingContent) isQwenEvent() {}
func (p *Qwen3VLParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
events := p.parseEvents()
var contentSb strings.Builder
var thinkingSb strings.Builder
for _, event := range events {
switch event := event.(type) {
case qwenEventRawToolCall:
toolCall, err := parseJSONToolCall(event, p.tools)
if err != nil {
slog.Warn("qwen tool call parsing failed", "error", err)
return "", "", nil, err
}
calls = append(calls, toolCall)
case qwenEventThinkingContent:
thinkingSb.WriteString(event.content)
case qwenEventContent:
// TODO(drifkin): if the same turn contains multiple interleaved content
// events, we naively append them together here.
contentSb.WriteString(event.content)
}
}
for i := range calls {
calls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentSb.String(), thinkingSb.String(), calls, nil
}
func (p *Qwen3VLParser) parseEvents() []qwenEvent {
var all []qwenEvent
keepLooping := true
for keepLooping {
var events []qwenEvent
events, keepLooping = p.eat()
if len(events) > 0 {
all = append(all, events...)
}
}
if len(all) > 0 {
slog.Log(context.TODO(), logutil.LevelTrace, "qwen events parsed", "events", all, "state", p.state, "buffer", p.buffer.String())
}
return all
}
func (p *Qwen3VLParser) eatLeadingWhitespaceAndTransitionTo(nextState qwenParserState) ([]qwenEvent, bool) {
trimmed := strings.TrimLeftFunc(p.buffer.String(), unicode.IsSpace)
p.buffer.Reset()
if trimmed == "" {
return nil, false
}
p.state = nextState
p.buffer.WriteString(trimmed)
return nil, true
}
func (p *Qwen3VLParser) eat() ([]qwenEvent, bool) {
var events []qwenEvent
switch p.state {
case CollectingContent:
if strings.Contains(p.buffer.String(), toolOpenTag) {
// events = emitContentBeforeTag(p, events, toolOpenTag)
before, _ := splitAtTag(&p.buffer, toolOpenTag, false)
if len(before) > 0 {
events = append(events, qwenEventContent{content: before})
}
p.state = CollectingToolContent
return events, true
} else if overlapLen := overlap(p.buffer.String(), toolOpenTag); overlapLen > 0 {
beforePartialTag := p.buffer.String()[:len(p.buffer.String())-overlapLen]
trailingWhitespaceLen := trailingWhitespaceLen(beforePartialTag)
ambiguousStart := len(beforePartialTag) - trailingWhitespaceLen
unambiguous := p.buffer.String()[:ambiguousStart]
ambiguous := p.buffer.String()[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, qwenEventContent{content: unambiguous})
}
return events, false
} else {
whitespaceLen := trailingWhitespaceLen(p.buffer.String())
ambiguousStart := len(p.buffer.String()) - whitespaceLen
unambiguous := p.buffer.String()[:ambiguousStart]
ambiguous := p.buffer.String()[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, qwenEventContent{content: unambiguous})
}
return events, false
}
case CollectingToolContent:
if strings.Contains(p.buffer.String(), toolCloseTag) {
split := strings.SplitN(p.buffer.String(), toolCloseTag, 2)
before := split[0] // do we also need to do it to tool calls?
if len(before) == 0 {
slog.Warn("qwen tool call closing tag found but no content before it")
}
after := split[1]
events = append(events, qwenEventRawToolCall{raw: before})
p.buffer.Reset()
p.buffer.WriteString(after)
p.state = ToolCallDoneEatingWhitespace
return events, true
} else {
return events, false
}
case CollectingThinkingContent:
acc := p.buffer.String()
thinkingCloseIdx := strings.Index(acc, thinkingCloseTag)
toolOpenIdx := strings.Index(acc, toolOpenTag)
// If a tool call starts before </think>, treat that as the end of thinking
// for parsing purposes and continue in tool-call mode.
if toolOpenIdx != -1 && (thinkingCloseIdx == -1 || toolOpenIdx < thinkingCloseIdx) {
before, _ := splitAtTag(&p.buffer, toolOpenTag, false)
if len(before) > 0 {
events = append(events, qwenEventThinkingContent{content: before})
}
p.state = CollectingToolContent
return events, true
}
if strings.Contains(acc, thinkingCloseTag) {
thinking, remaining := splitAtTag(&p.buffer, thinkingCloseTag, true)
if len(thinking) > 0 {
events = append(events, qwenEventThinkingContent{content: thinking})
}
if remaining == "" {
p.state = ThinkingDoneEatingWhitespace
} else {
p.state = CollectingContent
}
return events, true
} else if overlapLen := max(overlap(acc, thinkingCloseTag), overlap(acc, toolOpenTag)); overlapLen > 0 {
beforePartialTag := acc[:len(acc)-overlapLen]
trailingWhitespaceLen := trailingWhitespaceLen(beforePartialTag)
ambiguousStart := len(beforePartialTag) - trailingWhitespaceLen
unambiguous := acc[:ambiguousStart]
ambiguous := acc[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, qwenEventThinkingContent{content: unambiguous})
}
return events, false
} else {
whitespaceLen := trailingWhitespaceLen(acc)
ambiguousStart := len(acc) - whitespaceLen
unambiguous := acc[:ambiguousStart]
ambiguous := acc[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, qwenEventThinkingContent{content: unambiguous})
}
return events, false
}
case ThinkingDoneEatingWhitespace:
return p.eatLeadingWhitespaceAndTransitionTo(CollectingContent)
case ToolCallDoneEatingWhitespace:
return p.eatLeadingWhitespaceAndTransitionTo(CollectingContent)
default:
panic("unreachable")
}
}
func parseJSONToolCall(raw qwenEventRawToolCall, tools []api.Tool) (api.ToolCall, error) {
var toolCallFunction api.ToolCallFunction
if err := json.Unmarshal([]byte(raw.raw), &toolCallFunction); err != nil {
return api.ToolCall{}, err
}
toolCall := api.ToolCall{}
toolCall.Function = toolCallFunction
return toolCall, nil
}

View File

@@ -0,0 +1,887 @@
package parsers
import (
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
)
func TestQwen3VLNonThinkingParserStreaming(t *testing.T) {
type step struct {
input string
wantEvents []qwenEvent
}
cases := []struct {
desc string
steps []step
only bool
}{
{
desc: "simple thinking",
steps: []step{
{input: "abc</think>", wantEvents: []qwenEvent{qwenEventContent{content: "abc</think>"}}},
},
},
{
desc: "simple trip thinking",
steps: []step{
{input: "<think>abc</think>", wantEvents: []qwenEvent{qwenEventContent{content: "<think>abc</think>"}}},
},
},
{
desc: "thinking with split tags",
steps: []step{
{input: "abc", wantEvents: []qwenEvent{qwenEventContent{content: "abc"}}},
{input: "</think>", wantEvents: []qwenEvent{qwenEventContent{content: "</think>"}}},
},
},
{
desc: "multiple think tags",
steps: []step{
{input: "abc<think>actually, is not thinking</think>", wantEvents: []qwenEvent{qwenEventContent{content: "abc<think>actually, is not thinking</think>"}}},
},
},
{
desc: "thinking and tool call",
steps: []step{
{
input: "I'm thinking</think><tool_call>I'm tool calling</tool_call>",
wantEvents: []qwenEvent{
qwenEventContent{content: "I'm thinking</think>"},
qwenEventRawToolCall{raw: "I'm tool calling"},
},
},
},
},
{
desc: "nested thinking (outside thinking, inside thinking)",
steps: []step{
{
input: "I'm thinking<think>I'm nested thinking</think></think>",
wantEvents: []qwenEvent{
qwenEventContent{content: "I'm thinking<think>I'm nested thinking</think></think>"},
},
},
},
},
{
desc: "interleaved thinking",
steps: []step{
{
input: "<think>I'm thinking</think>I'm actually content</think>",
wantEvents: []qwenEvent{
qwenEventContent{content: "<think>I'm thinking</think>I'm actually content</think>"},
},
},
},
},
{
desc: "nested thinking and tool call (outside thinking, inside tool call)",
steps: []step{
{
input: "I'm thinking<tool_call>I'm nested tool call</tool_call></think>",
wantEvents: []qwenEvent{
qwenEventContent{content: "I'm thinking"},
qwenEventRawToolCall{raw: "I'm nested tool call"},
qwenEventContent{content: "</think>"},
},
},
},
},
{
desc: "nested thinking and tool call (outside tool call, inside thinking)",
steps: []step{
{
input: "<tool_call>I'm nested tool call<think>I'm thinking</think></tool_call>",
wantEvents: []qwenEvent{
qwenEventRawToolCall{raw: "I'm nested tool call<think>I'm thinking</think>"},
},
},
},
},
{
desc: "interleaved thinking and tool call",
steps: []step{
{
input: "I'm thinking<tool_call>I'm NOT a nested tool call</think></tool_call><tool_call>I'm nested tool call 2<think></tool_call></think>",
wantEvents: []qwenEvent{
qwenEventContent{content: "I'm thinking"},
qwenEventRawToolCall{raw: "I'm NOT a nested tool call</think>"},
qwenEventRawToolCall{raw: "I'm nested tool call 2<think>"},
qwenEventContent{content: "</think>"},
},
},
},
},
{
desc: "emit unambiguous before partial tool open (trailing ws)",
steps: []step{
{
input: "abc\u00a0\n<tool_call",
wantEvents: []qwenEvent{qwenEventContent{content: "abc"}},
},
{
input: " fakeout",
wantEvents: []qwenEvent{qwenEventContent{content: "\u00a0\n<tool_call fakeout"}},
},
},
},
{
desc: "unambiguous empty: partial tool open at buffer start",
steps: []step{
{
input: "<tool_ca",
wantEvents: []qwenEvent{},
},
{
input: "ll>abc</tool_call>",
wantEvents: []qwenEvent{
qwenEventRawToolCall{raw: "abc"},
},
},
},
},
{
desc: "partial thinking tag fakeout",
steps: []step{
{
input: "abc</think",
wantEvents: []qwenEvent{qwenEventContent{content: "abc</think"}},
},
{
input: " fakeout",
wantEvents: []qwenEvent{qwenEventContent{content: " fakeout"}},
},
},
},
{
desc: "partial thinking incomplete",
steps: []step{
{
input: "abc<think>unfinished<", // when something is ambiguious, we dont emit anything
wantEvents: []qwenEvent{qwenEventContent{content: "abc<think>unfinished"}},
},
},
},
{
desc: "test with split tool and content",
steps: []step{
{
input: "abc<tool_call>unfinished</", // when something is ambiguious, we dont emit anything
wantEvents: []qwenEvent{
qwenEventContent{content: "abc"},
},
},
{
input: "tool_call> def",
wantEvents: []qwenEvent{
qwenEventRawToolCall{raw: "unfinished"},
qwenEventContent{content: "def"},
},
},
},
},
}
anyOnlies := false
for _, tc := range cases {
if tc.only {
anyOnlies = true
}
}
for _, tc := range cases {
if anyOnlies && !tc.only {
continue
}
t.Run(tc.desc, func(t *testing.T) {
parser := Qwen3VLParser{hasThinkingSupport: false}
parser.Init([]api.Tool{}, nil, nil)
for i, step := range tc.steps {
parser.buffer.WriteString(step.input)
gotEvents := parser.parseEvents()
if len(gotEvents) == 0 && len(step.wantEvents) == 0 {
// avoid deep equal on empty vs. nil slices
continue
}
if !reflect.DeepEqual(gotEvents, step.wantEvents) {
t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents)
}
}
})
}
}
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
wantEvents []qwenEvent
}
cases := []struct {
desc string
steps []step
only bool
}{
{
desc: "simple message streamed word by word",
steps: []step{
{
input: "hi",
wantEvents: []qwenEvent{qwenEventContent{content: "hi"}},
},
{
input: " there",
wantEvents: []qwenEvent{qwenEventContent{content: " there"}},
},
},
},
{
desc: "content before tool call",
steps: []step{
{
input: "hi there<tool_call>",
wantEvents: []qwenEvent{qwenEventContent{content: "hi there"}},
},
},
},
{
desc: "multiple tool calls in one message",
steps: []step{
{
input: "before1<tool_call>in tool call</tool_call>after1<tool_call>in tool call 2</tool_call>after2",
wantEvents: []qwenEvent{
qwenEventContent{content: "before1"},
qwenEventRawToolCall{raw: "in tool call"},
qwenEventContent{content: "after1"},
qwenEventRawToolCall{raw: "in tool call 2"},
qwenEventContent{content: "after2"},
},
},
},
},
{
desc: "tool calls with split tags",
steps: []step{
{
input: "before<tool",
wantEvents: []qwenEvent{
qwenEventContent{content: "before"},
},
},
{
input: "_call>in tool call</tool",
wantEvents: []qwenEvent{},
},
{
input: "_call>af",
wantEvents: []qwenEvent{
qwenEventRawToolCall{raw: "in tool call"},
qwenEventContent{content: "af"},
},
},
{
input: "ter",
wantEvents: []qwenEvent{
qwenEventContent{content: "ter"},
},
},
},
},
{
desc: "trailing whitespace between content and tool call",
steps: []step{
{
input: "abc\n<tool_call>def</tool_call>",
wantEvents: []qwenEvent{
qwenEventContent{content: "abc"},
qwenEventRawToolCall{raw: "def"},
},
},
},
},
{
desc: "trailing whitespace between tool call and content",
steps: []step{
{
input: "<tool_call>abc</tool_call>\ndef",
wantEvents: []qwenEvent{
qwenEventRawToolCall{raw: "abc"},
qwenEventContent{content: "def"},
},
},
},
},
{
desc: "empty content before tool call",
steps: []step{
{
input: "\n<tool_call>abc</tool_call>",
wantEvents: []qwenEvent{
qwenEventRawToolCall{raw: "abc"},
},
},
},
},
{
desc: "partial tool open tag fakeout",
steps: []step{
{
input: "abc\n<tool_call",
wantEvents: []qwenEvent{
// \n should not be emitted yet because `<tool_call` might be a tool
// open tag, in which case the whitespace should be trimmed
qwenEventContent{content: "abc"},
},
},
{
input: " fakeout",
wantEvents: []qwenEvent{
qwenEventContent{content: "\n<tool_call fakeout"},
},
},
},
},
{
desc: "token-by-token whitespace handling",
steps: []step{
{
input: "a",
wantEvents: []qwenEvent{
qwenEventContent{content: "a"},
},
},
{
input: "\n",
wantEvents: []qwenEvent{},
},
{
input: "b",
wantEvents: []qwenEvent{
qwenEventContent{content: "\nb"},
},
},
},
},
{
desc: "unicode content",
steps: []step{
{
input: "你好 🌍<tool_call>test</tool_call>مرحبا",
wantEvents: []qwenEvent{
qwenEventContent{content: "你好 🌍"},
qwenEventRawToolCall{raw: "test"},
qwenEventContent{content: "مرحبا"},
},
},
},
},
{
desc: "arabic text handling",
steps: []step{
{
input: "مرحبا بالعالم",
wantEvents: []qwenEvent{qwenEventContent{content: "مرحبا بالعالم"}},
},
},
},
{
desc: "emoji passthrough",
steps: []step{
{
input: "✅",
wantEvents: []qwenEvent{qwenEventContent{content: "✅"}},
},
},
},
{
desc: "emoji after tool call",
steps: []step{
{
input: "<tool_call>test</tool_call>完成 ✅",
wantEvents: []qwenEvent{
qwenEventRawToolCall{raw: "test"},
qwenEventContent{content: "完成 ✅"},
},
},
},
},
{
desc: "unicode streaming with whitespace handling",
steps: []step{
{
input: "مرحبا",
wantEvents: []qwenEvent{
qwenEventContent{content: "مرحبا"},
},
},
{
input: " \n",
wantEvents: []qwenEvent{},
},
{
input: "世界",
wantEvents: []qwenEvent{
qwenEventContent{content: " \n世界"},
},
},
},
},
{
desc: "non-breaking space withheld across chunks",
steps: []step{
{
input: "Hello\u00a0",
wantEvents: []qwenEvent{
qwenEventContent{content: "Hello"},
},
},
{
input: "world",
wantEvents: []qwenEvent{
qwenEventContent{content: "\u00a0world"},
},
},
},
},
{
desc: "ideographic space before partial tool",
steps: []step{
{
input: "Hello\u3000<tool",
wantEvents: []qwenEvent{
qwenEventContent{content: "Hello"},
},
},
{
input: "_call>abc",
wantEvents: []qwenEvent{},
},
{
input: "</tool_call>def",
wantEvents: []qwenEvent{
qwenEventRawToolCall{raw: "abc"},
qwenEventContent{content: "def"},
},
},
},
},
{
desc: "ideographic space before partial tool fakeout",
steps: []step{
{
input: "Hello\u3000<tool",
wantEvents: []qwenEvent{
qwenEventContent{content: "Hello"},
},
},
{
input: "fakeout>abc",
wantEvents: []qwenEvent{
qwenEventContent{content: "\u3000<toolfakeout>abc"},
},
},
},
},
{
desc: "unicode with partial tool tag",
steps: []step{
{
input: "测试🎯 <to",
wantEvents: []qwenEvent{
qwenEventContent{content: "测试🎯"},
},
},
},
},
}
anyOnlies := false
for _, tc := range cases {
if tc.only {
anyOnlies = true
}
}
for _, tc := range cases {
if anyOnlies && !tc.only {
continue
}
t.Run(tc.desc, func(t *testing.T) {
parser := Qwen3VLParser{hasThinkingSupport: false}
parser.Init([]api.Tool{}, nil, nil)
for i, step := range tc.steps {
parser.buffer.WriteString(step.input)
gotEvents := parser.parseEvents()
if len(gotEvents) == 0 && len(step.wantEvents) == 0 {
// avoid deep equal on empty vs. nil slices
continue
}
if !reflect.DeepEqual(gotEvents, step.wantEvents) {
t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents)
}
}
})
}
}
func TestQwen3VLNonThinkingToolParser(t *testing.T) {
type step struct {
name string
rawToolCall string
tools []api.Tool
wantToolCall api.ToolCall
}
steps := []step{
{
name: "simple tool call",
tools: []api.Tool{},
rawToolCall: `{"name": "get-current-weather", "arguments": {"location": "San Francisco, CA", "unit": "fahrenheit"}}`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "get-current-weather",
Arguments: testArgs(map[string]any{
"location": "San Francisco, CA",
"unit": "fahrenheit",
}),
},
},
},
{
name: "names with spaces",
tools: []api.Tool{},
rawToolCall: `{"name": "get current temperature", "arguments": {"location with spaces": "San Francisco", "unit with spaces": "celsius"}}`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "get current temperature",
Arguments: testArgs(map[string]any{
"location with spaces": "San Francisco",
"unit with spaces": "celsius",
}),
},
},
},
{
name: "names with quotes",
tools: []api.Tool{},
rawToolCall: `{"name": "\"get current temperature\"", "arguments": {"\"location with spaces\"": "San Francisco", "\"unit with spaces\"": "\"celsius\""}}`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "\"get current temperature\"",
Arguments: testArgs(map[string]any{
"\"location with spaces\"": "San Francisco",
"\"unit with spaces\"": "\"celsius\"",
}),
},
},
},
{
name: "tool call with typed parameters (json types)",
tools: []api.Tool{},
rawToolCall: `{"name": "calculate", "arguments": {"x": 3.14, "y": 42, "enabled": true, "items": ["a", "b", "c"]}}`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "calculate",
Arguments: testArgs(map[string]any{
"x": 3.14,
"y": float64(42),
"enabled": true,
"items": []any{"a", "b", "c"},
}),
},
},
},
{
name: "ampersands in parameter values",
tools: []api.Tool{},
rawToolCall: `{"name": "exec", "arguments": {"command": "ls && echo \"done\""}}`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "exec",
Arguments: testArgs(map[string]any{
"command": "ls && echo \"done\"",
}),
},
},
},
{
name: "angle brackets in parameter values",
tools: []api.Tool{},
rawToolCall: `{"name": "exec", "arguments": {"command": "ls && echo \"a > b and a < b\""}}`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "exec",
Arguments: testArgs(map[string]any{
"command": "ls && echo \"a > b and a < b\"",
}),
},
},
},
{
name: "unicode in function names and parameters",
tools: []api.Tool{},
rawToolCall: `{"name": "获取天气", "arguments": {"城市": "北京", "message": "Hello! 你好! 🌟 مرحبا"}}`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "获取天气",
Arguments: testArgs(map[string]any{
"城市": "北京",
"message": "Hello! 你好! 🌟 مرحبا",
}),
},
},
},
}
for i, step := range steps {
gotToolCall, err := parseJSONToolCall(qwenEventRawToolCall{raw: step.rawToolCall}, step.tools)
if err != nil {
t.Errorf("step %d (%s): %v", i, step.name, err)
}
if !toolCallEqual(gotToolCall, step.wantToolCall) {
t.Errorf("step %d (%s): got tool call %#v, want %#v", i, step.name, gotToolCall, step.wantToolCall)
}
}
}
func TestQwen3VLNonThinkingToolCallWhitespaceHandling(t *testing.T) {
type step struct {
input string
wantEvents []qwenEvent
}
cases := []struct {
desc string
steps []step
only bool
}{
{
desc: "whitespace inside tool call preserves trailing space",
steps: []step{
{
input: "before<tool_call> tool content </tool_call>after",
wantEvents: []qwenEvent{
qwenEventContent{content: "before"},
qwenEventRawToolCall{raw: " tool content "},
qwenEventContent{content: "after"},
},
},
},
},
{
desc: "whitespace inside tool call preserves trailing space",
steps: []step{
{
input: "\n \n \n \n \n \n blahhhhhhhhhh blahhhh blahhhh \n\n\n\t\t <tool_call> tool content </tool_call> \n\n\n\n\n\n\n after",
wantEvents: []qwenEvent{
qwenEventContent{content: "\n \n \n \n \n \n blahhhhhhhhhh blahhhh blahhhh"},
qwenEventRawToolCall{raw: " tool content "},
qwenEventContent{content: "after"},
},
},
},
},
{
desc: "whitespace inside tool call preserves trailing space",
steps: []step{
{
input: "<tool_call> tool content </tool_call> ",
wantEvents: []qwenEvent{
qwenEventRawToolCall{raw: " tool content "},
},
},
{
input: "\n \n \n \n \n \n blahhhhhhhhhh blahhhh blahhhh \n\n\n\t\t <tool_call> anotha one </tool_call> \n\n\n\n\n\n\n after \n\n\n\n\n\n blep",
wantEvents: []qwenEvent{
qwenEventContent{content: "blahhhhhhhhhh blahhhh blahhhh"},
qwenEventRawToolCall{raw: " anotha one "},
qwenEventContent{content: "after \n\n\n\n\n\n blep"},
},
},
},
},
{
desc: "whitespace between content and tool call",
steps: []step{
{
input: "content \n <tool_call>tool</tool_call> \n more content",
wantEvents: []qwenEvent{
qwenEventContent{content: "content"},
qwenEventRawToolCall{raw: "tool"},
qwenEventContent{content: "more content"},
},
},
},
},
{
desc: "consecutive tool calls with whitespace",
steps: []step{
{
input: "<tool_call>first</tool_call> \n <tool_call>second</tool_call> \n <tool_call>third</tool_call>",
wantEvents: []qwenEvent{
qwenEventRawToolCall{raw: "first"},
qwenEventRawToolCall{raw: "second"},
qwenEventRawToolCall{raw: "third"},
},
},
},
},
{
desc: "whitespace before and after tool open tag",
steps: []step{
{
input: "text \n <tool_call>content</tool_call>",
wantEvents: []qwenEvent{
qwenEventContent{content: "text"},
qwenEventRawToolCall{raw: "content"},
},
},
},
},
{
desc: "unicode whitespace around tool calls",
steps: []step{
{
input: "text\u00a0\u3000<tool_call>content</tool_call>\u00a0\u3000text",
wantEvents: []qwenEvent{
qwenEventContent{content: "text"},
qwenEventRawToolCall{raw: "content"},
qwenEventContent{content: "text"},
},
},
},
},
{
desc: "empty tool call with surrounding whitespace",
steps: []step{
{
input: "before <tool_call></tool_call> after",
wantEvents: []qwenEvent{
qwenEventContent{content: "before"},
qwenEventRawToolCall{raw: ""},
qwenEventContent{content: "after"},
},
},
},
},
{
desc: "whitespace in tool call split across chunks",
steps: []step{
{
input: "before<tool_call> ",
wantEvents: []qwenEvent{qwenEventContent{content: "before"}},
},
{
input: "tool",
wantEvents: []qwenEvent{},
},
{
input: " </tool_call>after",
wantEvents: []qwenEvent{
qwenEventRawToolCall{raw: " tool "},
qwenEventContent{content: "after"},
},
},
},
},
{
desc: "mixed whitespace types between tool calls",
steps: []step{
{
input: "<tool_call>first</tool_call> \t\n\r <tool_call>second</tool_call>",
wantEvents: []qwenEvent{
qwenEventRawToolCall{raw: "first"},
qwenEventRawToolCall{raw: "second"},
},
},
},
},
}
anyOnlies := false
for _, tc := range cases {
if tc.only {
anyOnlies = true
}
}
for _, tc := range cases {
if anyOnlies && !tc.only {
continue
}
t.Run(tc.desc, func(t *testing.T) {
parser := Qwen3VLParser{hasThinkingSupport: false}
parser.Init([]api.Tool{}, nil, nil)
for i, step := range tc.steps {
parser.buffer.WriteString(step.input)
gotEvents := parser.parseEvents()
if len(gotEvents) == 0 && len(step.wantEvents) == 0 {
continue
}
if !reflect.DeepEqual(gotEvents, step.wantEvents) {
t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents)
}
}
})
}
}

View File

@@ -0,0 +1,881 @@
package parsers
import (
"reflect"
"testing"
"github.com/ollama/ollama/api"
)
func TestQwen3VLThinkingParserStreaming(t *testing.T) {
type step struct {
input string
wantEvents []qwenEvent
}
cases := []struct {
desc string
steps []step
only bool
}{
{
desc: "simple thinking",
steps: []step{
{input: "abc</think>", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "abc"}}},
},
},
{
desc: "simple trip thinking",
steps: []step{
{input: "<think>abc</think>", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "<think>abc"}}},
},
},
{
desc: "thinking with split tags",
steps: []step{
{input: "abc", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "abc"}}},
{input: "</think>", wantEvents: []qwenEvent{}},
},
},
{
desc: "multiple think tags",
steps: []step{
{input: "abc<think>actually, is not thinking</think>", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "abc<think>actually, is not thinking"}}},
},
},
{
desc: "thinking and tool call",
steps: []step{
{
input: "I'm thinking</think><tool_call>I'm tool calling</tool_call>",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "I'm thinking"},
qwenEventRawToolCall{raw: "I'm tool calling"},
},
},
},
},
{
desc: "thinking and content",
steps: []step{
{
input: "I'm thinking</think>I'm content",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "I'm thinking"},
qwenEventContent{content: "I'm content"},
},
},
},
},
{
desc: "thinking and tool call and content",
},
{
desc: "nested thinking (outside thinking, inside thinking)",
steps: []step{
{
input: "I'm thinking<think>I'm nested thinking</think></think>",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "I'm thinking<think>I'm nested thinking"},
qwenEventContent{content: "</think>"},
},
},
},
},
{
desc: "interleaved thinking",
steps: []step{
{
input: "<think>I'm thinking</think>I'm actually content</think>",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "<think>I'm thinking"},
qwenEventContent{content: "I'm actually content</think>"},
},
},
},
},
{
desc: "nested thinking and tool call (outside thinking, inside tool call)",
steps: []step{
{
input: "I'm thinking<tool_call>I'm nested tool call</tool_call></think>",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "I'm thinking"},
qwenEventRawToolCall{raw: "I'm nested tool call"},
qwenEventContent{content: "</think>"},
},
},
},
},
{
desc: "nested thinking and tool call (outside tool call, inside thinking)",
steps: []step{
{
input: "<tool_call>I'm nested tool call<think>I'm thinking</think></tool_call>",
wantEvents: []qwenEvent{
qwenEventRawToolCall{raw: "I'm nested tool call<think>I'm thinking</think>"},
},
},
},
},
{
desc: "interleaved thinking and tool call",
steps: []step{
{
input: "I'm thinking<tool_call>I'm NOT a nested tool call</think></tool_call><tool_call>I'm nested tool call 2<think></tool_call></think>",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "I'm thinking"},
qwenEventRawToolCall{raw: "I'm NOT a nested tool call</think>"},
qwenEventRawToolCall{raw: "I'm nested tool call 2<think>"},
qwenEventContent{content: "</think>"},
},
},
},
},
{
desc: "partial thinking tag fakeout",
steps: []step{
{
input: "abc</think",
wantEvents: []qwenEvent{qwenEventThinkingContent{content: "abc"}},
},
{
input: " fakeout",
wantEvents: []qwenEvent{qwenEventThinkingContent{content: "</think fakeout"}},
},
},
},
{
desc: "partial thinking incomplete",
steps: []step{
{
input: "abc<think>unfinished</think", // when something is ambiguious, we dont emit anything
wantEvents: []qwenEvent{qwenEventThinkingContent{content: "abc<think>unfinished"}},
},
},
},
{
desc: "test with split thinking and content",
steps: []step{
{
input: "abc<think>unfinished</th", // when something is ambiguious, we dont emit anything
wantEvents: []qwenEvent{qwenEventThinkingContent{content: "abc<think>unfinished"}},
},
{
input: "ink> def",
wantEvents: []qwenEvent{
qwenEventContent{content: "def"},
},
},
},
},
{
desc: "thinking with no tags",
steps: []step{
{
input: "Hello I am thinking",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "Hello I am thinking"},
},
},
{
input: "Hello I am thinking some more",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "Hello I am thinking some more"},
},
},
{
input: "Hello I am think</think> NOT",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "Hello I am think"},
qwenEventContent{content: "NOT"},
},
},
},
},
}
anyOnlies := false
for _, tc := range cases {
if tc.only {
anyOnlies = true
}
}
for _, tc := range cases {
if anyOnlies && !tc.only {
continue
}
t.Run(tc.desc, func(t *testing.T) {
parser := Qwen3VLParser{hasThinkingSupport: true}
parser.Init([]api.Tool{}, nil, nil)
// parser.state = CollectingThinkingContent
for i, step := range tc.steps {
parser.buffer.WriteString(step.input)
gotEvents := parser.parseEvents()
if len(gotEvents) == 0 && len(step.wantEvents) == 0 {
// avoid deep equal on empty vs. nil slices
continue
}
if !reflect.DeepEqual(gotEvents, step.wantEvents) {
t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents)
}
}
})
}
}
func TestQwen3VLThinkingToolParser(t *testing.T) {
type step struct {
name string
rawToolCall string
tools []api.Tool
wantToolCall api.ToolCall
}
steps := []step{
{
name: "simple tool call",
tools: []api.Tool{},
rawToolCall: `{"name": "get-current-weather", "arguments": {"location": "San Francisco, CA", "unit": "fahrenheit"}}`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "get-current-weather",
Arguments: testArgs(map[string]any{
"location": "San Francisco, CA",
"unit": "fahrenheit",
}),
},
},
},
{
name: "names with spaces",
tools: []api.Tool{},
rawToolCall: `{"name": "get current temperature", "arguments": {"location with spaces": "San Francisco", "unit with spaces": "celsius"}}`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "get current temperature",
Arguments: testArgs(map[string]any{
"location with spaces": "San Francisco",
"unit with spaces": "celsius",
}),
},
},
},
{
name: "names with quotes",
tools: []api.Tool{},
rawToolCall: `{"name": "\"get current temperature\"", "arguments": {"\"location with spaces\"": "San Francisco", "\"unit with spaces\"": "\"celsius\""}}`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "\"get current temperature\"",
Arguments: testArgs(map[string]any{
"\"location with spaces\"": "San Francisco",
"\"unit with spaces\"": "\"celsius\"",
}),
},
},
},
{
name: "tool call with typed parameters (json types)",
tools: []api.Tool{},
rawToolCall: `{"name": "calculate", "arguments": {"x": 3.14, "y": 42, "enabled": true, "items": ["a", "b", "c"]}}`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "calculate",
Arguments: testArgs(map[string]any{
"x": 3.14,
"y": float64(42),
"enabled": true,
"items": []any{"a", "b", "c"},
}),
},
},
},
{
name: "ampersands in parameter values",
tools: []api.Tool{},
rawToolCall: `{"name": "exec", "arguments": {"command": "ls && echo \"done\""}}`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "exec",
Arguments: testArgs(map[string]any{
"command": "ls && echo \"done\"",
}),
},
},
},
{
name: "angle brackets in parameter values",
tools: []api.Tool{},
rawToolCall: `{"name": "exec", "arguments": {"command": "ls && echo \"a > b and a < b\""}}`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "exec",
Arguments: testArgs(map[string]any{
"command": "ls && echo \"a > b and a < b\"",
}),
},
},
},
{
name: "unicode in function names and parameters",
tools: []api.Tool{},
rawToolCall: `{"name": "获取天气", "arguments": {"城市": "北京", "message": "Hello! 你好! 🌟 مرحبا"}}`,
wantToolCall: api.ToolCall{
Function: api.ToolCallFunction{
Name: "获取天气",
Arguments: testArgs(map[string]any{
"城市": "北京",
"message": "Hello! 你好! 🌟 مرحبا",
}),
},
},
},
}
for i, step := range steps {
gotToolCall, err := parseJSONToolCall(qwenEventRawToolCall{raw: step.rawToolCall}, step.tools)
if err != nil {
t.Errorf("step %d (%s): %v", i, step.name, err)
}
if !toolCallEqual(gotToolCall, step.wantToolCall) {
t.Errorf("step %d (%s): got tool call %#v, want %#v", i, step.name, gotToolCall, step.wantToolCall)
}
}
}
func TestQwen3VLParserState(t *testing.T) {
cases := []struct {
desc string
hasThinking bool
last *api.Message
wantState qwenParserState
}{
{
desc: "no thinking support => CollectingContent",
hasThinking: false,
last: nil,
wantState: CollectingContent,
},
{
desc: "thinking support, no last message => CollectingThinkingContent",
hasThinking: true,
last: nil,
wantState: CollectingThinkingContent,
},
{
desc: "thinking support, last assistant with empty content => CollectingThinkingContent",
hasThinking: true,
last: &api.Message{Role: "assistant", Content: ""},
wantState: CollectingThinkingContent,
},
{
desc: "thinking support, last assistant with content => CollectingContent",
hasThinking: true,
last: &api.Message{Role: "assistant", Content: "hello"},
wantState: CollectingContent,
},
{
desc: "thinking support, last is user => CollectingThinkingContent",
hasThinking: true,
last: &api.Message{Role: "user", Content: "hi"},
wantState: CollectingThinkingContent,
},
}
for _, tc := range cases {
parser := Qwen3VLParser{hasThinkingSupport: tc.hasThinking}
parser.Init(nil, tc.last, nil)
if parser.state != tc.wantState {
t.Errorf("%s: got state %v, want %v", tc.desc, parser.state, tc.wantState)
}
}
}
func TestQwen3VLThinkingParserWithThinkingPrefill(t *testing.T) {
type step struct {
input string
wantEvents []qwenEvent
}
cases := []struct {
desc string
steps []step
only bool
}{
{
desc: "thinking prefill",
steps: []step{
{input: "abc</think>", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "abc"}}},
},
},
{
desc: "thinking prefill with content",
steps: []step{
{input: "abc</th", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "abc"}}},
{input: "ink> def", wantEvents: []qwenEvent{qwenEventContent{content: "def"}}},
},
},
{
desc: "thinking prefill with fakeout",
steps: []step{
{input: "abc</think", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "abc"}}},
{input: " fakeout </think", wantEvents: []qwenEvent{qwenEventThinkingContent{content: "</think fakeout"}}},
{input: ">", wantEvents: []qwenEvent{}},
},
},
{
desc: "thinking prefill with spaces",
steps: []step{
{input: " </think> starting content", wantEvents: []qwenEvent{qwenEventContent{content: "starting content"}}},
},
},
}
last := &api.Message{Role: "assistant", Thinking: "i am thinking"} // so if there is thinking the test is still thinking
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
parser := Qwen3VLParser{hasThinkingSupport: true}
parser.Init([]api.Tool{}, last, nil)
for i, step := range tc.steps {
parser.buffer.WriteString(step.input)
gotEvents := parser.parseEvents()
if len(gotEvents) == 0 && len(step.wantEvents) == 0 {
// avoid deep equal on empty vs. nil slices
continue
}
if !reflect.DeepEqual(gotEvents, step.wantEvents) {
t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents)
}
}
})
}
}
func TestQwen3VLThinkingParserWithNonThinkingPrefill(t *testing.T) {
type step struct {
input string
wantEvents []qwenEvent
}
cases := []struct {
desc string
steps []step
only bool
}{
{
desc: "thinking prefill",
steps: []step{
{input: "abc</think>", wantEvents: []qwenEvent{qwenEventContent{content: "abc</think>"}}},
},
},
{
desc: "thinking prefill with content",
steps: []step{
{input: "abc</th", wantEvents: []qwenEvent{qwenEventContent{content: "abc</th"}}},
{input: "ink> def", wantEvents: []qwenEvent{qwenEventContent{content: "ink> def"}}},
},
},
{
desc: "thinking prefill with fakeout",
steps: []step{
{input: "abc</think", wantEvents: []qwenEvent{qwenEventContent{content: "abc</think"}}},
{input: " fakeout </think", wantEvents: []qwenEvent{qwenEventContent{content: " fakeout </think"}}},
{input: ">", wantEvents: []qwenEvent{qwenEventContent{content: ">"}}},
},
},
{
desc: "thinking prefill with spaces",
steps: []step{
{input: " </think> starting content", wantEvents: []qwenEvent{qwenEventContent{content: " </think> starting content"}}},
},
},
}
last := &api.Message{Role: "assistant", Thinking: "i am thinking", Content: "i am content"} // so if there is thinking the test is still thinking
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
parser := Qwen3VLParser{hasThinkingSupport: true}
parser.Init([]api.Tool{}, last, nil)
for i, step := range tc.steps {
parser.buffer.WriteString(step.input)
gotEvents := parser.parseEvents()
if len(gotEvents) == 0 && len(step.wantEvents) == 0 {
// avoid deep equal on empty vs. nil slices
continue
}
if !reflect.DeepEqual(gotEvents, step.wantEvents) {
t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents)
}
}
})
}
}
func TestQwen3VLThinkingParserStreamingAssistantPrefillContent(t *testing.T) {
// last message is assistant with content ⇒ start in CollectingContent
last := &api.Message{Role: "assistant", Content: "has content"}
parser := Qwen3VLParser{hasThinkingSupport: true}
parser.Init([]api.Tool{}, last, nil)
type step struct {
input string
wantEvents []qwenEvent
}
steps := []step{
{input: "abc</think>", wantEvents: []qwenEvent{qwenEventContent{content: "abc</think>"}}},
{input: "<tool_call>{\"name\": \"x\", \"arguments\": {}}</tool_call>", wantEvents: []qwenEvent{qwenEventRawToolCall{raw: "{\"name\": \"x\", \"arguments\": {}}"}}},
}
for i, s := range steps {
parser.buffer.WriteString(s.input)
gotEvents := parser.parseEvents()
if len(gotEvents) == 0 && len(s.wantEvents) == 0 {
continue
}
if !reflect.DeepEqual(gotEvents, s.wantEvents) {
t.Fatalf("step %d: input %q: got %#v, want %#v", i, s.input, gotEvents, s.wantEvents)
}
}
}
func TestQwen3VLThinkingWhitespaceHandling(t *testing.T) {
type step struct {
input string
wantEvents []qwenEvent
}
cases := []struct {
desc string
steps []step
only bool
}{
{
desc: "whitespace after thinking tag is trimmed",
steps: []step{
{
input: "thinking content</think> \n\t content starts here",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "thinking content"},
qwenEventContent{content: "content starts here"},
},
},
},
},
{
desc: "whitespace after thinking tag split across chunks",
steps: []step{
{
input: "thinking content</think> ",
wantEvents: []qwenEvent{qwenEventThinkingContent{content: "thinking content"}},
},
{
input: " \n\t",
wantEvents: []qwenEvent{},
},
{
input: "content",
wantEvents: []qwenEvent{
qwenEventContent{content: "content"},
},
},
},
},
{
desc: "only whitespace after thinking tag",
steps: []step{
{
input: "thinking content</think> \n\t ",
wantEvents: []qwenEvent{qwenEventThinkingContent{content: "thinking content"}},
},
},
},
{
desc: "multiple spaces and tabs after thinking",
steps: []step{
{
input: "think</think> \t\t\n\n text",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "think"},
qwenEventContent{content: "text"},
},
},
},
},
{
desc: "trailing whitespace before thinking tag is preserved in content",
steps: []step{
{
input: "thinking with spaces </think>text",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "thinking with spaces"},
qwenEventContent{content: "text"},
},
},
},
},
{
desc: "whitespace between thinking and tool call",
steps: []step{
{
input: "thinking</think> \n <tool_call>{\"name\":\"test\"}</tool_call>",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "thinking"},
qwenEventRawToolCall{raw: "{\"name\":\"test\"}"},
},
},
},
},
{
desc: "no whitespace after thinking tag",
steps: []step{
{
input: "thinking</think>content",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "thinking"},
qwenEventContent{content: "content"},
},
},
},
},
{
desc: "unicode whitespace after thinking tag",
steps: []step{
{
input: "thinking</think>\u00a0\u3000content",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "thinking"},
qwenEventContent{content: "content"},
},
},
},
},
{
desc: "whitespace split with partial thinking tag",
steps: []step{
{
input: "thinking</th",
wantEvents: []qwenEvent{qwenEventThinkingContent{content: "thinking"}},
},
{
input: "ink> \n",
wantEvents: []qwenEvent{},
},
{
input: " content",
wantEvents: []qwenEvent{
qwenEventContent{content: "content"},
},
},
},
},
{
desc: "empty thinking tag with whitespace after",
steps: []step{
{
input: "</think> \ncontent",
wantEvents: []qwenEvent{
qwenEventContent{content: "content"},
},
},
},
},
{
desc: "whitespace inside tool call preserves trailing space",
steps: []step{
{
input: "bruh</think> \n \n \n \n \n \n blahhhhhhhhhh blahhhh blahhhh \n\n\n\t\t <tool_call> tool content </tool_call> \n\n\n\n\n\n\n after",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "bruh"},
qwenEventContent{content: "blahhhhhhhhhh blahhhh blahhhh"},
qwenEventRawToolCall{raw: " tool content "},
qwenEventContent{content: "after"},
},
},
},
},
{
desc: "whitespace inside tool call preserves trailing space",
steps: []step{
{
input: "bruh</think> shdjfhksdhfj ",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "bruh"},
qwenEventContent{content: "shdjfhksdhfj"},
},
},
{
input: "another word ",
wantEvents: []qwenEvent{
qwenEventContent{content: " another word"},
},
},
{
input: "<tool_call> tool content </tool_call> ",
wantEvents: []qwenEvent{
qwenEventRawToolCall{raw: " tool content "},
},
},
{
input: "\n \n \n \n \n \n blahhhhhhhhhh blahhhh blahhhh \n\n\n\t\t <tool_call> anotha one </tool_call> \n\n\n\n\n\n\n after \n\n\n\n\n\n blep",
wantEvents: []qwenEvent{
qwenEventContent{content: "blahhhhhhhhhh blahhhh blahhhh"},
qwenEventRawToolCall{raw: " anotha one "},
qwenEventContent{content: "after \n\n\n\n\n\n blep"},
},
},
},
},
}
anyOnlies := false
for _, tc := range cases {
if tc.only {
anyOnlies = true
}
}
for _, tc := range cases {
if anyOnlies && !tc.only {
continue
}
t.Run(tc.desc, func(t *testing.T) {
parser := Qwen3VLParser{hasThinkingSupport: true}
parser.Init([]api.Tool{}, nil, nil)
for i, step := range tc.steps {
parser.buffer.WriteString(step.input)
gotEvents := parser.parseEvents()
if len(gotEvents) == 0 && len(step.wantEvents) == 0 {
continue
}
if !reflect.DeepEqual(gotEvents, step.wantEvents) {
t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents)
}
}
})
}
}
func TestQwen3VLToolCallWhitespaceHandling(t *testing.T) {
type step struct {
input string
wantEvents []qwenEvent
}
cases := []struct {
desc string
steps []step
only bool
prefillMsg *api.Message // allows starting in content mode instead of thinking mode
}{
{
desc: "whitespace inside tool call is fully preserved (with content prefill)",
prefillMsg: &api.Message{Role: "assistant", Content: "prefill"},
steps: []step{
{
input: "before<tool_call> tool content </tool_call> \n after",
wantEvents: []qwenEvent{
qwenEventContent{content: "before"},
qwenEventRawToolCall{raw: " tool content "},
qwenEventContent{content: "after"},
},
},
},
},
{
desc: "whitespace after tool call trimmed across chunks (with content prefill)",
prefillMsg: &api.Message{Role: "assistant", Content: "prefill"},
steps: []step{
{
input: "before<tool_call>tool</tool_call> ",
wantEvents: []qwenEvent{
qwenEventContent{content: "before"},
qwenEventRawToolCall{raw: "tool"},
},
},
{
input: "\n\t",
wantEvents: []qwenEvent{},
},
{
input: "after \n this is a song",
wantEvents: []qwenEvent{
qwenEventContent{content: "after \n this is a song"},
},
},
},
},
{
desc: "multiple tool calls with whitespace between (with content prefill)",
prefillMsg: &api.Message{Role: "assistant", Content: "prefill"},
steps: []step{
{
input: "<tool_call>first</tool_call> \n <tool_call>second</tool_call>",
wantEvents: []qwenEvent{
qwenEventRawToolCall{raw: "first"},
qwenEventRawToolCall{raw: "second"},
},
},
},
},
{
desc: "thinking with whitespace then tool call",
steps: []step{
{
input: "thinking</think> \n <tool_call>tool</tool_call> \n content",
wantEvents: []qwenEvent{
qwenEventThinkingContent{content: "thinking"},
qwenEventRawToolCall{raw: "tool"},
qwenEventContent{content: "content"},
},
},
},
},
}
anyOnlies := false
for _, tc := range cases {
if tc.only {
anyOnlies = true
}
}
for _, tc := range cases {
if anyOnlies && !tc.only {
continue
}
t.Run(tc.desc, func(t *testing.T) {
parser := Qwen3VLParser{hasThinkingSupport: true}
parser.Init([]api.Tool{}, tc.prefillMsg, nil)
for i, step := range tc.steps {
parser.buffer.WriteString(step.input)
gotEvents := parser.parseEvents()
if len(gotEvents) == 0 && len(step.wantEvents) == 0 {
continue
}
if !reflect.DeepEqual(gotEvents, step.wantEvents) {
t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents)
}
}
})
}
}

View File

@@ -0,0 +1,106 @@
package parsers
import (
"encoding/json"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
)
// argsComparer provides cmp options for comparing ToolCallFunctionArguments
// It compares by logical equality (same keys with same values) not by order
var argsComparer = cmp.Comparer(func(a, b api.ToolCallFunctionArguments) bool {
// Convert both to maps and compare
aMap := a.ToMap()
bMap := b.ToMap()
if len(aMap) != len(bMap) {
return false
}
for k, av := range aMap {
bv, ok := bMap[k]
if !ok {
return false
}
// Use JSON encoding for deep comparison of values
aJSON, _ := json.Marshal(av)
bJSON, _ := json.Marshal(bv)
if string(aJSON) != string(bJSON) {
return false
}
}
return true
})
// propsComparer provides cmp options for comparing ToolPropertiesMap
var propsComparer = cmp.Comparer(func(a, b *api.ToolPropertiesMap) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
aJSON, _ := json.Marshal(a)
bJSON, _ := json.Marshal(b)
return string(aJSON) == string(bJSON)
})
// toolsComparer combines argsComparer and propsComparer for comparing tools
var toolsComparer = cmp.Options{argsComparer, propsComparer}
// toolCallEqual compares two tool calls by comparing their components
// It compares arguments by logical equality (same keys with same values) not by order
func toolCallEqual(a, b api.ToolCall) bool {
if a.ID != b.ID {
return false
}
if a.Function.Index != b.Function.Index {
return false
}
if a.Function.Name != b.Function.Name {
return false
}
// Compare arguments by logical equality using argsComparer logic
aMap := a.Function.Arguments.ToMap()
bMap := b.Function.Arguments.ToMap()
if len(aMap) != len(bMap) {
return false
}
for k, av := range aMap {
bv, ok := bMap[k]
if !ok {
return false
}
aJSON, _ := json.Marshal(av)
bJSON, _ := json.Marshal(bv)
if string(aJSON) != string(bJSON) {
return false
}
}
return true
}
// testPropsMap creates a ToolPropertiesMap from a map (convenience function for tests, order not preserved)
func testPropsMap(m map[string]api.ToolProperty) *api.ToolPropertiesMap {
props := api.NewToolPropertiesMap()
for k, v := range m {
props.Set(k, v)
}
return props
}
// testArgs creates ToolCallFunctionArguments from a map (convenience function for tests, order not preserved)
func testArgs(m map[string]any) api.ToolCallFunctionArguments {
args := api.NewToolCallFunctionArguments()
for k, v := range m {
args.Set(k, v)
}
return args
}
func args(s string) api.ToolCallFunctionArguments {
var result api.ToolCallFunctionArguments
if err := json.Unmarshal([]byte(s), &result); err != nil {
panic("invalid JSON in args(): " + err.Error())
}
return result
}