diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 5c868626a..a9712b59a 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" @@ -271,6 +272,9 @@ func responsePreview(body []byte, maxLen int) string { return string(trimmed[:maxLen]) + "..." } +// thinkTagRe matches blocks (including multi-line). +var thinkTagRe = regexp.MustCompile(`(?s)(.*?)`) + func parseResponse(body io.Reader) (*LLMResponse, error) { var apiResponse struct { Choices []struct { @@ -310,6 +314,26 @@ func parseResponse(body io.Reader) (*LLMResponse, error) { } choice := apiResponse.Choices[0] + + // Strip ... blocks from content. + // Some models (e.g. MiniMax M2.5, DeepSeek) embed chain-of-thought in the + // content field using 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, "") { + 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)) for _, tc := range choice.Message.ToolCalls { arguments := make(map[string]any) @@ -351,8 +375,8 @@ func parseResponse(body io.Reader) (*LLMResponse, error) { } return &LLMResponse{ - Content: choice.Message.Content, - ReasoningContent: choice.Message.ReasoningContent, + Content: msgContent, + ReasoningContent: msgReasoning, Reasoning: choice.Message.Reasoning, ReasoningDetails: choice.Message.ReasoningDetails, ToolCalls: toolCalls,