Skip to content

Commit e1ec03d

Browse files
localai-botmudlerclaude
authored
fix(reasoning): stop prefilled <think> from swallowing tag-less answers (#10225)
* fix(reasoning): stop prefilled <think> from swallowing tag-less answers When a chat template injects the thinking start token into the prompt (so DetectThinkingStartToken returns e.g. "<think>"), the model's output begins inside a reasoning block and carries only the closing tag. The non-jinja autoparser fallback (peg-native "pure content" mode, issue #9985) prepends the start token so the extractor can pair it with the model's </think>. But on a COMPLETE response that contains no closing tag, the model answered directly with no reasoning at all. Prepending the start token there manufactures an unclosed block that swallows the entire answer into reasoning, leaving the OpenAI `content` field empty. This breaks short/direct answers — session names, JSON summaries, any terse completion where the model skips the think block — which come back with empty content. Regression surfaced by #9991, which added the defensive prefill extraction to the complete-response paths. Add reasoning.ExtractReasoningComplete: it only honors a prefilled start token when the response actually contains the matching closing tag (proof a reasoning block exists). Genuine reasoning tags already in the content still extract; tag-less content stays content. Apply it at every complete-response site (applyAutoparserOverride, realtime, openresponses). The streaming per-token extractor is intentionally left on ExtractReasoningWithConfig — mid-stream an as-yet-unclosed block is legitimate and must surface as reasoning deltas. Also adds reasoning.ClosingTokenForStart and hoists the default reasoning tag pairs to package scope so both helpers share one source of truth. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(reasoning): cover the enable_thinking=false non-thinking-mode regression Adds the end-to-end case that actually broke session summaries / auto-titles and was not covered before: a request with enable_thinking=false against a <think>-capable model. In non-thinking mode the model emits no reasoning block, so llama.cpp's autoparser returns ChatDeltas with content set and reasoning_content empty (verified against stock llama-server: same model with chat_template_kwargs.enable_thinking=false returns reasoning_content=null, content="hello"). thinkingStartToken is still "<think>" because it is detected per-model from the enable_thinking=true render, so the old code prepended it and swallowed the answer. The test fails without the ExtractReasoningComplete gate. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9323f4b commit e1ec03d

6 files changed

Lines changed: 245 additions & 25 deletions

File tree

core/http/endpoints/openai/chat.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,12 @@ func applyAutoparserOverride(
103103
// blocks like "<think></think>" that some models emit when reasoning
104104
// is disabled.
105105
if deltaReasoning == "" && deltaContent != "" {
106-
deltaReasoning, deltaContent = reason.ExtractReasoningWithConfig(deltaContent, thinkingStartToken, reasoningConfig)
106+
// Complete-response extraction: only honor a prefilled <think> start
107+
// token when deltaContent actually closes the reasoning block. Without
108+
// it the model answered directly and the whole answer must stay in
109+
// content rather than be swallowed as unclosed reasoning. See
110+
// reason.ExtractReasoningComplete.
111+
deltaReasoning, deltaContent = reason.ExtractReasoningComplete(deltaContent, thinkingStartToken, reasoningConfig)
107112
}
108113
xlog.Debug("[ChatDeltas] non-SSE no-tools: overriding result with C++ autoparser deltas",
109114
"content_len", len(deltaContent), "reasoning_len", len(deltaReasoning))

core/http/endpoints/openai/chat_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,114 @@ var _ = Describe("applyAutoparserOverride", func() {
186186
Expect(result).To(Equal(existing))
187187
})
188188
})
189+
190+
// Regression tests for the prefilled-thinking-token path (thinkingStartToken
191+
// != ""). This is the configuration the gallery qwen3 family runs in: the
192+
// chat template injects <think> into the prompt, so DetectThinkingStartToken
193+
// returns "<think>" and the model's output begins *inside* a reasoning block
194+
// — it emits a closing </think> but no opening tag.
195+
//
196+
// The defensive Go-side fallback prepends the start token so the standard
197+
// extractor can pair it with the model's </think>. But on a *complete*
198+
// response that contains NO closing tag (the model answered directly with no
199+
// reasoning at all), prepending <think> manufactures an unclosed block that
200+
// swallows the entire answer into reasoning, leaving content empty. That is
201+
// the bug: short/direct answers (session names, JSON summaries) come back
202+
// with an empty content field.
203+
Context("autoparser delivered content with empty reasoning and a prefilled thinking token", func() {
204+
const startToken = "<think>"
205+
206+
It("keeps a tag-less direct answer as content instead of swallowing it as reasoning", func() {
207+
// Model answered directly: no <think>, no </think> anywhere.
208+
chatDeltas := []*pb.ChatDelta{
209+
{Content: "hello", ReasoningContent: ""},
210+
}
211+
212+
result := applyAutoparserOverride(chatDeltas, startToken, reason.Config{}, nil)
213+
214+
Expect(result).To(HaveLen(1))
215+
Expect(result[0].Message.Content).ToNot(BeNil())
216+
Expect(*(result[0].Message.Content.(*string))).To(Equal("hello"),
217+
"a complete answer with no closing reasoning tag must stay in content")
218+
Expect(result[0].Message.Reasoning).To(BeNil(),
219+
"no reasoning block was emitted, so Reasoning must not be set")
220+
})
221+
222+
It("keeps a tag-less JSON answer as content (the summary case)", func() {
223+
raw := `{"short":"Tests pass","long":"go test ./... succeeded."}`
224+
chatDeltas := []*pb.ChatDelta{
225+
{Content: raw, ReasoningContent: ""},
226+
}
227+
228+
result := applyAutoparserOverride(chatDeltas, startToken, reason.Config{}, nil)
229+
230+
Expect(result).To(HaveLen(1))
231+
Expect(*(result[0].Message.Content.(*string))).To(Equal(raw))
232+
Expect(result[0].Message.Reasoning).To(BeNil())
233+
})
234+
235+
It("still splits reasoning when the model emits the closing tag (prefill paired with </think>)", func() {
236+
// The legitimate prefill case: <think> was in the prompt, so the
237+
// output carries only the closing tag. The closing tag is the proof
238+
// that a reasoning block exists, so extraction must run.
239+
raw := "The user wants a greeting.\n</think>\n\nHello there!"
240+
chatDeltas := []*pb.ChatDelta{
241+
{Content: raw, ReasoningContent: ""},
242+
}
243+
244+
result := applyAutoparserOverride(chatDeltas, startToken, reason.Config{}, nil)
245+
246+
Expect(result).To(HaveLen(1))
247+
content := *(result[0].Message.Content.(*string))
248+
Expect(content).To(ContainSubstring("Hello there!"))
249+
Expect(content).ToNot(ContainSubstring("</think>"))
250+
Expect(content).ToNot(ContainSubstring("The user wants a greeting"))
251+
Expect(result[0].Message.Reasoning).ToNot(BeNil())
252+
Expect(*result[0].Message.Reasoning).To(ContainSubstring("The user wants a greeting"))
253+
})
254+
255+
It("still splits a fully-tagged <think>…</think> block with a prefill token set", func() {
256+
raw := "<think>Reasoning here.</think>Final answer."
257+
chatDeltas := []*pb.ChatDelta{
258+
{Content: raw, ReasoningContent: ""},
259+
}
260+
261+
result := applyAutoparserOverride(chatDeltas, startToken, reason.Config{}, nil)
262+
263+
Expect(result).To(HaveLen(1))
264+
Expect(*(result[0].Message.Content.(*string))).To(Equal("Final answer."))
265+
Expect(result[0].Message.Reasoning).ToNot(BeNil())
266+
Expect(*result[0].Message.Reasoning).To(ContainSubstring("Reasoning here"))
267+
})
268+
269+
// End-to-end regression for the real production failure: a request with
270+
// enable_thinking=false against a <think>-capable model (qwen3 family).
271+
//
272+
// In non-thinking mode the model emits no reasoning block, so llama.cpp's
273+
// autoparser correctly returns ChatDeltas with Content set and
274+
// ReasoningContent EMPTY (verified against stock llama-server: the same
275+
// model with chat_template_kwargs.enable_thinking=false returns
276+
// reasoning_content=null and content="hello"). But thinkingStartToken is
277+
// detected per-model from the enable_thinking=TRUE render
278+
// (grpc-server renders with enable_thinking=true; DetectThinkingStartToken
279+
// does not evaluate the jinja {% if enable_thinking %} conditional), so it
280+
// is "<think>" even for this non-thinking request. The old code prepended
281+
// it and swallowed the answer. This is the case that broke session
282+
// summaries and auto-titles and was NOT covered before.
283+
It("preserves content for a non-thinking-mode request (enable_thinking=false, empty reasoning_content)", func() {
284+
// What llama.cpp's autoparser actually returns in non-thinking mode.
285+
chatDeltas := []*pb.ChatDelta{
286+
{Content: `{"short":"Go tests passed for internal/session"}`, ReasoningContent: ""},
287+
}
288+
289+
result := applyAutoparserOverride(chatDeltas, startToken, reason.Config{}, nil)
290+
291+
Expect(result).To(HaveLen(1))
292+
Expect(*(result[0].Message.Content.(*string))).To(Equal(`{"short":"Go tests passed for internal/session"}`),
293+
"non-thinking-mode answers must reach the client intact, not be swallowed as reasoning")
294+
Expect(result[0].Message.Reasoning).To(BeNil())
295+
})
296+
})
189297
})
190298

191299
var _ = Describe("mergeToolCallDeltas", func() {

core/http/endpoints/openai/realtime.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1579,15 +1579,15 @@ func triggerResponseAtTurn(ctx context.Context, session *Session, conv *Conversa
15791579
// ExtractReasoningWithConfig is a no-op when no tag pair matches,
15801580
// so it's safe to apply unconditionally in the no-reasoning branch.
15811581
if deltaReasoning == "" && deltaContent != "" {
1582-
deltaReasoning, deltaContent = reasoning.ExtractReasoningWithConfig(deltaContent, thinkingStartToken, config.ReasoningConfig)
1582+
deltaReasoning, deltaContent = reasoning.ExtractReasoningComplete(deltaContent, thinkingStartToken, config.ReasoningConfig)
15831583
}
15841584
reasoningText = deltaReasoning
15851585
responseWithoutReasoning = deltaContent
15861586
textContent = deltaContent
15871587
cleanedResponse = deltaContent
15881588
toolCalls = deltaToolCalls
15891589
} else {
1590-
reasoningText, responseWithoutReasoning = reasoning.ExtractReasoningWithConfig(rawResponse, thinkingStartToken, config.ReasoningConfig)
1590+
reasoningText, responseWithoutReasoning = reasoning.ExtractReasoningComplete(rawResponse, thinkingStartToken, config.ReasoningConfig)
15911591
textContent = functions.ParseTextContent(responseWithoutReasoning, config.FunctionsConfig)
15921592
cleanedResponse = functions.CleanupLLMResult(responseWithoutReasoning, config.FunctionsConfig)
15931593
toolCalls = functions.ParseFunctionCall(cleanedResponse, config.FunctionsConfig)

core/http/endpoints/openresponses/responses.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,7 +1356,7 @@ func handleOpenResponsesNonStream(c echo.Context, responseID string, createdAt i
13561356
thinkingStartToken := reason.DetectThinkingStartToken(template, &cfg.ReasoningConfig)
13571357

13581358
// Extract reasoning from result before cleaning
1359-
reasoningContent, cleanedResult := reason.ExtractReasoningWithConfig(result, thinkingStartToken, cfg.ReasoningConfig)
1359+
reasoningContent, cleanedResult := reason.ExtractReasoningComplete(result, thinkingStartToken, cfg.ReasoningConfig)
13601360

13611361
// Parse tool calls if using functions
13621362
var outputItems []schema.ORItemField
@@ -1996,7 +1996,7 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
19961996
finalCleanedResult = extractor.CleanedContent()
19971997
}
19981998
if finalReasoning == "" && finalCleanedResult == "" {
1999-
finalReasoning, finalCleanedResult = reason.ExtractReasoningWithConfig(result, thinkingStartToken, cfg.ReasoningConfig)
1999+
finalReasoning, finalCleanedResult = reason.ExtractReasoningComplete(result, thinkingStartToken, cfg.ReasoningConfig)
20002000
}
20012001

20022002
// Close reasoning item if it exists and wasn't closed yet
@@ -2493,7 +2493,7 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
24932493
finalCleanedResult = extractor.CleanedContent()
24942494
}
24952495
if finalReasoning == "" && finalCleanedResult == "" {
2496-
finalReasoning, finalCleanedResult = reason.ExtractReasoningWithConfig(result, thinkingStartToken, cfg.ReasoningConfig)
2496+
finalReasoning, finalCleanedResult = reason.ExtractReasoningComplete(result, thinkingStartToken, cfg.ReasoningConfig)
24972497
}
24982498

24992499
// Close reasoning item if it exists and wasn't closed yet

pkg/reasoning/reasoning.go

Lines changed: 77 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,35 @@ func ExtractReasoningWithConfig(content, thinkingStartToken string, config Confi
8989
return reasoning, cleanedContent
9090
}
9191

92+
// ExtractReasoningComplete extracts reasoning from a COMPLETE (non-streaming)
93+
// model response. It behaves like ExtractReasoningWithConfig except that it only
94+
// honors a prefilled thinking start token when the response actually contains
95+
// the matching closing tag.
96+
//
97+
// Rationale: when a chat template injects the start token into the prompt (so
98+
// DetectThinkingStartToken returns e.g. "<think>"), the model's output begins
99+
// inside a reasoning block and carries only the closing tag. The defensive
100+
// fallback prepends the start token so the extractor can pair it with that
101+
// close tag. But on a COMPLETE response with no closing tag, the model answered
102+
// directly with no reasoning at all — prepending the start token would
103+
// manufacture an unclosed block that swallows the entire answer into reasoning,
104+
// leaving content empty (breaking short/direct answers such as session names or
105+
// JSON summaries). Genuine reasoning tags already present in the content still
106+
// extract, because dropping the synthetic prefill does not affect them.
107+
//
108+
// Streaming callers must keep using ExtractReasoningWithConfig: mid-stream an
109+
// as-yet-unclosed block is legitimate and its tokens should surface as
110+
// reasoning deltas as they arrive.
111+
func ExtractReasoningComplete(content, thinkingStartToken string, config Config) (reasoning string, cleanedContent string) {
112+
startToken := thinkingStartToken
113+
if startToken != "" {
114+
if end := ClosingTokenForStart(startToken, &config); end == "" || !strings.Contains(content, end) {
115+
startToken = ""
116+
}
117+
}
118+
return ExtractReasoningWithConfig(content, startToken, config)
119+
}
120+
92121
// PrependThinkingTokenIfNeeded prepends the thinking start token to content if it was
93122
// detected in the prompt. This allows the standard extraction logic to work correctly
94123
// for models where the thinking token is already in the prompt.
@@ -131,6 +160,48 @@ func PrependThinkingTokenIfNeeded(content string, startToken string) string {
131160
return startToken + content
132161
}
133162

163+
// defaultReasoningTagPairs are the built-in start/end reasoning tag pairs,
164+
// matching llama.cpp's chat-parser.cpp. Kept at package scope so that
165+
// ExtractReasoning and ClosingTokenForStart share a single source of truth.
166+
var defaultReasoningTagPairs = []TagPair{
167+
{Start: "<|START_THINKING|>", End: "<|END_THINKING|>"}, // Command-R models
168+
{Start: "<|inner_prefix|>", End: "<|inner_suffix|>"}, // Apertus models
169+
{Start: "<seed:think>", End: "</seed:think>"}, // Seed models
170+
{Start: "<think>", End: "</think>"}, // DeepSeek, Granite, ExaOne models
171+
{Start: "<|think|>", End: "<|end|><|begin|>assistant<|content|>"}, // Solar Open models (complex end)
172+
{Start: "<|channel>thought", End: "<channel|>"}, // Gemma 4 models
173+
{Start: "<thinking>", End: "</thinking>"}, // General thinking tag
174+
{Start: "[THINK]", End: "[/THINK]"}, // Magistral models
175+
}
176+
177+
// ClosingTokenForStart returns the closing reasoning tag that pairs with the
178+
// given start token, searching custom config TagPairs first then the built-in
179+
// defaults. Returns "" when startToken is empty or unrecognized.
180+
//
181+
// Used by the non-streaming autoparser fallback to decide whether a complete
182+
// response that began with a prefilled thinking token actually closed its
183+
// reasoning block: only then is synthesizing the start token (so the standard
184+
// extractor can pair it with the model's close tag) safe. A complete response
185+
// with no closing tag is a direct answer, not unclosed reasoning.
186+
func ClosingTokenForStart(startToken string, config *Config) string {
187+
if startToken == "" {
188+
return ""
189+
}
190+
if config != nil {
191+
for _, pair := range config.TagPairs {
192+
if pair.Start == startToken {
193+
return pair.End
194+
}
195+
}
196+
}
197+
for _, pair := range defaultReasoningTagPairs {
198+
if pair.Start == startToken {
199+
return pair.End
200+
}
201+
}
202+
return ""
203+
}
204+
134205
// ExtractReasoning extracts reasoning content from thinking tags and returns
135206
// both the extracted reasoning and the cleaned content (with tags removed).
136207
// It handles <thinking>...</thinking> and <think>...</think> tags.
@@ -145,22 +216,7 @@ func ExtractReasoning(content string, config *Config) (reasoning string, cleaned
145216
var cleanedParts []string
146217
remaining := content
147218

148-
// Define default tag pairs to look for (matching llama.cpp's chat-parser.cpp)
149-
defaultTagPairs := []struct {
150-
start string
151-
end string
152-
}{
153-
{"<|START_THINKING|>", "<|END_THINKING|>"}, // Command-R models
154-
{"<|inner_prefix|>", "<|inner_suffix|>"}, // Apertus models
155-
{"<seed:think>", "</seed:think>"}, // Seed models
156-
{"<think>", "</think>"}, // DeepSeek, Granite, ExaOne models
157-
{"<|think|>", "<|end|><|begin|>assistant<|content|>"}, // Solar Open models (complex end)
158-
{"<|channel>thought", "<channel|>"}, // Gemma 4 models
159-
{"<thinking>", "</thinking>"}, // General thinking tag
160-
{"[THINK]", "[/THINK]"}, // Magistral models
161-
}
162-
163-
// Merge custom tag pairs with default tag pairs (custom pairs first for priority)
219+
// Merge custom tag pairs (highest priority) with the built-in defaults.
164220
var tagPairs []struct {
165221
start string
166222
end string
@@ -175,9 +231,11 @@ func ExtractReasoning(content string, config *Config) (reasoning string, cleaned
175231
}
176232
}
177233
}
178-
// Add default tag pairs
179-
for _, pair := range defaultTagPairs {
180-
tagPairs = append(tagPairs, pair)
234+
for _, pair := range defaultReasoningTagPairs {
235+
tagPairs = append(tagPairs, struct {
236+
start string
237+
end string
238+
}{pair.Start, pair.End})
181239
}
182240

183241
// Track the last position we've processed

pkg/reasoning/reasoning_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,6 +1175,55 @@ var _ = Describe("Custom Tokens and Tag Pairs Integration", func() {
11751175
})
11761176
})
11771177

1178+
var _ = Describe("ClosingTokenForStart", func() {
1179+
It("returns the default closing tag for a known start token", func() {
1180+
Expect(ClosingTokenForStart("<think>", nil)).To(Equal("</think>"))
1181+
Expect(ClosingTokenForStart("<thinking>", nil)).To(Equal("</thinking>"))
1182+
Expect(ClosingTokenForStart("[THINK]", nil)).To(Equal("[/THINK]"))
1183+
})
1184+
1185+
It("returns empty for an empty or unknown start token", func() {
1186+
Expect(ClosingTokenForStart("", nil)).To(BeEmpty())
1187+
Expect(ClosingTokenForStart("<nope>", nil)).To(BeEmpty())
1188+
})
1189+
1190+
It("prefers custom config tag pairs over the defaults", func() {
1191+
cfg := &Config{TagPairs: []TagPair{{Start: "<think>", End: "<<END>>"}}}
1192+
Expect(ClosingTokenForStart("<think>", cfg)).To(Equal("<<END>>"))
1193+
})
1194+
})
1195+
1196+
var _ = Describe("ExtractReasoningComplete", func() {
1197+
const startToken = "<think>"
1198+
1199+
It("keeps a tag-less answer as content when a start token is prefilled but no close tag is present", func() {
1200+
// The bug guard: prompt-prefilled <think>, model answered directly with
1201+
// no reasoning. The synthetic prefill must not swallow it as reasoning.
1202+
reasoning, content := ExtractReasoningComplete("hello", startToken, Config{})
1203+
Expect(reasoning).To(BeEmpty())
1204+
Expect(content).To(Equal("hello"))
1205+
})
1206+
1207+
It("extracts reasoning when the model emits only the closing tag (legitimate prefill)", func() {
1208+
reasoning, content := ExtractReasoningComplete("the rationale\n</think>\n\nthe answer", startToken, Config{})
1209+
Expect(reasoning).To(ContainSubstring("the rationale"))
1210+
Expect(content).To(ContainSubstring("the answer"))
1211+
Expect(content).ToNot(ContainSubstring("</think>"))
1212+
})
1213+
1214+
It("extracts a fully-tagged block regardless of the prefill token", func() {
1215+
reasoning, content := ExtractReasoningComplete("<think>r</think>answer", startToken, Config{})
1216+
Expect(reasoning).To(Equal("r"))
1217+
Expect(content).To(Equal("answer"))
1218+
})
1219+
1220+
It("behaves like ExtractReasoningWithConfig when no start token is prefilled", func() {
1221+
reasoning, content := ExtractReasoningComplete("<think>r</think>answer", "", Config{})
1222+
Expect(reasoning).To(Equal("r"))
1223+
Expect(content).To(Equal("answer"))
1224+
})
1225+
})
1226+
11781227
// Helper function to create bool pointers for test configs
11791228
func boolPtr(b bool) *bool {
11801229
return &b

0 commit comments

Comments
 (0)