fix: Message.ReasoningContent/Reasoning 改为 *string,修复空思考内容在请求转发时被静默丢弃的问题
问题: 在非 passThrough 模式下,客户端发送的 reasoning_content: "" 经过 Go struct 反序列化再序列化后,因 string + omitempty 无法区分空串和 字段缺失,导致空的思考内容被静默丢弃。 根因: dto.Message.ReasoningContent 和 Message.Reasoning 使用 string(非指针) 加 omitempty,违反 AGENTS.md Rule 6(可选标量字段必须用指针类型)。 修复: 1. Message.ReasoningContent/Reasoning 类型从 string 改为 *string - nil = 字段缺失 → JSON 省略 - &"" = 显式空串 → JSON 保留 reasoning_content: "" 2. 新增 Message.GetReasoningContent() 辅助方法 3. 更新所有读写处:relay-openai, relay-claude, relay-gemini, ollama 4. 新增测试覆盖空串保留、字段省略、getter 回退逻辑
This commit is contained in:
@@ -0,0 +1,104 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMessageReasoningContentPreservesEmptyString verifies that an explicitly
|
||||||
|
// set empty reasoning_content string survives the JSON round-trip.
|
||||||
|
//
|
||||||
|
// This is critical for the request-forwarding path (non-passThrough mode):
|
||||||
|
// the gateway unmarshals the client request into GeneralOpenAIRequest, then
|
||||||
|
// re-marshals it before sending upstream. If Message.ReasoningContent were
|
||||||
|
// `string` + `omitempty` (the old type), the empty string would be silently
|
||||||
|
// dropped, causing the upstream to never receive the field.
|
||||||
|
//
|
||||||
|
// With the fix (`*string` + `omitempty`), nil = absent, &"" = explicit empty.
|
||||||
|
func TestMessageReasoningContentPreservesEmptyString(t *testing.T) {
|
||||||
|
raw := []byte(`{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hello",
|
||||||
|
"reasoning_content": "",
|
||||||
|
"reasoning": ""
|
||||||
|
}`)
|
||||||
|
|
||||||
|
var msg Message
|
||||||
|
err := common.Unmarshal(raw, &msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Pointers must be non-nil: the field was explicitly set to ""
|
||||||
|
require.NotNil(t, msg.ReasoningContent, "reasoning_content should be non-nil when explicitly set to empty string")
|
||||||
|
require.NotNil(t, msg.Reasoning, "reasoning should be non-nil when explicitly set to empty string")
|
||||||
|
require.Equal(t, "", *msg.ReasoningContent)
|
||||||
|
require.Equal(t, "", *msg.Reasoning)
|
||||||
|
|
||||||
|
// Re-marshal — the fields must still be present in the output JSON
|
||||||
|
encoded, err := common.Marshal(msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.True(t, gjson.GetBytes(encoded, "reasoning_content").Exists(),
|
||||||
|
"reasoning_content should exist in re-marshaled JSON when explicitly set to empty string")
|
||||||
|
require.True(t, gjson.GetBytes(encoded, "reasoning").Exists(),
|
||||||
|
"reasoning should exist in re-marshaled JSON when explicitly set to empty string")
|
||||||
|
require.Equal(t, "", gjson.GetBytes(encoded, "reasoning_content").String())
|
||||||
|
require.Equal(t, "", gjson.GetBytes(encoded, "reasoning").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMessageReasoningContentOmitsAbsentField verifies that when
|
||||||
|
// reasoning_content / reasoning are absent from the input JSON, they remain
|
||||||
|
// absent after a round-trip (nil pointer → omitted by omitempty).
|
||||||
|
func TestMessageReasoningContentOmitsAbsentField(t *testing.T) {
|
||||||
|
raw := []byte(`{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hello"
|
||||||
|
}`)
|
||||||
|
|
||||||
|
var msg Message
|
||||||
|
err := common.Unmarshal(raw, &msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Pointers must be nil: the fields were not present in the input
|
||||||
|
require.Nil(t, msg.ReasoningContent)
|
||||||
|
require.Nil(t, msg.Reasoning)
|
||||||
|
|
||||||
|
// Re-marshal — the fields must NOT appear in the output JSON
|
||||||
|
encoded, err := common.Marshal(msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.False(t, gjson.GetBytes(encoded, "reasoning_content").Exists(),
|
||||||
|
"reasoning_content should not exist in re-marshaled JSON when absent from input")
|
||||||
|
require.False(t, gjson.GetBytes(encoded, "reasoning").Exists(),
|
||||||
|
"reasoning should not exist in re-marshaled JSON when absent from input")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMessageGetReasoningContent verifies the GetReasoningContent helper
|
||||||
|
// method that is used in token-counting code paths.
|
||||||
|
func TestMessageGetReasoningContent(t *testing.T) {
|
||||||
|
t.Run("both nil returns empty", func(t *testing.T) {
|
||||||
|
msg := Message{Role: "assistant"}
|
||||||
|
require.Equal(t, "", msg.GetReasoningContent())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ReasoningContent takes priority", func(t *testing.T) {
|
||||||
|
rc := "thinking..."
|
||||||
|
r := "should be ignored"
|
||||||
|
msg := Message{ReasoningContent: &rc, Reasoning: &r}
|
||||||
|
require.Equal(t, "thinking...", msg.GetReasoningContent())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("falls back to Reasoning when ReasoningContent is nil", func(t *testing.T) {
|
||||||
|
r := "fallback reasoning"
|
||||||
|
msg := Message{Reasoning: &r}
|
||||||
|
require.Equal(t, "fallback reasoning", msg.GetReasoningContent())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty string values returned correctly", func(t *testing.T) {
|
||||||
|
empty := ""
|
||||||
|
msg := Message{ReasoningContent: &empty}
|
||||||
|
require.Equal(t, "", msg.GetReasoningContent())
|
||||||
|
})
|
||||||
|
}
|
||||||
+12
-2
@@ -279,8 +279,8 @@ type Message struct {
|
|||||||
Content any `json:"content"`
|
Content any `json:"content"`
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
Prefix *bool `json:"prefix,omitempty"`
|
Prefix *bool `json:"prefix,omitempty"`
|
||||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
ReasoningContent *string `json:"reasoning_content,omitempty"`
|
||||||
Reasoning string `json:"reasoning,omitempty"`
|
Reasoning *string `json:"reasoning,omitempty"`
|
||||||
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
|
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
|
||||||
ToolCallId string `json:"tool_call_id,omitempty"`
|
ToolCallId string `json:"tool_call_id,omitempty"`
|
||||||
parsedContent []MediaContent
|
parsedContent []MediaContent
|
||||||
@@ -431,6 +431,16 @@ const (
|
|||||||
//ContentTypeAudioUrl = "audio_url"
|
//ContentTypeAudioUrl = "audio_url"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (m *Message) GetReasoningContent() string {
|
||||||
|
if m.ReasoningContent == nil && m.Reasoning == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if m.ReasoningContent != nil {
|
||||||
|
return *m.ReasoningContent
|
||||||
|
}
|
||||||
|
return *m.Reasoning
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Message) GetPrefix() bool {
|
func (m *Message) GetPrefix() bool {
|
||||||
if m.Prefix == nil {
|
if m.Prefix == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -567,12 +567,14 @@ func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextRe
|
|||||||
}
|
}
|
||||||
choice.SetStringContent(responseText)
|
choice.SetStringContent(responseText)
|
||||||
if len(responseThinking) > 0 {
|
if len(responseThinking) > 0 {
|
||||||
choice.ReasoningContent = responseThinking
|
choice.ReasoningContent = &responseThinking
|
||||||
}
|
}
|
||||||
if len(tools) > 0 {
|
if len(tools) > 0 {
|
||||||
choice.Message.SetToolCalls(tools)
|
choice.Message.SetToolCalls(tools)
|
||||||
}
|
}
|
||||||
choice.Message.ReasoningContent = thinkingContent
|
if thinkingContent != "" {
|
||||||
|
choice.Message.ReasoningContent = &thinkingContent
|
||||||
|
}
|
||||||
fullTextResponse.Model = claudeResponse.Model
|
fullTextResponse.Model = claudeResponse.Model
|
||||||
choices = append(choices, choice)
|
choices = append(choices, choice)
|
||||||
fullTextResponse.Choices = choices
|
fullTextResponse.Choices = choices
|
||||||
|
|||||||
@@ -1097,7 +1097,7 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse)
|
|||||||
toolCalls = append(toolCalls, *call)
|
toolCalls = append(toolCalls, *call)
|
||||||
}
|
}
|
||||||
} else if part.Thought {
|
} else if part.Thought {
|
||||||
choice.Message.ReasoningContent = part.Text
|
choice.Message.ReasoningContent = &part.Text
|
||||||
} else {
|
} else {
|
||||||
if part.ExecutableCode != nil {
|
if part.ExecutableCode != nil {
|
||||||
texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```")
|
texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```")
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
|
|||||||
|
|
||||||
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
|
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
|
||||||
if rc := reasoningBuilder.String(); rc != "" {
|
if rc := reasoningBuilder.String(); rc != "" {
|
||||||
msg.ReasoningContent = rc
|
msg.ReasoningContent = &rc
|
||||||
}
|
}
|
||||||
full := dto.OpenAITextResponse{
|
full := dto.OpenAITextResponse{
|
||||||
Id: common.GetUUID(),
|
Id: common.GetUUID(),
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
|||||||
completionTokens := simpleResponse.Usage.CompletionTokens
|
completionTokens := simpleResponse.Usage.CompletionTokens
|
||||||
if completionTokens == 0 {
|
if completionTokens == 0 {
|
||||||
for _, choice := range simpleResponse.Choices {
|
for _, choice := range simpleResponse.Choices {
|
||||||
ctkm := service.CountTextToken(choice.Message.StringContent()+choice.Message.ReasoningContent+choice.Message.Reasoning, info.UpstreamModelName)
|
ctkm := service.CountTextToken(choice.Message.StringContent()+choice.Message.GetReasoningContent(), info.UpstreamModelName)
|
||||||
completionTokens += ctkm
|
completionTokens += ctkm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user