fix(openai_compat): strip <think> tags from content before sending to user

Some models (e.g. MiniMax M2.5, DeepSeek) embed chain-of-thought in the
content field using <think> tags rather than a dedicated reasoning_content
field. Move these blocks into ReasoningContent so channels don't render
raw thinking output to users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Denys Vitali 2026-03-08 09:15:51 +00:00
parent 81dfdf5f45
commit 1239cd24cb

View file

@ -10,6 +10,7 @@ import (
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"strings" "strings"
"time" "time"
@ -271,6 +272,9 @@ func responsePreview(body []byte, maxLen int) string {
return string(trimmed[:maxLen]) + "..." return string(trimmed[:maxLen]) + "..."
} }
// thinkTagRe matches <think>…</think> blocks (including multi-line).
var thinkTagRe = regexp.MustCompile(`(?s)<think>(.*?)</think>`)
func parseResponse(body io.Reader) (*LLMResponse, error) { func parseResponse(body io.Reader) (*LLMResponse, error) {
var apiResponse struct { var apiResponse struct {
Choices []struct { Choices []struct {
@ -310,6 +314,26 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
} }
choice := apiResponse.Choices[0] choice := apiResponse.Choices[0]
// Strip <think>...</think> blocks from content.
// Some models (e.g. MiniMax M2.5, DeepSeek) embed chain-of-thought in the
// content field using <think> tags rather than a dedicated reasoning_content
// field. We move them into ReasoningContent so channels don't render them.
msgContent := choice.Message.Content
msgReasoning := choice.Message.ReasoningContent
if msgReasoning == "" && strings.Contains(msgContent, "<think>") {
var blocks []string
msgContent = thinkTagRe.ReplaceAllStringFunc(msgContent, func(m string) string {
sub := thinkTagRe.FindStringSubmatch(m)
if len(sub) > 1 {
blocks = append(blocks, strings.TrimSpace(sub[1]))
}
return ""
})
msgContent = strings.TrimSpace(msgContent)
msgReasoning = strings.Join(blocks, "\n\n")
}
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls)) toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
for _, tc := range choice.Message.ToolCalls { for _, tc := range choice.Message.ToolCalls {
arguments := make(map[string]any) arguments := make(map[string]any)
@ -351,8 +375,8 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
} }
return &LLMResponse{ return &LLMResponse{
Content: choice.Message.Content, Content: msgContent,
ReasoningContent: choice.Message.ReasoningContent, ReasoningContent: msgReasoning,
Reasoning: choice.Message.Reasoning, Reasoning: choice.Message.Reasoning,
ReasoningDetails: choice.Message.ReasoningDetails, ReasoningDetails: choice.Message.ReasoningDetails,
ToolCalls: toolCalls, ToolCalls: toolCalls,