Skip to content

Commit aa93740

Browse files
committed
feat(gemma4): add thinking support
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 7962dd1 commit aa93740

3 files changed

Lines changed: 46 additions & 6 deletions

File tree

core/http/react-ui/src/hooks/useChat.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { useState, useCallback, useRef, useEffect } from 'react'
22
import { API_CONFIG } from '../utils/config'
33
import { apiUrl } from '../utils/basePath'
44

5-
const thinkingTagRegex = /<thinking>([\s\S]*?)<\/thinking>|<think>([\s\S]*?)<\/think>/g
6-
const openThinkTagRegex = /<thinking>|<think>/
7-
const closeThinkTagRegex = /<\/thinking>|<\/think>/
5+
const thinkingTagRegex = /<thinking>([\s\S]*?)<\/thinking>|<think>([\s\S]*?)<\/think>|<\|channel>thought([\s\S]*?)<channel\|>/g
6+
const openThinkTagRegex = /<thinking>|<think>|<\|channel>thought/
7+
const closeThinkTagRegex = /<\/thinking>|<\/think>|<channel\|>/
88

99
async function extractHttpError(response) {
1010
let errorMsg = `HTTP ${response.status}`
@@ -23,7 +23,7 @@ function extractThinking(text) {
2323
thinkingTagRegex.lastIndex = 0
2424
while ((match = thinkingTagRegex.exec(text)) !== null) {
2525
regularContent += text.slice(lastIdx, match.index)
26-
thinkingContent += match[1] || match[2] || ''
26+
thinkingContent += match[1] || match[2] || match[3] || ''
2727
lastIdx = match.index + match[0].length
2828
}
2929
regularContent += text.slice(lastIdx)
@@ -578,9 +578,9 @@ export function useChat(initialModel = '') {
578578
}
579579

580580
if (insideThinkTag) {
581-
const lastOpen = Math.max(rawContent.lastIndexOf('<thinking>'), rawContent.lastIndexOf('<think>'))
581+
const lastOpen = Math.max(rawContent.lastIndexOf('<thinking>'), rawContent.lastIndexOf('<think>'), rawContent.lastIndexOf('<|channel>thought'))
582582
if (lastOpen >= 0) {
583-
const partial = rawContent.slice(lastOpen).replace(/<thinking>|<think>/, '')
583+
const partial = rawContent.slice(lastOpen).replace(/<thinking>|<think>|<\|channel>thought/, '')
584584
setStreamingReasoning(partial)
585585
const beforeThink = rawContent.slice(0, lastOpen)
586586
const { regularContent: contentBeforeThink } = extractThinking(beforeThink)

pkg/reasoning/reasoning.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
// - <|inner_prefix|> (Apertus models)
1616
// - <seed:think> (Seed models)
1717
// - <think> (DeepSeek, Granite, ExaOne models)
18+
// - <|channel>thought (Gemma 4 models)
1819
// - <|think|> (Solar Open models)
1920
// - <thinking> (General thinking tag)
2021
// - [THINK] (Magistral models)
@@ -28,6 +29,7 @@ func DetectThinkingStartToken(prompt string, config *Config) string {
2829
"<seed:think>", // Seed models
2930
"<think>", // DeepSeek, Granite, ExaOne models
3031
"<|think|>", // Solar Open models
32+
"<|channel>thought", // Gemma 4 models
3133
"<thinking>", // General thinking tag
3234
"[THINK]", // Magistral models
3335
}
@@ -146,6 +148,7 @@ func ExtractReasoning(content string, config *Config) (reasoning string, cleaned
146148
{"<seed:think>", "</seed:think>"}, // Seed models
147149
{"<think>", "</think>"}, // DeepSeek, Granite, ExaOne models
148150
{"<|think|>", "<|end|><|begin|>assistant<|content|>"}, // Solar Open models (complex end)
151+
{"<|channel>thought", "<channel|>"}, // Gemma 4 models
149152
{"<thinking>", "</thinking>"}, // General thinking tag
150153
{"[THINK]", "[/THINK]"}, // Magistral models
151154
}

pkg/reasoning/reasoning_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,29 @@ var _ = Describe("ExtractReasoning", func() {
317317
Expect(cleaned).To(Equal("Before "))
318318
})
319319
})
320+
321+
Context("when content has <|channel>thought tags (Gemma 4)", func() {
322+
It("should extract reasoning from channel thought block", func() {
323+
content := "<|channel>thought\nThis is my reasoning\n<channel|>Hello! How can I help?"
324+
reasoning, cleaned := ExtractReasoning(content, nil)
325+
Expect(reasoning).To(Equal("This is my reasoning"))
326+
Expect(cleaned).To(Equal("Hello! How can I help?"))
327+
})
328+
329+
It("should handle unclosed channel thought block", func() {
330+
content := "<|channel>thought\nIncomplete reasoning"
331+
reasoning, cleaned := ExtractReasoning(content, nil)
332+
Expect(reasoning).To(Equal("Incomplete reasoning"))
333+
Expect(cleaned).To(Equal(""))
334+
})
335+
336+
It("should handle content before and after channel thought block", func() {
337+
content := "Before <|channel>thought\nGemma 4 reasoning\n<channel|> After"
338+
reasoning, cleaned := ExtractReasoning(content, nil)
339+
Expect(reasoning).To(Equal("Gemma 4 reasoning"))
340+
Expect(cleaned).To(Equal("Before After"))
341+
})
342+
})
320343
})
321344

322345
var _ = Describe("DetectThinkingStartToken", func() {
@@ -339,6 +362,12 @@ var _ = Describe("DetectThinkingStartToken", func() {
339362
Expect(token).To(Equal("<thinking>"))
340363
})
341364

365+
It("should detect <|channel>thought at the end (Gemma 4)", func() {
366+
prompt := "Prompt text <|channel>thought"
367+
token := DetectThinkingStartToken(prompt, nil)
368+
Expect(token).To(Equal("<|channel>thought"))
369+
})
370+
342371
It("should detect <|inner_prefix|> at the end", func() {
343372
prompt := "Prompt <|inner_prefix|>"
344373
token := DetectThinkingStartToken(prompt, nil)
@@ -817,6 +846,14 @@ var _ = Describe("ExtractReasoningWithConfig", func() {
817846
Expect(cleaned).To(Equal("Text More"))
818847
})
819848

849+
It("should strip reasoning from Gemma 4 channel tags when StripReasoningOnly is true", func() {
850+
content := "<|channel>thought\nGemma 4 reasoning\n<channel|>Response text"
851+
config := Config{StripReasoningOnly: boolPtr(true)}
852+
reasoning, cleaned := ExtractReasoningWithConfig(content, "<|channel>thought", config)
853+
Expect(reasoning).To(BeEmpty())
854+
Expect(cleaned).To(Equal("Response text"))
855+
})
856+
820857
It("should strip reasoning with multiline content when StripReasoningOnly is true", func() {
821858
content := "Start <thinking>Line 1\nLine 2\nLine 3</thinking> End"
822859
config := Config{StripReasoningOnly: boolPtr(true)}

0 commit comments

Comments
 (0)