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