From f0caa04f60de7380ab8284ce62cde4e24163e4e0 Mon Sep 17 00:00:00 2001 From: hobostay Date: Thu, 12 Mar 2026 16:57:23 +0800 Subject: [PATCH] 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: - ... - ... - ... - ... - ... 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 tag wrappers while preserving the final content 4. Cleans up leftover whitespace-only lines Fixes #1235 Co-Authored-By: Claude Opus 4.6 --- pkg/providers/openai_compat/provider.go | 56 ++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index f97bf3acd..f887202f3 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -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: , , , , +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: content, , + patterns := []string{ + `(?i)]*>.*?`, // ... + `(?i)]*>.*?`, // ... + `(?i)]*>.*?`, // ... + `(?i)]*>.*?`, // ... + `(?i)*>(?:/>|>.*?)`, // or ... (remove tag, keep content) + } + + for _, pattern := range patterns { + re := regexp.MustCompile(pattern) + if strings.HasPrefix(pattern, `(?i) 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") +}