mirror of
https://github.com/ollama/ollama.git
synced 2026-03-09 07:16:38 -05:00
model: fix renderer and parser for qwen3.5 (#14605)
This commit is contained in:
@@ -50,7 +50,7 @@ func ParserForName(name string) Parser {
|
|||||||
case "qwen3-thinking":
|
case "qwen3-thinking":
|
||||||
p = &Qwen3Parser{hasThinkingSupport: true, defaultThinking: true}
|
p = &Qwen3Parser{hasThinkingSupport: true, defaultThinking: true}
|
||||||
case "qwen3.5":
|
case "qwen3.5":
|
||||||
p = &Qwen3Parser{hasThinkingSupport: true, defaultThinking: true}
|
p = &Qwen35Parser{}
|
||||||
case "qwen3-coder":
|
case "qwen3-coder":
|
||||||
p = &Qwen3CoderParser{}
|
p = &Qwen3CoderParser{}
|
||||||
case "qwen3-vl-instruct":
|
case "qwen3-vl-instruct":
|
||||||
|
|||||||
238
model/parsers/qwen35.go
Normal file
238
model/parsers/qwen35.go
Normal file
@@ -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 = "<think>"
|
||||||
|
qwen35ThinkingCloseTag = "</think>"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 := 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
|
||||||
|
}
|
||||||
|
}
|
||||||
382
model/parsers/qwen35_test.go
Normal file
382
model/parsers/qwen35_test.go
Normal file
@@ -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 := "<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 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<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)
|
||||||
|
}
|
||||||
|
expectedThinking := `Need weather lookup<tool_call><function=get_weather><parameter=location>
|
||||||
|
SF
|
||||||
|
</parameter></function></tool_call>`
|
||||||
|
if thinking != expectedThinking {
|
||||||
|
t.Fatalf("expected thinking %q, got %q", expectedThinking, thinking)
|
||||||
|
}
|
||||||
|
if len(calls) != 0 {
|
||||||
|
t.Fatalf("expected no tool calls before </think>, 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
194
model/renderers/qwen35.go
Normal file
194
model/renderers/qwen35.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package renderers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
qwen35ThinkOpenTag = "<think>"
|
||||||
|
qwen35ThinkCloseTag = "</think>"
|
||||||
|
qwen35ToolPostamble = `
|
||||||
|
</tools>
|
||||||
|
|
||||||
|
If you choose to call a function ONLY reply in the following format with NO suffix:
|
||||||
|
|
||||||
|
<tool_call>
|
||||||
|
<function=example_function_name>
|
||||||
|
<parameter=example_parameter_1>
|
||||||
|
value_1
|
||||||
|
</parameter>
|
||||||
|
<parameter=example_parameter_2>
|
||||||
|
This is the value for the second parameter
|
||||||
|
that can span
|
||||||
|
multiple lines
|
||||||
|
</parameter>
|
||||||
|
</function>
|
||||||
|
</tool_call>
|
||||||
|
|
||||||
|
<IMPORTANT>
|
||||||
|
Reminder:
|
||||||
|
- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> 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
|
||||||
|
</IMPORTANT>`
|
||||||
|
)
|
||||||
|
|
||||||
|
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<tools>")
|
||||||
|
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, "<tool_response>") && strings.HasSuffix(content, "</tool_response>")) {
|
||||||
|
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<think>\n" + contentReasoning + "\n</think>\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("<tool_call>\n<function=" + toolCall.Function.Name + ">\n")
|
||||||
|
for name, value := range toolCall.Function.Arguments.All() {
|
||||||
|
sb.WriteString("<parameter=" + name + ">\n")
|
||||||
|
sb.WriteString(formatToolCallArgument(value))
|
||||||
|
sb.WriteString("\n</parameter>\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("</function>\n</tool_call>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<tool_response>\n" + content + "\n</tool_response>")
|
||||||
|
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("<think>\n")
|
||||||
|
} else if r.emitEmptyThinkOnNoThink {
|
||||||
|
sb.WriteString("<think>\n\n</think>\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
389
model/renderers/qwen35_test.go
Normal file
389
model/renderers/qwen35_test.go
Normal file
@@ -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, "<tools>") {
|
||||||
|
t.Fatalf("expected tools section in prompt, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "<function=example_function_name>") {
|
||||||
|
t.Fatalf("expected xml-style tool call instructions, got:\n%s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantToolCall := "<tool_call>\n<function=get_weather>\n<parameter=location>\nParis\n</parameter>\n</function>\n</tool_call>"
|
||||||
|
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<think>\n\n</think>\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 := `<tool_call>
|
||||||
|
<function=add>
|
||||||
|
<parameter=a>
|
||||||
|
2
|
||||||
|
</parameter>
|
||||||
|
<parameter=b>
|
||||||
|
3
|
||||||
|
</parameter>
|
||||||
|
</function>
|
||||||
|
</tool_call>
|
||||||
|
<tool_call>
|
||||||
|
<function=multiply>
|
||||||
|
<parameter=x>
|
||||||
|
4
|
||||||
|
</parameter>
|
||||||
|
<parameter=y>
|
||||||
|
5
|
||||||
|
</parameter>
|
||||||
|
</function>
|
||||||
|
</tool_call>`
|
||||||
|
if !strings.Contains(got, wantToolCalls) {
|
||||||
|
t.Fatalf("expected back-to-back tool calls, got:\n%s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantToolResponses := `<|im_start|>user
|
||||||
|
<tool_response>
|
||||||
|
5
|
||||||
|
</tool_response>
|
||||||
|
<tool_response>
|
||||||
|
20
|
||||||
|
</tool_response><|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<think>\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
|
||||||
|
<think>
|
||||||
|
Need weather before giving advice.
|
||||||
|
</think>
|
||||||
|
|
||||||
|
Checking weather first.
|
||||||
|
|
||||||
|
<tool_call>
|
||||||
|
<function=get_weather>
|
||||||
|
<parameter=location>
|
||||||
|
Paris
|
||||||
|
</parameter>
|
||||||
|
</function>
|
||||||
|
</tool_call><|im_end|>`
|
||||||
|
if !strings.Contains(got, wantFirstTurn) {
|
||||||
|
t.Fatalf("expected first assistant thinking/tool sequence, got:\n%s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantSecondTurn := `<|im_start|>assistant
|
||||||
|
<think>
|
||||||
|
Need UV index for sunscreen advice.
|
||||||
|
</think>
|
||||||
|
|
||||||
|
Checking UV too.
|
||||||
|
|
||||||
|
<tool_call>
|
||||||
|
<function=get_uv>
|
||||||
|
<parameter=location>
|
||||||
|
Paris
|
||||||
|
</parameter>
|
||||||
|
</function>
|
||||||
|
</tool_call><|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<think>\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
|
||||||
|
<think>
|
||||||
|
Keep it short.
|
||||||
|
</think>
|
||||||
|
|
||||||
|
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"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ func rendererForName(name string) Renderer {
|
|||||||
renderer := &Qwen3VLRenderer{isThinking: true, useImgTags: RenderImgTags}
|
renderer := &Qwen3VLRenderer{isThinking: true, useImgTags: RenderImgTags}
|
||||||
return renderer
|
return renderer
|
||||||
case "qwen3.5":
|
case "qwen3.5":
|
||||||
renderer := &Qwen3VLRenderer{isThinking: true, emitEmptyThinkOnNoThink: true, useImgTags: RenderImgTags}
|
renderer := &Qwen35Renderer{isThinking: true, emitEmptyThinkOnNoThink: true, useImgTags: RenderImgTags}
|
||||||
return renderer
|
return renderer
|
||||||
case "cogito":
|
case "cogito":
|
||||||
renderer := &CogitoRenderer{isThinking: true}
|
renderer := &CogitoRenderer{isThinking: true}
|
||||||
|
|||||||
Reference in New Issue
Block a user