diff --git a/model/parsers/qwen3coder.go b/model/parsers/qwen3coder.go
index 5604988ec..666c8aaaa 100644
--- a/model/parsers/qwen3coder.go
+++ b/model/parsers/qwen3coder.go
@@ -19,8 +19,9 @@ import (
type qwenParserState int
const (
- toolOpenTag = ""
- toolCloseTag = ""
+ toolOpenTag = ""
+ toolCloseTag = ""
+ functionOpenStart = " but starts with this
)
const (
@@ -138,11 +139,26 @@ func eat(p *Qwen3CoderParser) ([]qwenEvent, bool) {
p.acc.WriteString(after)
p.state = qwenParserState_CollectingToolContent
return events, true
- } else if overlap := overlap(p.acc.String(), toolOpenTag); overlap > 0 {
+ } else if idx := strings.Index(p.acc.String(), functionOpenStart); idx != -1 {
+ // qwen3-coder sometimes omits but starts with 0 {
+ events = append(events, qwenEventContent{content: before})
+ }
+ after := p.acc.String()[idx:]
+ p.acc.Reset()
+ p.acc.WriteString(after)
+ p.state = qwenParserState_CollectingToolContent
+ return events, true
+ } else if toolOverlap, funcOverlap := overlap(p.acc.String(), toolOpenTag), overlap(p.acc.String(), functionOpenStart); toolOverlap > 0 || funcOverlap > 0 {
// we found a partial tool open tag, so we can emit the unambiguous part,
// which is the (trailing-whitespace trimmed) content before the partial
// tool open tag
- beforePartialTag := p.acc.String()[:len(p.acc.String())-overlap]
+ maxOverlap := max(toolOverlap, funcOverlap)
+ beforePartialTag := p.acc.String()[:len(p.acc.String())-maxOverlap]
trailingWhitespaceLen := trailingWhitespaceLen(beforePartialTag)
ambiguousStart := len(beforePartialTag) - trailingWhitespaceLen
unambiguous := p.acc.String()[:ambiguousStart]
diff --git a/model/parsers/qwen3coder_test.go b/model/parsers/qwen3coder_test.go
index 24ccc5e9e..d1ec16d58 100644
--- a/model/parsers/qwen3coder_test.go
+++ b/model/parsers/qwen3coder_test.go
@@ -343,20 +343,23 @@ func TestQwenParserStreaming(t *testing.T) {
},
},
},
- }
-
- anyOnlies := false
- for _, tc := range cases {
- if tc.only {
- anyOnlies = true
- }
+ // qwen3-coder:30b occasionally leaves off opening tags, but we
+ // want to parse it anyway
+ {
+ desc: "missing opening tag still parses",
+ steps: []step{
+ {
+ input: "before tool callsome tool content here",
+ wantEvents: []qwenEvent{
+ qwenEventContent{content: "before tool call"},
+ qwenEventRawToolCall{raw: "some tool content here"},
+ },
+ },
+ },
+ },
}
for _, tc := range cases {
- if anyOnlies && !tc.only {
- continue
- }
-
t.Run(tc.desc, func(t *testing.T) {
parser := Qwen3CoderParser{}