diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 0e8db74097..ace4e71af0 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,18 @@ type Option func(*Provider) const defaultRequestTimeout = 120 * time.Second +var ( + escapedTagReplacer = strings.NewReplacer( + `\u003c`, "<", + `\u003e`, ">", + `\u003C`, "<", + `\u003E`, ">", + ) + reasoningTagPattern = regexp.MustCompile(`(?is)<(?:think|thinking|thought|reasoning)\b[^>]*>.*?`) + trailingReasoningTagPattern = regexp.MustCompile(`(?is)<(?:think|thinking|thought|reasoning)\b[^>]*>.*$`) + finalTagPattern = regexp.MustCompile(`(?is)]*>`) +) + func WithMaxTokensField(maxTokensField string) Option { return func(p *Provider) { p.maxTokensField = maxTokensField @@ -351,7 +364,7 @@ func parseResponse(body io.Reader) (*LLMResponse, error) { } return &LLMResponse{ - Content: choice.Message.Content, + Content: sanitizeAssistantContent(choice.Message.Content), ReasoningContent: choice.Message.ReasoningContent, Reasoning: choice.Message.Reasoning, ReasoningDetails: choice.Message.ReasoningDetails, @@ -361,6 +374,19 @@ func parseResponse(body io.Reader) (*LLMResponse, error) { }, nil } +func sanitizeAssistantContent(content string) string { + if content == "" { + return "" + } + + sanitized := escapedTagReplacer.Replace(content) + sanitized = reasoningTagPattern.ReplaceAllString(sanitized, "") + sanitized = trailingReasoningTagPattern.ReplaceAllString(sanitized, "") + sanitized = finalTagPattern.ReplaceAllString(sanitized, "") + + return strings.TrimSpace(sanitized) +} + // openaiMessage is the wire-format message for OpenAI-compatible APIs. // It mirrors protocoltypes.Message but omits SystemParts, which is an // internal field that would be unknown to third-party endpoints. diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 9a3a7acc5c..005330f45d 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_StripsEscapedThinkingAndFinalTags(t *testing.T) { + body := strings.NewReader(`{ + "choices": [{ + "message": { + "content": "\\u003cthink\\u003einternal reasoning\\u003c/think\\u003e\\u003cfinal\\u003eThe answer is 2\\u003c/final\\u003e" + }, + "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_DropsTrailingUnclosedThinkingBlock(t *testing.T) { + body := strings.NewReader(`{ + "choices": [{ + "message": { + "content": "The answer is 2internal reasoning" + }, + "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 TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { var requestBody map[string]any