Skip to content

Commit 53deeb1

Browse files
committed
fix(reasoning): suppress partial tag tokens during autoparser warm-up
The C++ PEG parser needs a few tokens to identify the reasoning format (e.g. "<|channel>thought\n" for Gemma 4). During this warm-up, the gRPC layer was sending raw partial tag tokens to Go, which leaked into the reasoning field. - Clear reply.message in gRPC when autoparser is active but has no diffs yet, matching llama.cpp server behavior of only emitting classified output - Prefer C++ autoparser chat deltas for reasoning/content in all streaming paths, falling back to Go-side extraction for backends without autoparser (e.g. vLLM) - Override non-streaming no-tools result with chat delta content when available - Guard PrependThinkingTokenIfNeeded against partial tag prefixes during streaming accumulation - Reorder default thinking tokens so <|channel>thought is checked before <|think|> (Gemma 4 templates contain both)
1 parent c5a840f commit 53deeb1

4 files changed

Lines changed: 34 additions & 28 deletions

File tree

backend/cpp/llama-cpp/grpc-server.cpp

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1608,8 +1608,18 @@ class BackendServiceImpl final : public backend::Backend::Service {
16081608
auto attach_chat_deltas = [](backend::Reply & reply, server_task_result * raw_result) {
16091609
// Try streaming partial result first
16101610
auto* partial = dynamic_cast<server_task_result_cmpl_partial*>(raw_result);
1611-
if (partial && !partial->oaicompat_msg_diffs.empty()) {
1612-
populate_chat_deltas_from_diffs(reply, partial->oaicompat_msg_diffs);
1611+
if (partial) {
1612+
if (!partial->oaicompat_msg_diffs.empty()) {
1613+
populate_chat_deltas_from_diffs(reply, partial->oaicompat_msg_diffs);
1614+
} else if (partial->is_updated) {
1615+
// Autoparser is active but hasn't classified this chunk yet
1616+
// (PEG parser warming up). Clear the raw message so the Go
1617+
// side doesn't try to parse partial tag tokens (e.g. "<|channel>"
1618+
// before the full "<|channel>thought\n" is received).
1619+
// This matches llama.cpp server behavior which only emits SSE
1620+
// chunks when the parser produces diffs.
1621+
reply.set_message("");
1622+
}
16131623
return;
16141624
}
16151625
// Try final result

core/http/endpoints/openai/chat.go

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,24 +84,18 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
8484
_, _, _, err := ComputeChoices(req, s, config, cl, startupOptions, loader, func(s string, c *[]schema.Choice) {}, func(s string, tokenUsage backend.TokenUsage) bool {
8585
var reasoningDelta, contentDelta string
8686

87-
// Always keep the Go-side extractor in sync with raw tokens
88-
// (needed for backends that never send chat deltas).
87+
// Always keep the Go-side extractor in sync with raw tokens so it
88+
// can serve as fallback for backends without an autoparser (e.g. vLLM).
8989
goReasoning, goContent := extractor.ProcessToken(s)
9090

91-
// Prefer pre-parsed chat deltas from C++ autoparser when available.
91+
// When C++ autoparser chat deltas are available, prefer them — they
92+
// handle model-specific formats (Gemma 4, etc.) without Go-side tags.
93+
// Otherwise fall back to Go-side extraction.
9294
if tokenUsage.HasChatDeltaContent() {
9395
rawReasoning, cd := tokenUsage.ChatDeltaReasoningAndContent()
9496
contentDelta = cd
95-
// Strip reasoning tags (e.g. <|channel>thought / <channel|>) that
96-
// the C++ autoparser includes as part of reasoning content.
9797
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
98-
} else if config.TemplateConfig.UseTokenizerTemplate {
99-
// C++ autoparser is active (jinja templates) but hasn't emitted
100-
// chat deltas for this chunk yet — PEG parser is still warming up
101-
// (e.g. accumulating "<|channel>thought\n" for Gemma 4).
102-
// Suppress Go-side output to avoid leaking partial tag tokens.
10398
} else {
104-
// No autoparser — use Go-side extraction as the sole source.
10599
reasoningDelta = goReasoning
106100
contentDelta = goContent
107101
}
@@ -159,20 +153,13 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
159153

160154
var reasoningDelta, contentDelta string
161155

162-
// Always keep the Go-side extractor in sync with raw tokens
163156
goReasoning, goContent := extractor.ProcessToken(s)
164157

165-
// Prefer pre-parsed chat deltas from C++ autoparser when available.
166158
if usage.HasChatDeltaContent() {
167159
rawReasoning, cd := usage.ChatDeltaReasoningAndContent()
168160
contentDelta = cd
169-
// Strip reasoning tags (e.g. <|channel>thought / <channel|>) that
170-
// the C++ autoparser includes as part of reasoning content.
171161
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
172-
} else if config.TemplateConfig.UseTokenizerTemplate {
173-
// C++ autoparser warming up — suppress Go-side to avoid tag leaks.
174162
} else {
175-
// No autoparser — use Go-side extraction.
176163
reasoningDelta = goReasoning
177164
contentDelta = goContent
178165
}

core/http/endpoints/openresponses/responses.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1821,14 +1821,15 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
18211821
// If no tool calls detected yet, handle reasoning and text
18221822
if !inToolCallMode {
18231823
var reasoningDelta, contentDelta string
1824-
// Prefer pre-parsed chat deltas from C++ autoparser when available
1824+
goReasoning, goContent := extractor.ProcessToken(token)
1825+
18251826
if tokenUsage.HasChatDeltaContent() {
18261827
rawReasoning, cd := tokenUsage.ChatDeltaReasoningAndContent()
18271828
contentDelta = cd
18281829
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
1829-
extractor.ProcessToken(token) // keep state consistent
18301830
} else {
1831-
reasoningDelta, contentDelta = extractor.ProcessToken(token)
1831+
reasoningDelta = goReasoning
1832+
contentDelta = goContent
18321833
}
18331834

18341835
// Handle reasoning item
@@ -2350,14 +2351,15 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
23502351
accumulatedText += token
23512352

23522353
var reasoningDelta, contentDelta string
2353-
// Prefer pre-parsed chat deltas from C++ autoparser when available
2354+
goReasoning, goContent := extractor.ProcessToken(token)
2355+
23542356
if tokenUsage.HasChatDeltaContent() {
23552357
rawReasoning, cd := tokenUsage.ChatDeltaReasoningAndContent()
23562358
contentDelta = cd
23572359
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
2358-
extractor.ProcessToken(token) // keep state consistent
23592360
} else {
2360-
reasoningDelta, contentDelta = extractor.ProcessToken(token)
2361+
reasoningDelta = goReasoning
2362+
contentDelta = goContent
23612363
}
23622364

23632365
// Handle reasoning item

pkg/reasoning/reasoning.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ func DetectThinkingStartToken(prompt string, config *Config) string {
2525
// Based on llama.cpp's chat-parser.cpp implementations
2626
defaultTokens := []string{
2727
"<|START_THINKING|>", // Command-R models
28+
"<|channel>thought", // Gemma 4 models (before <|think|> — Gemma 4 templates contain both)
2829
"<|inner_prefix|>", // Apertus models
2930
"<seed:think>", // Seed models
3031
"<think>", // DeepSeek, Granite, ExaOne models
3132
"<|think|>", // Solar Open models
32-
"<|channel>thought", // Gemma 4 models
3333
"<thinking>", // General thinking tag
3434
"[THINK]", // Magistral models
3535
}
@@ -102,11 +102,18 @@ func PrependThinkingTokenIfNeeded(content string, startToken string) string {
102102
return r == ' ' || r == '\t' || r == '\n' || r == '\r'
103103
})
104104

105-
// If content already starts with the token, don't prepend
105+
// If content already contains the token, don't prepend
106106
if strings.Contains(trimmed, startToken) {
107107
return content
108108
}
109109

110+
// If content is a non-empty prefix of the start token (e.g. "<|channel>"
111+
// accumulating toward "<|channel>thought"), don't prepend — we're still
112+
// receiving the tag token-by-token during streaming.
113+
if trimmed != "" && strings.HasPrefix(startToken, trimmed) {
114+
return content
115+
}
116+
110117
// Find where leading whitespace ends
111118
whitespaceEnd := 0
112119
for whitespaceEnd < len(content) {

0 commit comments

Comments
 (0)