Skip to content

Commit 17bf60a

Browse files
committed
fix(reasoning): acccumulate and strip reasoning tags from autoparser results
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 557d0f0 commit 17bf60a

4 files changed

Lines changed: 135 additions & 4 deletions

File tree

core/http/endpoints/openai/chat.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,11 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
8686

8787
// Prefer pre-parsed chat deltas from C++ autoparser when available
8888
if tokenUsage.HasChatDeltaContent() {
89-
reasoningDelta, contentDelta = tokenUsage.ChatDeltaReasoningAndContent()
89+
rawReasoning, cd := tokenUsage.ChatDeltaReasoningAndContent()
90+
contentDelta = cd
91+
// Strip reasoning tags (e.g. <|channel>thought / <channel|>) that
92+
// the C++ autoparser includes as part of reasoning content.
93+
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
9094
// Keep extractor state consistent for fallback
9195
extractor.ProcessToken(s)
9296
} else {
@@ -149,7 +153,11 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
149153

150154
// Prefer pre-parsed chat deltas from C++ autoparser when available
151155
if usage.HasChatDeltaContent() {
152-
reasoningDelta, contentDelta = usage.ChatDeltaReasoningAndContent()
156+
rawReasoning, cd := usage.ChatDeltaReasoningAndContent()
157+
contentDelta = cd
158+
// Strip reasoning tags (e.g. <|channel>thought / <channel|>) that
159+
// the C++ autoparser includes as part of reasoning content.
160+
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
153161
// Keep extractor state consistent for fallback
154162
extractor.ProcessToken(s)
155163
} else {

core/http/endpoints/openresponses/responses.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1823,7 +1823,9 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
18231823
var reasoningDelta, contentDelta string
18241824
// Prefer pre-parsed chat deltas from C++ autoparser when available
18251825
if tokenUsage.HasChatDeltaContent() {
1826-
reasoningDelta, contentDelta = tokenUsage.ChatDeltaReasoningAndContent()
1826+
rawReasoning, cd := tokenUsage.ChatDeltaReasoningAndContent()
1827+
contentDelta = cd
1828+
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
18271829
extractor.ProcessToken(token) // keep state consistent
18281830
} else {
18291831
reasoningDelta, contentDelta = extractor.ProcessToken(token)
@@ -2350,7 +2352,9 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
23502352
var reasoningDelta, contentDelta string
23512353
// Prefer pre-parsed chat deltas from C++ autoparser when available
23522354
if tokenUsage.HasChatDeltaContent() {
2353-
reasoningDelta, contentDelta = tokenUsage.ChatDeltaReasoningAndContent()
2355+
rawReasoning, cd := tokenUsage.ChatDeltaReasoningAndContent()
2356+
contentDelta = cd
2357+
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
23542358
extractor.ProcessToken(token) // keep state consistent
23552359
} else {
23562360
reasoningDelta, contentDelta = extractor.ProcessToken(token)

pkg/reasoning/extractor.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ type ReasoningExtractor struct {
2121
lastReasoning string
2222
lastCleaned string
2323
suppressReasoning bool
24+
25+
// ChatDelta reasoning accumulator — used by ProcessChatDeltaReasoning
26+
// to strip reasoning tags (e.g. <|channel>thought, <channel|>) that
27+
// the C++ autoparser includes in reasoning_content deltas.
28+
cdReasoningAccum string
29+
cdLastStrippedReasoning string
2430
}
2531

2632
// NewReasoningExtractor creates a new extractor for the given thinking token and config.
@@ -64,6 +70,33 @@ func (e *ReasoningExtractor) ProcessToken(token string) (reasoningDelta, content
6470
return reasoningDelta, contentDelta
6571
}
6672

73+
// ProcessChatDeltaReasoning accumulates raw reasoning text from C++ autoparser
74+
// ChatDeltas, strips any embedded reasoning tags (e.g. <|channel>thought …
75+
// <channel|> for Gemma 4), and returns only the new stripped delta.
76+
// This prevents tag tokens from leaking into the reasoning field of SSE chunks.
77+
func (e *ReasoningExtractor) ProcessChatDeltaReasoning(rawDelta string) string {
78+
if rawDelta == "" {
79+
return ""
80+
}
81+
e.cdReasoningAccum += rawDelta
82+
83+
// Strip reasoning tags from accumulated ChatDelta reasoning
84+
stripped, _ := ExtractReasoning(e.cdReasoningAccum, &e.config)
85+
86+
var delta string
87+
if len(stripped) > len(e.cdLastStrippedReasoning) && strings.HasPrefix(stripped, e.cdLastStrippedReasoning) {
88+
delta = stripped[len(e.cdLastStrippedReasoning):]
89+
} else if stripped != e.cdLastStrippedReasoning && stripped != "" {
90+
delta = stripped
91+
}
92+
e.cdLastStrippedReasoning = stripped
93+
94+
if e.suppressReasoning {
95+
return ""
96+
}
97+
return delta
98+
}
99+
67100
// Reasoning returns the total accumulated reasoning after streaming.
68101
func (e *ReasoningExtractor) Reasoning() string {
69102
return e.lastReasoning
@@ -84,6 +117,8 @@ func (e *ReasoningExtractor) Reset() {
84117
e.accumulated = ""
85118
e.lastReasoning = ""
86119
e.lastCleaned = ""
120+
e.cdReasoningAccum = ""
121+
e.cdLastStrippedReasoning = ""
87122
}
88123

89124
// ResetAndSuppressReasoning clears state and suppresses future reasoning deltas.
@@ -95,6 +130,8 @@ func (e *ReasoningExtractor) ResetAndSuppressReasoning() {
95130
e.accumulated = ""
96131
e.lastReasoning = ""
97132
e.lastCleaned = ""
133+
e.cdReasoningAccum = ""
134+
e.cdLastStrippedReasoning = ""
98135
e.suppressReasoning = true
99136
}
100137

pkg/reasoning/extractor_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,4 +195,86 @@ var _ = Describe("ReasoningExtractor", func() {
195195
Expect(ext.CleanedContent()).To(Equal("visible content"))
196196
})
197197
})
198+
199+
Context("ProcessChatDeltaReasoning with Gemma 4 tags", func() {
200+
It("should strip <|channel>thought and <channel|> tags from streaming deltas", func() {
201+
ext := NewReasoningExtractor("<|channel>thought", Config{})
202+
203+
// Simulate C++ autoparser sending tag tokens as reasoning
204+
d1 := ext.ProcessChatDeltaReasoning("<|channel>")
205+
Expect(d1).To(BeEmpty(), "start tag prefix should be buffered, not emitted")
206+
207+
d2 := ext.ProcessChatDeltaReasoning("thought")
208+
Expect(d2).To(BeEmpty(), "start tag suffix should be buffered, not emitted")
209+
210+
d3 := ext.ProcessChatDeltaReasoning("\n")
211+
Expect(d3).To(BeEmpty(), "newline after start tag should not emit yet")
212+
213+
d4 := ext.ProcessChatDeltaReasoning("The")
214+
Expect(d4).To(Equal("The"))
215+
216+
d5 := ext.ProcessChatDeltaReasoning(" user")
217+
Expect(d5).To(Equal(" user"))
218+
219+
d6 := ext.ProcessChatDeltaReasoning(" asks")
220+
Expect(d6).To(Equal(" asks"))
221+
222+
d7 := ext.ProcessChatDeltaReasoning("\n")
223+
Expect(d7).To(Equal("\n"))
224+
225+
d8 := ext.ProcessChatDeltaReasoning("<channel|>")
226+
Expect(d8).To(BeEmpty(), "close tag should be consumed, not emitted")
227+
})
228+
229+
It("should handle empty deltas", func() {
230+
ext := NewReasoningExtractor("<|channel>thought", Config{})
231+
d := ext.ProcessChatDeltaReasoning("")
232+
Expect(d).To(BeEmpty())
233+
})
234+
235+
It("should pass through reasoning without tags unchanged", func() {
236+
ext := NewReasoningExtractor("<think>", Config{})
237+
238+
// When C++ autoparser already strips tags (e.g. <think> models),
239+
// reasoning arrives clean — just pass it through.
240+
d1 := ext.ProcessChatDeltaReasoning("I need to")
241+
Expect(d1).To(Equal("I need to"))
242+
243+
d2 := ext.ProcessChatDeltaReasoning(" think carefully")
244+
Expect(d2).To(Equal(" think carefully"))
245+
})
246+
247+
It("should strip <think> tags if C++ autoparser includes them", func() {
248+
ext := NewReasoningExtractor("<think>", Config{})
249+
250+
d1 := ext.ProcessChatDeltaReasoning("<think>")
251+
Expect(d1).To(BeEmpty())
252+
253+
d2 := ext.ProcessChatDeltaReasoning("reasoning")
254+
Expect(d2).To(Equal("reasoning"))
255+
256+
d3 := ext.ProcessChatDeltaReasoning("</think>")
257+
Expect(d3).To(BeEmpty())
258+
})
259+
260+
It("should respect suppressReasoning", func() {
261+
ext := NewReasoningExtractor("<|channel>thought", Config{})
262+
ext.ResetAndSuppressReasoning()
263+
264+
d := ext.ProcessChatDeltaReasoning("some reasoning")
265+
Expect(d).To(BeEmpty())
266+
})
267+
268+
It("should reset ChatDelta state on Reset", func() {
269+
ext := NewReasoningExtractor("<|channel>thought", Config{})
270+
271+
ext.ProcessChatDeltaReasoning("<|channel>thought")
272+
ext.ProcessChatDeltaReasoning("\nfirst reasoning")
273+
ext.Reset()
274+
275+
// After reset, should start fresh
276+
d := ext.ProcessChatDeltaReasoning("clean reasoning")
277+
Expect(d).To(Equal("clean reasoning"))
278+
})
279+
})
198280
})

0 commit comments

Comments
 (0)