diff --git a/model/parsers/parsers.go b/model/parsers/parsers.go index e88b1a0d7..ec26f9bbc 100644 --- a/model/parsers/parsers.go +++ b/model/parsers/parsers.go @@ -50,7 +50,7 @@ func ParserForName(name string) Parser { case "qwen3-thinking": p = &Qwen3Parser{hasThinkingSupport: true, defaultThinking: true} case "qwen3.5": - p = &Qwen3Parser{hasThinkingSupport: true, defaultThinking: true} + p = &Qwen35Parser{} case "qwen3-coder": p = &Qwen3CoderParser{} case "qwen3-vl-instruct": diff --git a/model/parsers/qwen35.go b/model/parsers/qwen35.go new file mode 100644 index 000000000..b3e672c5d --- /dev/null +++ b/model/parsers/qwen35.go @@ -0,0 +1,238 @@ +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 = "" + qwen35ThinkingCloseTag = "" +) + +// 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 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 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 := overlap(acc, qwen35ThinkingCloseTag); 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 + } + + 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 + } +} diff --git a/model/parsers/qwen35_test.go b/model/parsers/qwen35_test.go new file mode 100644 index 000000000..94a3f26fe --- /dev/null +++ b/model/parsers/qwen35_test.go @@ -0,0 +1,382 @@ +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 := "\nSan Francisco\n\n3\n" + 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("\nLet me 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 TestQwen35ParserToolCallEmittedInThinkingIsNotParsed(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 +SF +` + 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) + } + expectedThinking := `Need weather lookup +SF +` + if thinking != expectedThinking { + t.Fatalf("expected thinking %q, got %q", expectedThinking, thinking) + } + if len(calls) != 0 { + t.Fatalf("expected no tool calls before , 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 +SF +` + 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 , 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("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 != "Some content after spurious tag." { + t.Fatalf("expected content %q, got %q", "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("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 textThe 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", 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 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)) + } +} diff --git a/model/renderers/qwen35.go b/model/renderers/qwen35.go new file mode 100644 index 000000000..1e6accbc3 --- /dev/null +++ b/model/renderers/qwen35.go @@ -0,0 +1,194 @@ +package renderers + +import ( + "fmt" + "strings" + + "github.com/ollama/ollama/api" +) + +const ( + qwen35ThinkOpenTag = "" + qwen35ThinkCloseTag = "" + qwen35ToolPostamble = ` + + +If you choose to call a function ONLY reply in the following format with NO suffix: + + + + +value_1 + + +This is the value for the second parameter +that can span +multiple lines + + + + + +Reminder: +- Function calls MUST follow the specified format: an inner block must be nested within XML tags +- Required parameters MUST be specified +- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after +- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls +` +) + +type Qwen35Renderer struct { + isThinking bool + + emitEmptyThinkOnNoThink bool + useImgTags bool +} + +func (r *Qwen35Renderer) renderContent(content api.Message, imageOffset int) (string, int) { + // This assumes all images are at the front of the message - same assumption as ollama/ollama/runner.go + var subSb strings.Builder + for range content.Images { + if r.useImgTags { + subSb.WriteString(fmt.Sprintf("[img-%d]", imageOffset)) + imageOffset++ + } else { + subSb.WriteString("<|vision_start|><|image_pad|><|vision_end|>") + } + } + // TODO: support videos + + subSb.WriteString(content.Content) + return subSb.String(), imageOffset +} + +func splitQwen35ReasoningContent(content, messageThinking string, isThinking bool) (reasoning string, remaining string) { + if isThinking && messageThinking != "" { + return strings.TrimSpace(messageThinking), content + } + + if idx := strings.Index(content, qwen35ThinkCloseTag); idx != -1 { + before := content[:idx] + if open := strings.LastIndex(before, qwen35ThinkOpenTag); open != -1 { + reasoning = before[open+len(qwen35ThinkOpenTag):] + } else { + reasoning = before + } + content = strings.TrimLeft(content[idx+len(qwen35ThinkCloseTag):], "\n") + } + + return strings.TrimSpace(reasoning), content +} + +func (r *Qwen35Renderer) Render(messages []api.Message, tools []api.Tool, think *api.ThinkValue) (string, error) { + var sb strings.Builder + + isThinking := r.isThinking + if think != nil { + isThinking = think.Bool() + } + + if len(tools) > 0 { + sb.WriteString(imStartTag + "system\n") + sb.WriteString("# Tools\n\nYou have access to the following functions:\n\n") + for _, tool := range tools { + sb.WriteString("\n") + if b, err := marshalWithSpaces(tool); err == nil { + sb.Write(b) + } + } + sb.WriteString(qwen35ToolPostamble) + if len(messages) > 0 && messages[0].Role == "system" { + systemContent, _ := r.renderContent(messages[0], 0) + systemContent = strings.TrimSpace(systemContent) + if systemContent != "" { + sb.WriteString("\n\n") + sb.WriteString(systemContent) + } + } + sb.WriteString(imEndTag + "\n") + } else if len(messages) > 0 && messages[0].Role == "system" { + systemContent, _ := r.renderContent(messages[0], 0) + sb.WriteString(imStartTag + "system\n" + strings.TrimSpace(systemContent) + imEndTag + "\n") + } + + multiStepTool := true + lastQueryIndex := len(messages) - 1 // so this is the last user message + + for i := len(messages) - 1; i >= 0; i-- { + message := messages[i] + if multiStepTool && message.Role == "user" { + content, _ := r.renderContent(message, 0) + content = strings.TrimSpace(content) + if !(strings.HasPrefix(content, "") && strings.HasSuffix(content, "")) { + multiStepTool = false + lastQueryIndex = i + } + } + } + + imageOffset := 0 + for i, message := range messages { + content, nextImageOffset := r.renderContent(message, imageOffset) + imageOffset = nextImageOffset + content = strings.TrimSpace(content) + + lastMessage := i == len(messages)-1 + prefill := lastMessage && message.Role == "assistant" + + if message.Role == "user" || (message.Role == "system" && i != 0) { + sb.WriteString(imStartTag + message.Role + "\n" + content + imEndTag + "\n") + } else if message.Role == "assistant" { + contentReasoning, content := splitQwen35ReasoningContent(content, message.Thinking, isThinking) + + if isThinking && i > lastQueryIndex { + sb.WriteString(imStartTag + message.Role + "\n\n" + contentReasoning + "\n\n\n" + content) + } else { + sb.WriteString(imStartTag + message.Role + "\n" + content) + } + + if len(message.ToolCalls) > 0 { + for j, toolCall := range message.ToolCalls { + if j == 0 { + if strings.TrimSpace(content) != "" { + sb.WriteString("\n\n") + } + } else { + sb.WriteString("\n") + } + + sb.WriteString("\n\n") + for name, value := range toolCall.Function.Arguments.All() { + sb.WriteString("\n") + sb.WriteString(formatToolCallArgument(value)) + sb.WriteString("\n\n") + } + sb.WriteString("\n") + } + } + + if !prefill { + sb.WriteString(imEndTag + "\n") + } + } else if message.Role == "tool" { + if i == 0 || messages[i-1].Role != "tool" { + sb.WriteString(imStartTag + "user") + } + sb.WriteString("\n\n" + content + "\n") + if i == len(messages)-1 || messages[i+1].Role != "tool" { + sb.WriteString(imEndTag + "\n") + } + } + + // prefill at the end + if lastMessage && !prefill { + sb.WriteString(imStartTag + "assistant\n") + if isThinking { + sb.WriteString("\n") + } else if r.emitEmptyThinkOnNoThink { + sb.WriteString("\n\n\n\n") + } + } + } + + return sb.String(), nil +} diff --git a/model/renderers/qwen35_test.go b/model/renderers/qwen35_test.go new file mode 100644 index 000000000..57c2c97ab --- /dev/null +++ b/model/renderers/qwen35_test.go @@ -0,0 +1,389 @@ +package renderers + +import ( + "strings" + "testing" + + "github.com/ollama/ollama/api" +) + +func TestQwen35RendererUsesXMLToolCallingFormat(t *testing.T) { + renderer := &Qwen35Renderer{isThinking: true} + msgs := []api.Message{ + {Role: "system", Content: "You are a helpful assistant."}, + {Role: "user", Content: "What's the weather in Paris?"}, + { + Role: "assistant", + Content: "I'll check.", + ToolCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "get_weather", + Arguments: testArgsOrdered([]orderedArg{ + {Key: "location", Value: "Paris"}, + }), + }, + }, + }, + }, + {Role: "tool", Content: "22C"}, + {Role: "user", Content: "Thanks"}, + } + tools := []api.Tool{ + { + Type: "function", + Function: api.ToolFunction{ + Name: "get_weather", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Properties: testPropsOrdered([]orderedProp{ + { + Key: "location", + Value: api.ToolProperty{ + Type: api.PropertyType{"string"}, + }, + }, + }), + Required: []string{"location"}, + }, + }, + }, + } + + got, err := renderer.Render(msgs, tools, nil) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + if !strings.Contains(got, "") { + t.Fatalf("expected tools section in prompt, got:\n%s", got) + } + if !strings.Contains(got, "") { + t.Fatalf("expected xml-style tool call instructions, got:\n%s", got) + } + + wantToolCall := "\n\n\nParis\n\n\n" + if !strings.Contains(got, wantToolCall) { + t.Fatalf("expected xml tool call payload, got:\n%s", got) + } + + toolsIdx := strings.Index(got, "# Tools") + systemIdx := strings.Index(got, "You are a helpful assistant.") + if toolsIdx == -1 || systemIdx == -1 || systemIdx < toolsIdx { + t.Fatalf("expected system prompt appended after tool instructions, got:\n%s", got) + } +} + +func TestQwen35RendererNoThinkPrefill(t *testing.T) { + renderer := &Qwen35Renderer{isThinking: true, emitEmptyThinkOnNoThink: true} + msgs := []api.Message{ + {Role: "user", Content: "hello"}, + } + + got, err := renderer.Render(msgs, nil, &api.ThinkValue{Value: false}) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + if !strings.HasSuffix(got, "<|im_start|>assistant\n\n\n\n\n") { + t.Fatalf("expected explicit no-think prefill, got:\n%s", got) + } +} + +func TestQwen35RendererBackToBackToolCallsAndResponses(t *testing.T) { + renderer := &Qwen35Renderer{isThinking: true} + + msgs := []api.Message{ + {Role: "system", Content: "You are a helpful assistant."}, + {Role: "user", Content: "Run add and multiply."}, + { + Role: "assistant", + Content: "I'll run both now.", + Thinking: "Need to call add and multiply.", + ToolCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "add", + Arguments: testArgsOrdered([]orderedArg{ + {Key: "a", Value: 2}, + {Key: "b", Value: 3}, + }), + }, + }, + { + Function: api.ToolCallFunction{ + Name: "multiply", + Arguments: testArgsOrdered([]orderedArg{ + {Key: "x", Value: 4}, + {Key: "y", Value: 5}, + }), + }, + }, + }, + }, + {Role: "tool", Content: "5"}, + {Role: "tool", Content: "20"}, + {Role: "user", Content: "Summarize the results."}, + } + + got, err := renderer.Render(msgs, qwen35MathTools(), nil) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + if strings.Contains(got, "Need to call add and multiply.") { + t.Fatalf("did not expect historical reasoning block in this sequence, got:\n%s", got) + } + + wantToolCalls := ` + + +2 + + +3 + + + + + + +4 + + +5 + + +` + if !strings.Contains(got, wantToolCalls) { + t.Fatalf("expected back-to-back tool calls, got:\n%s", got) + } + + wantToolResponses := `<|im_start|>user + +5 + + +20 +<|im_end|>` + if !strings.Contains(got, wantToolResponses) { + t.Fatalf("expected grouped back-to-back tool responses, got:\n%s", got) + } + + if !strings.HasSuffix(got, "<|im_start|>assistant\n\n") { + t.Fatalf("expected assistant thinking prefill at end, got:\n%s", got) + } +} + +func TestQwen35RendererInterleavedThinkingAndTools(t *testing.T) { + renderer := &Qwen35Renderer{isThinking: true} + + msgs := []api.Message{ + {Role: "system", Content: "You are a helpful assistant."}, + {Role: "user", Content: "Plan a picnic in Paris."}, + { + Role: "assistant", + Content: "Checking weather first.", + Thinking: "Need weather before giving advice.", + ToolCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "get_weather", + Arguments: testArgsOrdered([]orderedArg{ + {Key: "location", Value: "Paris"}, + }), + }, + }, + }, + }, + {Role: "tool", Content: "22C"}, + { + Role: "assistant", + Content: "Checking UV too.", + Thinking: "Need UV index for sunscreen advice.", + ToolCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "get_uv", + Arguments: testArgsOrdered([]orderedArg{ + {Key: "location", Value: "Paris"}, + }), + }, + }, + }, + }, + {Role: "tool", Content: "5"}, + } + + got, err := renderer.Render(msgs, qwen35WeatherUVTools(), nil) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + wantFirstTurn := `<|im_start|>assistant + +Need weather before giving advice. + + +Checking weather first. + + + + +Paris + + +<|im_end|>` + if !strings.Contains(got, wantFirstTurn) { + t.Fatalf("expected first assistant thinking/tool sequence, got:\n%s", got) + } + + wantSecondTurn := `<|im_start|>assistant + +Need UV index for sunscreen advice. + + +Checking UV too. + + + + +Paris + + +<|im_end|>` + if !strings.Contains(got, wantSecondTurn) { + t.Fatalf("expected second assistant thinking/tool sequence, got:\n%s", got) + } + + if !strings.HasSuffix(got, "<|im_start|>assistant\n\n") { + t.Fatalf("expected assistant thinking prefill at end, got:\n%s", got) + } +} + +func TestQwen35RendererAssistantPrefillWithThinking(t *testing.T) { + renderer := &Qwen35Renderer{isThinking: true} + msgs := []api.Message{ + {Role: "user", Content: "Write two words."}, + { + Role: "assistant", + Thinking: "Keep it short.", + Content: "Hello world", + }, + } + + got, err := renderer.Render(msgs, nil, nil) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + want := `<|im_start|>user +Write two words.<|im_end|> +<|im_start|>assistant + +Keep it short. + + +Hello world` + if got != want { + t.Fatalf("unexpected prefill output\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func qwen35MathTools() []api.Tool { + return []api.Tool{ + { + Type: "function", + Function: api.ToolFunction{ + Name: "add", + Description: "Add two numbers", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Properties: testPropsOrdered([]orderedProp{ + { + Key: "a", + Value: api.ToolProperty{ + Type: api.PropertyType{"integer"}, + }, + }, + { + Key: "b", + Value: api.ToolProperty{ + Type: api.PropertyType{"integer"}, + }, + }, + }), + Required: []string{"a", "b"}, + }, + }, + }, + { + Type: "function", + Function: api.ToolFunction{ + Name: "multiply", + Description: "Multiply two numbers", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Properties: testPropsOrdered([]orderedProp{ + { + Key: "x", + Value: api.ToolProperty{ + Type: api.PropertyType{"integer"}, + }, + }, + { + Key: "y", + Value: api.ToolProperty{ + Type: api.PropertyType{"integer"}, + }, + }, + }), + Required: []string{"x", "y"}, + }, + }, + }, + } +} + +func qwen35WeatherUVTools() []api.Tool { + return []api.Tool{ + { + Type: "function", + Function: api.ToolFunction{ + Name: "get_weather", + Description: "Get weather for a location", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Properties: testPropsOrdered([]orderedProp{ + { + Key: "location", + Value: api.ToolProperty{ + Type: api.PropertyType{"string"}, + }, + }, + }), + Required: []string{"location"}, + }, + }, + }, + { + Type: "function", + Function: api.ToolFunction{ + Name: "get_uv", + Description: "Get UV index for a location", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Properties: testPropsOrdered([]orderedProp{ + { + Key: "location", + Value: api.ToolProperty{ + Type: api.PropertyType{"string"}, + }, + }, + }), + Required: []string{"location"}, + }, + }, + }, + } +} diff --git a/model/renderers/renderer.go b/model/renderers/renderer.go index 7309d38f5..17acaf0b4 100644 --- a/model/renderers/renderer.go +++ b/model/renderers/renderer.go @@ -57,7 +57,7 @@ func rendererForName(name string) Renderer { renderer := &Qwen3VLRenderer{isThinking: true, useImgTags: RenderImgTags} return renderer case "qwen3.5": - renderer := &Qwen3VLRenderer{isThinking: true, emitEmptyThinkOnNoThink: true, useImgTags: RenderImgTags} + renderer := &Qwen35Renderer{isThinking: true, emitEmptyThinkOnNoThink: true, useImgTags: RenderImgTags} return renderer case "cogito": renderer := &CogitoRenderer{isThinking: true}