From 8ca103342d5ae7bd17e0e59b728e3d0bf903e963 Mon Sep 17 00:00:00 2001 From: heimoshuiyu Date: Wed, 29 Apr 2026 02:30:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Message.ReasoningContent/Reasoning=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20*string=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=A9=BA=E6=80=9D=E8=80=83=E5=86=85=E5=AE=B9=E5=9C=A8=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E8=BD=AC=E5=8F=91=E6=97=B6=E8=A2=AB=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E4=B8=A2=E5=BC=83=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: 在非 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 回退逻辑 --- dto/message_reasoning_test.go | 104 +++++++++++++++++++++++++++ dto/openai_request.go | 14 +++- relay/channel/claude/relay-claude.go | 6 +- relay/channel/gemini/relay-gemini.go | 2 +- relay/channel/ollama/stream.go | 2 +- relay/channel/openai/relay-openai.go | 2 +- 6 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 dto/message_reasoning_test.go diff --git a/dto/message_reasoning_test.go b/dto/message_reasoning_test.go new file mode 100644 index 00000000..630aa943 --- /dev/null +++ b/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()) + }) +} diff --git a/dto/openai_request.go b/dto/openai_request.go index 25ef3a21..8c104ddd 100644 --- a/dto/openai_request.go +++ b/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 diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index fa823452..e177e56d 100644 --- a/relay/channel/claude/relay-claude.go +++ b/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 diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 21641e48..355c75d7 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/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```") diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go index 2a264b27..43e024de 100644 --- a/relay/channel/ollama/stream.go +++ b/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(), diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index d33c5555..a8575184 100644 --- a/relay/channel/openai/relay-openai.go +++ b/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 } }