new-api/dto/message_reasoning_test.go
heimoshuiyu 8ca103342d 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 回退逻辑
2026-04-29 13:43:26 +08:00

105 lines
3.8 KiB
Go

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())
})
}