Skip to content

Commit e4c70fc

Browse files
localai-botmudler
andauthored
fix(streaming/tools): don't leak prefill-misclassified content as trailing reasoning chunk (#10000)
When the C++ autoparser is in pure-content fallback mode (qwen3-4b after model emits a tool-call JSON in non-thinking mode, the streaming worker ended the SSE stream with a spurious data: {...,"delta":{"reasoning":"{\"name\":\"exec\",\"arguments\":...}"}} chunk carrying the same JSON that was already in delta.tool_calls. The Go-side ReasoningExtractor is configured from DetectThinkingStartToken, which scans the model's jinja chat template verbatim and finds <think> inside an {% if enable_thinking %} block without evaluating the conditional. Every output chunk then runs through PrependThinkingTokenIfNeeded, which synthesizes a <think> in front and makes ExtractReasoning treat everything after as reasoning. The autoparser correctly classifies zero reasoning (qwen3's tool format isn't on llama.cpp's recognized-tool list, so all tokens land in ChatDelta.Content), but processStreamWithTools then preferred extractor.Reasoning() over functions.ReasoningFromChatDeltas at the end-of-stream flush — handing the polluted Go-side state to buildDeferredToolCallChunks, which emitted it as a trailing reasoning chunk. Two changes: * Add a sticky preferAutoparser flag to processStreamWithTools, mirroring the analogous flag in processStream from #9985. Once any ChatDelta carries content or reasoning, the flag stays on for the rest of the stream and the worker stops falling back to the Go-side extractor for per-token deltas. This avoids the per-chunk leak path and the cumulative pollution. * Extract chooseDeferredReasoning, a small helper that selects the end-of-stream reasoning source. When preferAutoparser is set, return functions.ReasoningFromChatDeltas(chatDeltas); otherwise fall back to extractor.Reasoning() (the correct source for vLLM and other backends with no autoparser). The helper has a focused test suite covering both sides of the contract: autoparser-active with empty reasoning (the qwen3 case — the fix's purpose), autoparser-active with real reasoning_content (jinja-with-recognized-format models), and autoparser-not-active with genuine Go-side reasoning (vLLM-style backends). E2E with combined #9988 and this fix on qwen3-4b post-#9985 gallery shape: 18 content chunks of the tool-call JSON, 1 tool_call chunk with name='exec' and the right arguments, finish_reason=tool_calls, and zero reasoning chunks — down from one polluted reasoning chunk before this fix. Depends on #9999 (the streaming JSON tool-call gating bug for qwen3) to make the trailing chunk observable end-to-end; the helper unit tests are independent. Assisted-by: Claude:opus-4-7 [Read] [Edit] [Bash] [Write] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 4b398c9 commit e4c70fc

2 files changed

Lines changed: 153 additions & 2 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package openai
2+
3+
import (
4+
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
5+
reason "github.com/mudler/LocalAI/pkg/reasoning"
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
)
9+
10+
// Regression test for the prefill-misclassification artifact surfaced in
11+
// the review of #9991: when LocalAI templates qwen3 with
12+
// use_tokenizer_template (the post-#9985 gallery shape),
13+
// DetectThinkingStartToken finds <think> in the model's jinja chat
14+
// template — without evaluating the surrounding {% if enable_thinking %}
15+
// guard — and the Go-side extractor's PrependThinkingTokenIfNeeded then
16+
// treats every non-thinking output token as reasoning. The autoparser does
17+
// not classify qwen3's tool calls into ChatDelta.ToolCalls (qwen3's tool
18+
// format isn't on llama.cpp's recognized-tool list), so all tokens land in
19+
// ChatDelta.Content while the Go-side extractor silently accumulates a
20+
// "reasoning" string equal to the raw tool-call JSON. End-of-stream this
21+
// is flushed as a trailing `delta.reasoning` chunk to the client.
22+
//
23+
// chooseDeferredReasoning is the gate: when the autoparser was active for
24+
// any chunk (preferAutoparser sticky), we trust its reasoning_content
25+
// classification (usually empty) instead of the polluted Go-side state.
26+
var _ = Describe("chooseDeferredReasoning", func() {
27+
// Simulate the qwen3-after-#9985 misclassification: build a real
28+
// extractor with a <think> thinking-start token, then feed it
29+
// non-thinking content. The extractor will (correctly per its own
30+
// contract) treat the content as reasoning because
31+
// PrependThinkingTokenIfNeeded synthesizes a leading <think>.
32+
pollutedExtractor := func(content string) *reason.ReasoningExtractor {
33+
e := reason.NewReasoningExtractor("<think>", reason.Config{})
34+
e.ProcessToken(content)
35+
Expect(e.Reasoning()).To(Equal(content),
36+
"sanity: when the thinking-start token is set and content has no real <think>...</think>, "+
37+
"the extractor classifies all content as reasoning — this is exactly the prefill pollution "+
38+
"we want chooseDeferredReasoning to guard against")
39+
return e
40+
}
41+
42+
Context("autoparser was active (preferAutoparser=true)", func() {
43+
It("returns the autoparser's reasoning classification, ignoring the polluted Go-side state", func() {
44+
toolCallJSON := `{"arguments": {"cmd": "echo hello"}, "name": "exec"}`
45+
extractor := pollutedExtractor(toolCallJSON)
46+
// What the C++ autoparser sent: content chunks but no
47+
// reasoning_content (qwen3 tool calls aren't classified by
48+
// the upstream PEG parser).
49+
chatDeltas := []*pb.ChatDelta{
50+
{Content: toolCallJSON, ReasoningContent: ""},
51+
}
52+
53+
got := chooseDeferredReasoning(true, chatDeltas, extractor)
54+
55+
Expect(got).To(BeEmpty(),
56+
"chooseDeferredReasoning must NOT return the polluted extractor state "+
57+
"when the autoparser was active — the autoparser correctly classified zero reasoning")
58+
})
59+
60+
It("returns the autoparser's reasoning when it actually did classify reasoning", func() {
61+
// The other side of the contract: when the autoparser was
62+
// in jinja-with-recognized-format mode and DID classify
63+
// reasoning, pass that through verbatim.
64+
actualReasoning := "Okay, the user asked X. I should call exec."
65+
extractor := pollutedExtractor("ignored polluted state")
66+
chatDeltas := []*pb.ChatDelta{
67+
{Content: "", ReasoningContent: actualReasoning},
68+
}
69+
70+
got := chooseDeferredReasoning(true, chatDeltas, extractor)
71+
72+
Expect(got).To(Equal(actualReasoning))
73+
})
74+
})
75+
76+
Context("autoparser was NOT active (preferAutoparser=false)", func() {
77+
It("falls back to the Go-side extractor — the right source for vLLM and other autoparser-less backends", func() {
78+
realReasoning := "Genuine reasoning from a backend without an autoparser"
79+
extractor := reason.NewReasoningExtractor("<think>", reason.Config{})
80+
extractor.ProcessToken("<think>" + realReasoning + "</think>final answer")
81+
82+
got := chooseDeferredReasoning(false, nil, extractor)
83+
84+
Expect(got).To(Equal(realReasoning))
85+
})
86+
87+
It("falls back even when ChatDeltas are present but the autoparser never classified anything", func() {
88+
// Defensive: chatDeltas could carry vestigial data; if
89+
// preferAutoparser wasn't flipped, we still use the
90+
// extractor.
91+
extractor := reason.NewReasoningExtractor("", reason.Config{})
92+
extractor.ProcessToken("<think>some thoughts</think>answer")
93+
94+
got := chooseDeferredReasoning(false, []*pb.ChatDelta{{Content: "answer"}}, extractor)
95+
96+
Expect(got).To(Equal("some thoughts"))
97+
})
98+
})
99+
})

core/http/endpoints/openai/chat_stream_workers.go

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/mudler/LocalAI/core/config"
88
"github.com/mudler/LocalAI/core/schema"
99
"github.com/mudler/LocalAI/pkg/functions"
10+
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
1011
"github.com/mudler/LocalAI/pkg/model"
1112
reason "github.com/mudler/LocalAI/pkg/reasoning"
1213
"github.com/mudler/xlog"
@@ -83,6 +84,34 @@ func emitJSONToolCallDeltas(
8384
return lastEmittedCount
8485
}
8586

87+
// chooseDeferredReasoning picks the source of truth for the end-of-stream
88+
// reasoning flush in processStreamWithTools. When the C++ autoparser was
89+
// active during the stream (preferAutoparser), it returns the autoparser's
90+
// own classified reasoning_content from ChatDeltas — usually empty when the
91+
// autoparser is in pure-content fallback mode. Otherwise it falls back to
92+
// the Go-side streaming extractor, which is the right source for backends
93+
// without an autoparser (vLLM, etc.).
94+
//
95+
// Why: the Go-side extractor's accumulated Reasoning() can be polluted by
96+
// PrependThinkingTokenIfNeeded — when the tokenizer template contains a
97+
// thinking start token (qwen3's jinja template has <think> inside an
98+
// {% if enable_thinking %} block, and DetectThinkingStartToken does not
99+
// evaluate jinja conditionals), prefill detection treats every chunk's
100+
// content as reasoning, even when the model emitted a raw tool-call JSON
101+
// in non-thinking mode. Without this guard, qwen3-4b with streaming + tools
102+
// (after #9985 flipped the gallery to use_tokenizer_template) emits a
103+
// trailing SSE chunk where `reasoning` carries the tool-call JSON.
104+
func chooseDeferredReasoning(
105+
preferAutoparser bool,
106+
chatDeltas []*pb.ChatDelta,
107+
extractor *reason.ReasoningExtractor,
108+
) string {
109+
if preferAutoparser {
110+
return functions.ReasoningFromChatDeltas(chatDeltas)
111+
}
112+
return extractor.Reasoning()
113+
}
114+
86115
// processStream is the streaming worker for chat completions with no
87116
// tool/function calling involved. It pushes SSE-shaped chunks onto
88117
// `responses` and returns the authoritative cumulative TokenUsage from
@@ -228,6 +257,17 @@ func processStreamWithTools(
228257
hasChatDeltaToolCalls := false
229258
hasChatDeltaContent := false
230259

260+
// preferAutoparser is sticky: once the C++ autoparser has ever delivered
261+
// content or reasoning via ChatDeltas, we trust its classification for the
262+
// rest of the stream — including for the end-of-stream reasoning flush in
263+
// buildDeferredToolCallChunks. Otherwise the Go-side extractor's
264+
// accumulated Reasoning() can be polluted by prefill detection
265+
// misclassifying content as reasoning (this happens when <think> appears
266+
// in the tokenizer template and the model emits non-reasoning content
267+
// like a raw tool-call JSON — qwen3-4b after #9985 enabled
268+
// use_tokenizer_template). Mirrors the analogous flag in processStream.
269+
preferAutoparser := false
270+
231271
// X-LocalAI-Node attribution is handled by middleware.ExposeNodeHeader
232272
// at the wrapper layer; no in-band signalling from this worker.
233273

@@ -251,12 +291,17 @@ func processStreamWithTools(
251291

252292
if usage.HasChatDeltaContent() {
253293
rawReasoning, cd := usage.ChatDeltaReasoningAndContent()
294+
preferAutoparser = true
254295
contentDelta = cd
255296
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
256-
} else {
297+
} else if !preferAutoparser {
257298
reasoningDelta = goReasoning
258299
contentDelta = goContent
259300
}
301+
// If preferAutoparser is already true but this chunk carried no
302+
// autoparser data, leave both deltas empty — the next autoparser
303+
// chunk will pick things up. Falling back to Go-side here would
304+
// re-introduce the prefill-misclassification leak.
260305

261306
// Emit reasoning deltas in their own SSE chunks before any tool-call chunks
262307
// (OpenAI spec: reasoning and tool_calls never share a delta)
@@ -399,7 +444,14 @@ func processStreamWithTools(
399444
} else {
400445
// Fallback: parse tool calls from raw text (no chat deltas from backend)
401446
xlog.Debug("[ChatDeltas] no pre-parsed tool calls, falling back to Go-side text parsing")
402-
reasoning = extractor.Reasoning()
447+
// When the autoparser was active during streaming (preferAutoparser),
448+
// trust its reasoning classification rather than the Go-side
449+
// extractor's accumulated state — the latter may have misclassified
450+
// content as reasoning due to prefill detection on a tokenizer
451+
// template that contains <think>. This was visible on qwen3-4b after
452+
// #9985 enabled use_tokenizer_template: a streaming tool-call JSON
453+
// would leak as a trailing reasoning chunk via the deferred flush.
454+
reasoning = chooseDeferredReasoning(preferAutoparser, chatDeltas, extractor)
403455
cleanedResult := extractor.CleanedContent()
404456
*textContentToReturn = functions.ParseTextContent(cleanedResult, cfg.FunctionsConfig)
405457
cleanedResult = functions.CleanupLLMResult(cleanedResult, cfg.FunctionsConfig)

0 commit comments

Comments
 (0)