fix: strip leaked Thinking/Final tags from OpenAI-compatible responses

Some OpenAI-compatible providers (e.g., MiniMax) may return chain-of-thought
content mixed into the response using tags like:
- </think>...</think>
- <thinking>...</thinking>
- <thought>...</thought>
- <reasoning>...</reasoning>
- <final>...</final>

These tags can leak internal reasoning text to end users. This commit adds
a sanitizer that:
1. Handles escaped Unicode forms (\u003c/\u003e)
2. Removes thinking blocks entirely (tags + content)
3. Removes <final> tag wrappers while preserving the final content
4. Cleans up leftover whitespace-only lines

Fixes #1235

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hobostay 2026-03-12 16:57:23 +08:00
parent 6460a0a7c7
commit f0caa04f60
No known key found for this signature in database

View file

@ -10,6 +10,7 @@ import (
"log"
"net/http"
"net/url"
"regexp"
"strings"
"time"
@ -346,8 +347,11 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
toolCalls = append(toolCalls, toolCall)
}
// Strip any leaked thinking/reasoning tags from content
cleanedContent := stripThinkingTags(choice.Message.Content)
return &LLMResponse{
Content: choice.Message.Content,
Content: cleanedContent,
ReasoningContent: choice.Message.ReasoningContent,
Reasoning: choice.Message.Reasoning,
ReasoningDetails: choice.Message.ReasoningDetails,
@ -518,3 +522,53 @@ func supportsPromptCacheKey(apiBase string) bool {
host := u.Hostname()
return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com")
}
// stripThinkingTags removes chain-of-thought tags from content that may be
// leaked by some OpenAI-compatible providers (e.g., MiniMax-style responses).
// Handles both normal tags and escaped Unicode forms.
// Tags removed: <think>, <thinking>, <thought>, <reasoning>, <final>
func stripThinkingTags(content string) string {
if content == "" {
return content
}
// First, handle escaped forms like \u003cthink\u003e...\u003c/think\u003e
// These appear as literal backslash-u-sequences in the string
content = strings.ReplaceAll(content, `\u003c`, "<")
content = strings.ReplaceAll(content, `\u003e`, ">")
content = strings.ReplaceAll(content, `\\u003c`, "<")
content = strings.ReplaceAll(content, `\\u003e`, ">")
// Pattern to match thinking/reasoning tags (with optional inner content)
// Matches: <tag>content</tag>, <tag />, <tag></tag>
patterns := []string{
`(?i)<think\b[^>]*>.*?</think>`, // <think>...</think>
`(?i)<thinking\b[^>]*>.*?</thinking>`, // <thinking>...</thinking>
`(?i)<thought\b[^>]*>.*?</thought>`, // <thought>...</thought>
`(?i)<reasoning\b[^>]*>.*?</reasoning>`, // <reasoning>...</reasoning>
`(?i)<final\b[^>*>(?:/>|>.*?</final>)`, // <final/> or <final>...</final> (remove tag, keep content)
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
if strings.HasPrefix(pattern, `(?i)<final`) {
// For <final> tags, remove the tags but keep the inner content
content = re.ReplaceAllString(content, "$1")
} else {
// For thinking tags, remove both tags and content
content = re.ReplaceAllString(content, "")
}
}
// Clean up any whitespace-only lines left after removing blocks
lines := strings.Split(content, "\n")
var cleaned []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
cleaned = append(cleaned, line)
}
}
return strings.Join(cleaned, "\n")
}