Parcourir la source

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 回退逻辑
heimoshuiyu il y a 1 mois
Parent
commit
8ca103342d

+ 104 - 0
dto/message_reasoning_test.go

@@ -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
dto/openai_request.go

@@ -279,8 +279,8 @@ type Message struct {
 	Content          any             `json:"content"`
 	Name             *string         `json:"name,omitempty"`
 	Prefix           *bool           `json:"prefix,omitempty"`
-	ReasoningContent string          `json:"reasoning_content,omitempty"`
-	Reasoning        string          `json:"reasoning,omitempty"`
+	ReasoningContent *string         `json:"reasoning_content,omitempty"`
+	Reasoning        *string         `json:"reasoning,omitempty"`
 	ToolCalls        json.RawMessage `json:"tool_calls,omitempty"`
 	ToolCallId       string          `json:"tool_call_id,omitempty"`
 	parsedContent    []MediaContent
@@ -431,6 +431,16 @@ const (
 	//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 {
 	if m.Prefix == nil {
 		return false

+ 4 - 2
relay/channel/claude/relay-claude.go

@@ -567,12 +567,14 @@ func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextRe
 	}
 	choice.SetStringContent(responseText)
 	if len(responseThinking) > 0 {
-		choice.ReasoningContent = responseThinking
+		choice.ReasoningContent = &responseThinking
 	}
 	if len(tools) > 0 {
 		choice.Message.SetToolCalls(tools)
 	}
-	choice.Message.ReasoningContent = thinkingContent
+	if thinkingContent != "" {
+		choice.Message.ReasoningContent = &thinkingContent
+	}
 	fullTextResponse.Model = claudeResponse.Model
 	choices = append(choices, choice)
 	fullTextResponse.Choices = choices

+ 1 - 1
relay/channel/gemini/relay-gemini.go

@@ -1097,7 +1097,7 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse)
 						toolCalls = append(toolCalls, *call)
 					}
 				} else if part.Thought {
-					choice.Message.ReasoningContent = part.Text
+					choice.Message.ReasoningContent = &part.Text
 				} else {
 					if part.ExecutableCode != nil {
 						texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```")

+ 1 - 1
relay/channel/ollama/stream.go

@@ -273,7 +273,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
 
 	msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
 	if rc := reasoningBuilder.String(); rc != "" {
-		msg.ReasoningContent = rc
+		msg.ReasoningContent = &rc
 	}
 	full := dto.OpenAITextResponse{
 		Id:      common.GetUUID(),

+ 1 - 1
relay/channel/openai/relay-openai.go

@@ -245,7 +245,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
 		completionTokens := simpleResponse.Usage.CompletionTokens
 		if completionTokens == 0 {
 			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
 			}
 		}