fix: strip leaked thinking tags from openai compat content
This commit is contained in:
parent
4a8a2e9c23
commit
4e38fd50d7
2 changed files with 84 additions and 1 deletions
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -40,6 +41,18 @@ type Option func(*Provider)
|
||||||
|
|
||||||
const defaultRequestTimeout = 120 * time.Second
|
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 {
|
func WithMaxTokensField(maxTokensField string) Option {
|
||||||
return func(p *Provider) {
|
return func(p *Provider) {
|
||||||
p.maxTokensField = maxTokensField
|
p.maxTokensField = maxTokensField
|
||||||
|
|
@ -351,7 +364,7 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return &LLMResponse{
|
return &LLMResponse{
|
||||||
Content: choice.Message.Content,
|
Content: sanitizeAssistantContent(choice.Message.Content),
|
||||||
ReasoningContent: choice.Message.ReasoningContent,
|
ReasoningContent: choice.Message.ReasoningContent,
|
||||||
Reasoning: choice.Message.Reasoning,
|
Reasoning: choice.Message.Reasoning,
|
||||||
ReasoningDetails: choice.Message.ReasoningDetails,
|
ReasoningDetails: choice.Message.ReasoningDetails,
|
||||||
|
|
@ -361,6 +374,19 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
|
||||||
}, nil
|
}, 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.
|
// openaiMessage is the wire-format message for OpenAI-compatible APIs.
|
||||||
// It mirrors protocoltypes.Message but omits SystemParts, which is an
|
// It mirrors protocoltypes.Message but omits SystemParts, which is an
|
||||||
// internal field that would be unknown to third-party endpoints.
|
// internal field that would be unknown to third-party endpoints.
|
||||||
|
|
|
||||||
|
|
@ -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_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": "<final>The answer is 2</final><think>internal 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) {
|
func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) {
|
||||||
var requestBody map[string]any
|
var requestBody map[string]any
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue