diff --git a/model/parsers/qwen3.go b/model/parsers/qwen3.go index e49111fb5..a2c541059 100644 --- a/model/parsers/qwen3.go +++ b/model/parsers/qwen3.go @@ -204,6 +204,24 @@ func (p *Qwen3Parser) eat() ([]qwen3Event, bool) { p.maybeThinkingOpenAtBOL = false } + thinkingCloseIdx := strings.Index(acc, qwen3ThinkingCloseTag) + toolOpenIdx := strings.Index(acc, qwen3ToolOpenTag) + + // If a tool call starts before , treat that as the end of thinking + // for parsing purposes and continue in tool-call mode. + if toolOpenIdx != -1 && (thinkingCloseIdx == -1 || toolOpenIdx < thinkingCloseIdx) { + before, after := p.splitAtTag(qwen3ToolOpenTag, true) + if len(before) > 0 { + events = append(events, qwen3EventThinkingContent{content: before}) + } + if after == "" { + p.state = qwen3ParserStateToolStartedEatingWhitespace + } else { + p.state = qwen3ParserStateCollectingToolContent + } + return events, true + } + if strings.Contains(acc, qwen3ThinkingCloseTag) { thinking, remaining := p.splitAtTag(qwen3ThinkingCloseTag, true) if len(thinking) > 0 { @@ -215,7 +233,7 @@ func (p *Qwen3Parser) eat() ([]qwen3Event, bool) { p.state = qwen3ParserStateCollectingContent } return events, true - } else if overlapLen := overlap(acc, qwen3ThinkingCloseTag); overlapLen > 0 { + } else if overlapLen := max(overlap(acc, qwen3ThinkingCloseTag), overlap(acc, qwen3ToolOpenTag)); overlapLen > 0 { beforePartialTag := acc[:len(acc)-overlapLen] trailingWsLen := trailingWhitespaceLen(beforePartialTag) ambiguousStart := len(beforePartialTag) - trailingWsLen diff --git a/model/parsers/qwen3_test.go b/model/parsers/qwen3_test.go index 0f8ab3da7..a1a2a8875 100644 --- a/model/parsers/qwen3_test.go +++ b/model/parsers/qwen3_test.go @@ -146,6 +146,68 @@ func TestQwen3ParserToolCall(t *testing.T) { } } +func TestQwen3ParserThinkingWithToolCallBeforeThinkingClose(t *testing.T) { + parser := &Qwen3Parser{hasThinkingSupport: true, defaultThinking: true} + parser.Init(nil, nil, &api.ThinkValue{Value: true}) + + input := "Let me think{\"name\":\"get_weather\",\"arguments\":{\"location\":\"San Francisco\",\"unit\":\"celsius\"}}" + content, thinking, calls, err := parser.Add(input, true) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + + if content != "" { + t.Fatalf("expected empty content, got %q", content) + } + if thinking != "Let me think" { + t.Fatalf("expected thinking %q, got %q", "Let me think", thinking) + } + if len(calls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(calls)) + } + if calls[0].Function.Name != "get_weather" { + t.Fatalf("expected tool name %q, got %q", "get_weather", calls[0].Function.Name) + } +} + +func TestQwen3ParserThinkingWithSplitToolOpenTag(t *testing.T) { + parser := &Qwen3Parser{hasThinkingSupport: true, defaultThinking: true} + parser.Init(nil, nil, &api.ThinkValue{Value: true}) + + content, thinking, calls, err := parser.Add("Let me think{\"name\":\"get_weather\",\"arguments\":{\"location\":\"SF\"}}", true) + if err != nil { + t.Fatalf("parse failed on second chunk: %v", err) + } + if content != "" { + t.Fatalf("expected empty content, got %q", content) + } + if thinking != "" { + t.Fatalf("expected no additional thinking on second chunk, got %q", thinking) + } + if len(calls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(calls)) + } + if calls[0].Function.Name != "get_weather" { + t.Fatalf("expected tool name %q, got %q", "get_weather", calls[0].Function.Name) + } +} + func TestQwen35ParserRespectsNoThink(t *testing.T) { parser := ParserForName("qwen3.5") if parser == nil { diff --git a/model/parsers/qwen3vl.go b/model/parsers/qwen3vl.go index cb7627638..40bc86106 100644 --- a/model/parsers/qwen3vl.go +++ b/model/parsers/qwen3vl.go @@ -180,7 +180,22 @@ func (p *Qwen3VLParser) eat() ([]qwenEvent, bool) { return events, false } case CollectingThinkingContent: - if strings.Contains(p.buffer.String(), thinkingCloseTag) { + acc := p.buffer.String() + thinkingCloseIdx := strings.Index(acc, thinkingCloseTag) + toolOpenIdx := strings.Index(acc, toolOpenTag) + + // If a tool call starts before , treat that as the end of thinking + // for parsing purposes and continue in tool-call mode. + if toolOpenIdx != -1 && (thinkingCloseIdx == -1 || toolOpenIdx < thinkingCloseIdx) { + before, _ := splitAtTag(&p.buffer, toolOpenTag, false) + if len(before) > 0 { + events = append(events, qwenEventThinkingContent{content: before}) + } + p.state = CollectingToolContent + return events, true + } + + if strings.Contains(acc, thinkingCloseTag) { thinking, remaining := splitAtTag(&p.buffer, thinkingCloseTag, true) if len(thinking) > 0 { events = append(events, qwenEventThinkingContent{content: thinking}) @@ -191,13 +206,13 @@ func (p *Qwen3VLParser) eat() ([]qwenEvent, bool) { p.state = CollectingContent } return events, true - } else if overlapLen := overlap(p.buffer.String(), thinkingCloseTag); overlapLen > 0 { - beforePartialTag := p.buffer.String()[:len(p.buffer.String())-overlapLen] + } else if overlapLen := max(overlap(acc, thinkingCloseTag), overlap(acc, toolOpenTag)); overlapLen > 0 { + beforePartialTag := acc[:len(acc)-overlapLen] trailingWhitespaceLen := trailingWhitespaceLen(beforePartialTag) ambiguousStart := len(beforePartialTag) - trailingWhitespaceLen - unambiguous := p.buffer.String()[:ambiguousStart] - ambiguous := p.buffer.String()[ambiguousStart:] + unambiguous := acc[:ambiguousStart] + ambiguous := acc[ambiguousStart:] p.buffer.Reset() p.buffer.WriteString(ambiguous) if len(unambiguous) > 0 { @@ -205,11 +220,11 @@ func (p *Qwen3VLParser) eat() ([]qwenEvent, bool) { } return events, false } else { - whitespaceLen := trailingWhitespaceLen(p.buffer.String()) - ambiguousStart := len(p.buffer.String()) - whitespaceLen + whitespaceLen := trailingWhitespaceLen(acc) + ambiguousStart := len(acc) - whitespaceLen - unambiguous := p.buffer.String()[:ambiguousStart] - ambiguous := p.buffer.String()[ambiguousStart:] + unambiguous := acc[:ambiguousStart] + ambiguous := acc[ambiguousStart:] p.buffer.Reset() p.buffer.WriteString(ambiguous) if len(unambiguous) > 0 { diff --git a/model/parsers/qwen3vl_thinking_test.go b/model/parsers/qwen3vl_thinking_test.go index ff3dc1683..c0d953f2e 100644 --- a/model/parsers/qwen3vl_thinking_test.go +++ b/model/parsers/qwen3vl_thinking_test.go @@ -98,8 +98,12 @@ func TestQwen3VLThinkingParserStreaming(t *testing.T) { desc: "nested thinking and tool call (outside thinking, inside tool call)", steps: []step{ { - input: "I'm thinkingI'm nested tool call", - wantEvents: []qwenEvent{qwenEventThinkingContent{content: "I'm thinkingI'm nested tool call"}}, + input: "I'm thinkingI'm nested tool call", + wantEvents: []qwenEvent{ + qwenEventThinkingContent{content: "I'm thinking"}, + qwenEventRawToolCall{raw: "I'm nested tool call"}, + qwenEventContent{content: ""}, + }, }, }, }, @@ -109,8 +113,7 @@ func TestQwen3VLThinkingParserStreaming(t *testing.T) { { input: "I'm nested tool callI'm thinking", wantEvents: []qwenEvent{ - qwenEventThinkingContent{content: "I'm nested tool callI'm thinking"}, - qwenEventContent{content: ""}, + qwenEventRawToolCall{raw: "I'm nested tool callI'm thinking"}, }, }, }, @@ -121,8 +124,8 @@ func TestQwen3VLThinkingParserStreaming(t *testing.T) { { input: "I'm thinkingI'm NOT a nested tool callI'm nested tool call 2", wantEvents: []qwenEvent{ - qwenEventThinkingContent{content: "I'm thinkingI'm NOT a nested tool call"}, - qwenEventContent{content: ""}, + qwenEventThinkingContent{content: "I'm thinking"}, + qwenEventRawToolCall{raw: "I'm NOT a nested tool call"}, qwenEventRawToolCall{raw: "I'm nested tool call 2"}, qwenEventContent{content: ""}, },