diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 5c868626a0..55bc414163 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" @@ -40,6 +41,13 @@ type Option func(*Provider) const defaultRequestTimeout = 120 * time.Second +var ( + // Providers sometimes leak chain-of-thought in these tags in `content`. + quickReasoningTagRE = regexp.MustCompile(`(?i)<\s*/?\s*(?:think(?:ing)?|thought|reasoning|final)\b`) + finalTagRE = regexp.MustCompile(`(?i)<\s*/?\s*final\b[^>]*>`) + thinkingTagRE = regexp.MustCompile(`(?i)<\s*(/?)\s*(?:think(?:ing)?|thought|reasoning)\b[^>]*>`) +) + func WithMaxTokensField(maxTokensField string) Option { return func(p *Provider) { p.maxTokensField = maxTokensField @@ -271,6 +279,52 @@ func responsePreview(body []byte, maxLen int) string { return string(trimmed[:maxLen]) + "..." } +// stripThinkingAndFinalTags removes leaked reasoning blocks while preserving +// user-facing answer text (e.g. answer -> answer). +func stripThinkingAndFinalTags(content string) string { + if content == "" { + return content + } + // Some APIs double-escape angle brackets in JSON payloads. + content = strings.ReplaceAll(content, `\u003c`, "<") + content = strings.ReplaceAll(content, `\u003e`, ">") + content = strings.ReplaceAll(content, `\u003C`, "<") + content = strings.ReplaceAll(content, `\u003E`, ">") + + if !quickReasoningTagRE.MatchString(content) { + return strings.TrimSpace(content) + } + + cleaned := finalTagRE.ReplaceAllString(content, "") + indexes := thinkingTagRE.FindAllStringSubmatchIndex(cleaned, -1) + if len(indexes) == 0 { + return strings.TrimSpace(cleaned) + } + + var b strings.Builder + lastIndex := 0 + inThinking := false + for _, idx := range indexes { + matchStart, matchEnd := idx[0], idx[1] + isClose := idx[2] >= 0 && cleaned[idx[2]:idx[3]] == "/" + + if !inThinking { + b.WriteString(cleaned[lastIndex:matchStart]) + if !isClose { + inThinking = true + } + } else if isClose { + inThinking = false + } + lastIndex = matchEnd + } + + if !inThinking { + b.WriteString(cleaned[lastIndex:]) + } + return strings.TrimSpace(b.String()) +} + func parseResponse(body io.Reader) (*LLMResponse, error) { var apiResponse struct { Choices []struct { @@ -351,7 +405,7 @@ func parseResponse(body io.Reader) (*LLMResponse, error) { } return &LLMResponse{ - Content: choice.Message.Content, + Content: stripThinkingAndFinalTags(choice.Message.Content), ReasoningContent: choice.Message.ReasoningContent, Reasoning: choice.Message.Reasoning, ReasoningDetails: choice.Message.ReasoningDetails, diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 9a3a7acc5c..53f21e9042 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -152,6 +152,63 @@ func TestProviderChat_ParsesReasoningContent(t *testing.T) { } } +func TestParseResponse_StripsThinkingAndFinalTags(t *testing.T) { + body := strings.NewReader(`{ + "choices": [{ + "message": { + "content": "internal reasoningThe answer is 2" + }, + "finish_reason": "stop" + }] + }`) + + out, err := parseResponse(body) + if err != nil { + t.Fatalf("parseResponse() error = %v", err) + } + if out.Content != "The answer is 2" { + t.Fatalf("Content = %q, want %q", out.Content, "The answer is 2") + } +} + +func TestParseResponse_StripsEscapedThinkingTags(t *testing.T) { + body := strings.NewReader(`{ + "choices": [{ + "message": { + "content": "\\u003cthink\\u003einternal\\u003c/think\\u003e\\u003cfinal\\u003eOK\\u003c/final\\u003e" + }, + "finish_reason": "stop" + }] + }`) + + out, err := parseResponse(body) + if err != nil { + t.Fatalf("parseResponse() error = %v", err) + } + if out.Content != "OK" { + t.Fatalf("Content = %q, want %q", out.Content, "OK") + } +} + +func TestParseResponse_DropsUnclosedThinkingBlock(t *testing.T) { + body := strings.NewReader(`{ + "choices": [{ + "message": { + "content": "Visiblehidden reasoning" + }, + "finish_reason": "stop" + }] + }`) + + out, err := parseResponse(body) + if err != nil { + t.Fatalf("parseResponse() error = %v", err) + } + if out.Content != "Visible" { + t.Fatalf("Content = %q, want %q", out.Content, "Visible") + } +} + func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { var requestBody map[string]any