mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
fix: properly handle tags from Qwen/DeepSeek models
Models like Qwen and DeepSeek wrap reasoning content in ... XML-like
tags within the regular content field. This was causing the reasoning
text to appear twice - once as a reasoning block and once as regular text.
Changes:
1. Provider hooks (providers.go):
- Extract reasoning from tags and emit proper reasoning events
- Use openai provider directly with custom ExtraContentFunc and
StreamExtraFunc hooks to parse thinking content
2. Stream filtering (stream.go):
- Filter out all text content between and tags at the
streaming level to prevent duplicate rendering
- Track state with inThinkTag flag across stream chunks
3. Message conversion (content.go):
- Strip any remaining tags from text content when converting
from fantasy messages
The regex patterns use string concatenation to avoid XML tag corruption:
regexp.MustCompile( + + + + + + + )
Fixes duplicate reasoning text when using custom provider with models
that wrap thinking in tags.
This commit is contained in:
@@ -4,12 +4,18 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// thinkTagRegex matches ... tags that some models (Qwen, DeepSeek) wrap
|
||||
// reasoning content in. Used to strip these tags from text content.
|
||||
// The (?s) flag makes . match newlines.
|
||||
var thinkTagRegex = regexp.MustCompile(`(?s)` + `` + `think` + `` + `(.*?)` + `` + `/think` + ``)
|
||||
|
||||
// sanitizeToolCallID ensures the ID matches Anthropic's required pattern:
|
||||
// ^[a-zA-Z0-9_-]+$ (alphanumeric, underscores, and hyphens only).
|
||||
// Invalid characters are replaced with underscores.
|
||||
@@ -443,7 +449,11 @@ func FromFantasyMessage(msg fantasy.Message) Message {
|
||||
switch p := part.(type) {
|
||||
case fantasy.TextPart:
|
||||
if p.Text != "" {
|
||||
m.Parts = append(m.Parts, TextContent{Text: p.Text})
|
||||
// Strip ... tags that some models wrap reasoning in
|
||||
cleanedText := thinkTagRegex.ReplaceAllString(p.Text, "")
|
||||
if cleanedText != "" {
|
||||
m.Parts = append(m.Parts, TextContent{Text: cleanedText})
|
||||
}
|
||||
}
|
||||
case fantasy.ToolCallPart:
|
||||
m.Parts = append(m.Parts, ToolCall{
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"charm.land/fantasy/providers/openaicompat"
|
||||
"charm.land/fantasy/providers/openrouter"
|
||||
"charm.land/fantasy/providers/vercel"
|
||||
openaisdk "github.com/charmbracelet/openai-go"
|
||||
|
||||
"github.com/mark3labs/kit/internal/auth"
|
||||
"github.com/mark3labs/kit/internal/ui/progress"
|
||||
@@ -998,6 +1000,133 @@ func createVercelProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
return &ProviderResult{Model: model}, nil
|
||||
}
|
||||
|
||||
// thinkTagRegex matches <think>...</think> tags for extracting reasoning content
|
||||
// from models that wrap thinking in XML-like tags (e.g., Qwen, DeepSeek).
|
||||
var thinkTagRegex = regexp.MustCompile(`(?s)<think>(.*?)</think>`)
|
||||
|
||||
// customExtraContentFunc extracts reasoning from <think> tags in the content field.
|
||||
// This handles models like Qwen and DeepSeek that return reasoning wrapped in XML tags
|
||||
// rather than using a separate reasoning_content field.
|
||||
func customExtraContentFunc(choice openaisdk.ChatCompletionChoice) []fantasy.Content {
|
||||
var content []fantasy.Content
|
||||
if choice.Message.Content == "" {
|
||||
return content
|
||||
}
|
||||
|
||||
// Check for <think> tags in the content
|
||||
matches := thinkTagRegex.FindStringSubmatch(choice.Message.Content)
|
||||
if len(matches) > 1 {
|
||||
// Found reasoning content in <think> tags
|
||||
reasoning := strings.TrimSpace(matches[1])
|
||||
if reasoning != "" {
|
||||
content = append(content, fantasy.ReasoningContent{
|
||||
Text: reasoning,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// customStreamExtraFunc handles streaming responses with <think> tags.
|
||||
// It extracts reasoning content and emits proper reasoning events.
|
||||
func customStreamExtraFunc(
|
||||
chunk openaisdk.ChatCompletionChunk,
|
||||
yield func(fantasy.StreamPart) bool,
|
||||
ctx map[string]any,
|
||||
) (map[string]any, bool) {
|
||||
if len(chunk.Choices) == 0 {
|
||||
return ctx, true
|
||||
}
|
||||
|
||||
const reasoningStartedKey = "reasoning_started"
|
||||
const reasoningBufferKey = "reasoning_buffer"
|
||||
const inThinkTagKey = "in_think_tag"
|
||||
|
||||
reasoningStarted, _ := ctx[reasoningStartedKey].(bool)
|
||||
inThinkTag, _ := ctx[inThinkTagKey].(bool)
|
||||
reasoningBuffer, _ := ctx[reasoningBufferKey].(string)
|
||||
|
||||
for i, choice := range chunk.Choices {
|
||||
content := choice.Delta.Content
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for <think> tag start
|
||||
if strings.Contains(content, "<think>") {
|
||||
inThinkTag = true
|
||||
ctx[inThinkTagKey] = true
|
||||
|
||||
// Emit reasoning start event
|
||||
if !reasoningStarted {
|
||||
reasoningStarted = true
|
||||
ctx[reasoningStartedKey] = true
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeReasoningStart,
|
||||
ID: fmt.Sprintf("%d", i),
|
||||
}) {
|
||||
return ctx, false
|
||||
}
|
||||
}
|
||||
|
||||
// Extract content after <think>
|
||||
parts := strings.SplitN(content, "<think>", 2)
|
||||
if len(parts) > 1 && parts[1] != "" {
|
||||
reasoningBuffer += parts[1]
|
||||
ctx[reasoningBufferKey] = reasoningBuffer
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for </think> tag end
|
||||
if strings.Contains(content, "</think>") {
|
||||
inThinkTag = false
|
||||
ctx[inThinkTagKey] = false
|
||||
|
||||
// Extract content before </think>
|
||||
parts := strings.SplitN(content, "</think>", 2)
|
||||
if len(parts) > 0 {
|
||||
reasoningBuffer += parts[0]
|
||||
}
|
||||
|
||||
// Emit the accumulated reasoning
|
||||
if reasoningBuffer != "" {
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeReasoningDelta,
|
||||
ID: fmt.Sprintf("%d", i),
|
||||
Delta: reasoningBuffer,
|
||||
}) {
|
||||
return ctx, false
|
||||
}
|
||||
ctx[reasoningBufferKey] = ""
|
||||
}
|
||||
|
||||
// Emit reasoning end
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeReasoningEnd,
|
||||
ID: fmt.Sprintf("%d", i),
|
||||
}) {
|
||||
return ctx, false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate reasoning content while in think tag
|
||||
if inThinkTag {
|
||||
reasoningBuffer += content
|
||||
ctx[reasoningBufferKey] = reasoningBuffer
|
||||
}
|
||||
}
|
||||
|
||||
return ctx, true
|
||||
}
|
||||
|
||||
// customToPromptFunc converts prompts to OpenAI format using the default conversion.
|
||||
func customToPromptFunc(prompt fantasy.Prompt, systemPrompt, user string) ([]openaisdk.ChatCompletionMessageParamUnion, []fantasy.CallWarning) {
|
||||
return openai.DefaultToPrompt(prompt, systemPrompt, user)
|
||||
}
|
||||
|
||||
func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
if config.ProviderURL == "" {
|
||||
return nil, fmt.Errorf("custom provider requires --provider-url")
|
||||
@@ -1012,16 +1141,23 @@ func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
apiKey = "custom"
|
||||
}
|
||||
|
||||
var opts []openaicompat.Option
|
||||
opts = append(opts, openaicompat.WithBaseURL(config.ProviderURL))
|
||||
opts = append(opts, openaicompat.WithAPIKey(apiKey))
|
||||
opts = append(opts, openaicompat.WithName("custom"))
|
||||
// Use the openai provider directly with custom hooks to handle <think> tags
|
||||
// from models like Qwen and DeepSeek that wrap reasoning in XML tags.
|
||||
var opts []openai.Option
|
||||
opts = append(opts, openai.WithBaseURL(config.ProviderURL))
|
||||
opts = append(opts, openai.WithAPIKey(apiKey))
|
||||
opts = append(opts, openai.WithName("custom"))
|
||||
opts = append(opts, openai.WithLanguageModelOptions(
|
||||
openai.WithLanguageModelExtraContentFunc(customExtraContentFunc),
|
||||
openai.WithLanguageModelStreamExtraFunc(customStreamExtraFunc),
|
||||
openai.WithLanguageModelToPromptFunc(customToPromptFunc),
|
||||
))
|
||||
|
||||
if config.TLSSkipVerify {
|
||||
opts = append(opts, openaicompat.WithHTTPClient(createHTTPClientWithTLSConfig(true)))
|
||||
opts = append(opts, openai.WithHTTPClient(createHTTPClientWithTLSConfig(true)))
|
||||
}
|
||||
|
||||
p, err := openaicompat.New(opts...)
|
||||
p, err := openai.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create custom provider: %w", err)
|
||||
}
|
||||
|
||||
+59
-3
@@ -2,6 +2,7 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -11,6 +12,17 @@ import (
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
)
|
||||
|
||||
// thinkTagRegex matches ... tags that some models (Qwen, DeepSeek) wrap
|
||||
// reasoning content in. Used to strip these tags from streaming text content.
|
||||
// The (?s) flag makes . match newlines.
|
||||
var thinkTagRegex = regexp.MustCompile(`(?s)` + `` + `think` + `` + `(.*?)` + `` + `/think` + ``)
|
||||
|
||||
// thinkTagOpen and thinkTagClose are the opening and closing think tag strings.
|
||||
const (
|
||||
thinkTagOpen = "<think>"
|
||||
thinkTagClose = "</think>"
|
||||
)
|
||||
|
||||
// knightRiderFrames generates a KITT-style scanning animation where a bright
|
||||
// light bounces back and forth across a row of dots with a trailing glow.
|
||||
// Colors are derived from the active theme. Used by StreamComponent (TUI
|
||||
@@ -202,6 +214,10 @@ type StreamComponent struct {
|
||||
// reasoningDuration holds the total reasoning time, frozen when streaming text begins.
|
||||
reasoningDuration time.Duration
|
||||
|
||||
// inThinkTag tracks whether we're currently inside a section
|
||||
// from models that wrap reasoning in XML-like tags (Qwen, DeepSeek).
|
||||
inThinkTag bool
|
||||
|
||||
// renderer renders streaming assistant text in either compact or standard mode.
|
||||
renderer Renderer
|
||||
|
||||
@@ -314,7 +330,9 @@ func (s *StreamComponent) GetRenderedContent() string {
|
||||
// Called before reading content for scrollback output or on flush tick.
|
||||
func (s *StreamComponent) commitPending() {
|
||||
if s.pendingStream.Len() > 0 {
|
||||
s.streamContent.WriteString(s.pendingStream.String())
|
||||
// Strip ... tags that some models wrap reasoning in
|
||||
cleanedText := thinkTagRegex.ReplaceAllString(s.pendingStream.String(), "")
|
||||
s.streamContent.WriteString(cleanedText)
|
||||
s.pendingStream.Reset()
|
||||
s.renderDirty = true
|
||||
}
|
||||
@@ -408,8 +426,46 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if s.reasoningDuration == 0 && !s.reasoningStartTime.IsZero() {
|
||||
s.reasoningDuration = time.Since(s.reasoningStartTime)
|
||||
}
|
||||
s.pendingStream.WriteString(msg.Content)
|
||||
if !s.flushPending {
|
||||
|
||||
// Handle models that wrap reasoning in tags (Qwen, DeepSeek)
|
||||
// Filter out all content between and tags
|
||||
content := msg.Content
|
||||
|
||||
// Check for opening tag
|
||||
if strings.Contains(content, thinkTagOpen) {
|
||||
parts := strings.SplitN(content, thinkTagOpen, 2)
|
||||
// Content before the tag can be written
|
||||
if !s.inThinkTag && parts[0] != "" {
|
||||
s.pendingStream.WriteString(parts[0])
|
||||
}
|
||||
s.inThinkTag = true
|
||||
// Content after the opening tag is reasoning - don't write it
|
||||
if len(parts) > 1 && parts[1] != "" {
|
||||
// Check if the same chunk contains the closing tag
|
||||
if strings.Contains(parts[1], thinkTagClose) {
|
||||
innerParts := strings.SplitN(parts[1], thinkTagClose, 2)
|
||||
s.inThinkTag = false
|
||||
// Content after closing tag can be written
|
||||
if len(innerParts) > 1 && innerParts[1] != "" {
|
||||
s.pendingStream.WriteString(innerParts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(content, thinkTagClose) {
|
||||
// Closing tag found
|
||||
parts := strings.SplitN(content, thinkTagClose, 2)
|
||||
s.inThinkTag = false
|
||||
// Content after closing tag can be written
|
||||
if len(parts) > 1 && parts[1] != "" {
|
||||
s.pendingStream.WriteString(parts[1])
|
||||
}
|
||||
} else if !s.inThinkTag {
|
||||
// Normal content, not inside think tags
|
||||
s.pendingStream.WriteString(content)
|
||||
}
|
||||
// else: inside think tag, don't write this content
|
||||
|
||||
if !s.flushPending && s.pendingStream.Len() > 0 {
|
||||
s.flushPending = true
|
||||
return s, streamFlushTickCmd(s.flushGeneration)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user