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[^>]*>.*?(?:think|thinking|thought|reasoning)\s*>`)
+ trailingReasoningTagPattern = regexp.MustCompile(`(?is)<(?:think|thinking|thought|reasoning)\b[^>]*>.*$`)
+ finalTagPattern = regexp.MustCompile(`(?is)?final\b[^>]*>`)
+)
+
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