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}