fix(openai_compat): strip leaked thinking/final tags

This commit is contained in:
horsley 2026-03-08 09:58:20 +00:00
parent 81dfdf5f45
commit 545f6a6868
2 changed files with 112 additions and 1 deletions

View file

@ -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. <final>answer</final> -> 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,

View file

@ -152,6 +152,63 @@ func TestProviderChat_ParsesReasoningContent(t *testing.T) {
}
}
func TestParseResponse_StripsThinkingAndFinalTags(t *testing.T) {
body := strings.NewReader(`{
"choices": [{
"message": {
"content": "<think>internal reasoning</think><final>The answer is 2</final>"
},
"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": "Visible<think>hidden 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