fix(openai_compat): strip leaked thinking/final tags
This commit is contained in:
parent
81dfdf5f45
commit
545f6a6868
2 changed files with 112 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue