diff --git a/dto/claude.go b/dto/claude.go index d7fed412aaa9..8f05cd8634c9 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -120,8 +120,9 @@ type ClaudeMessageSource struct { } type ClaudeMessage struct { - Role string `json:"role"` - Content any `json:"content"` + Role string `json:"role"` + Content any `json:"content"` + ReasoningContent json.RawMessage `json:"reasoning_content,omitempty"` } func (c *ClaudeMessage) IsStringContent() bool { diff --git a/dto/openai_request.go b/dto/openai_request.go index 25ef3a21aa51..b70e950efa45 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -279,7 +279,7 @@ type Message struct { Content any `json:"content"` Name *string `json:"name,omitempty"` Prefix *bool `json:"prefix,omitempty"` - ReasoningContent string `json:"reasoning_content,omitempty"` + ReasoningContent json.RawMessage `json:"reasoning_content,omitempty"` Reasoning string `json:"reasoning,omitempty"` ToolCalls json.RawMessage `json:"tool_calls,omitempty"` ToolCallId string `json:"tool_call_id,omitempty"` @@ -287,6 +287,15 @@ type Message struct { //parsedStringContent *string } +func (m *Message) SetReasoningContent(content string) { + encoded, _ := common.Marshal(content) + m.ReasoningContent = encoded +} + +func (m *Message) GetReasoningContent() string { + return common.JsonRawMessageToString(m.ReasoningContent) +} + type MediaContent struct { Type string `json:"type"` Text string `json:"text,omitempty"` diff --git a/dto/openai_request_zero_value_test.go b/dto/openai_request_zero_value_test.go index 4b0dbd7c25ee..6cec32b2017a 100644 --- a/dto/openai_request_zero_value_test.go +++ b/dto/openai_request_zero_value_test.go @@ -71,3 +71,17 @@ func TestOpenAIResponsesRequestPreserveExplicitZeroValues(t *testing.T) { require.True(t, gjson.GetBytes(encoded, "stream").Exists()) require.True(t, gjson.GetBytes(encoded, "top_p").Exists()) } + +func TestOpenAIMessagePreserveReasoningContentRaw(t *testing.T) { + raw := []byte(`{"role":"assistant","content":"","reasoning_content":" "}`) + + var message Message + err := common.Unmarshal(raw, &message) + require.NoError(t, err) + require.Equal(t, " ", message.GetReasoningContent()) + require.JSONEq(t, `" "`, string(message.ReasoningContent)) + + encoded, err := common.Marshal(message) + require.NoError(t, err) + require.Equal(t, " ", gjson.GetBytes(encoded, "reasoning_content").String()) +} diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index fa8234523c77..aa6ac7a454e5 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -264,6 +264,9 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe Role: message.Role, Content: message.Content, } + if len(message.ReasoningContent) > 0 { + fmtMessage.ReasoningContent = message.ReasoningContent + } if message.Role == "tool" { fmtMessage.ToolCallId = message.ToolCallId } @@ -331,6 +334,9 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe claudeMessage := dto.ClaudeMessage{ Role: message.Role, } + if len(message.ReasoningContent) > 0 { + claudeMessage.ReasoningContent = message.ReasoningContent + } if message.Role == "tool" { if len(claudeMessages) > 0 && claudeMessages[len(claudeMessages)-1].Role == "user" { lastMessage := claudeMessages[len(claudeMessages)-1] @@ -567,12 +573,14 @@ func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextRe } choice.SetStringContent(responseText) if len(responseThinking) > 0 { - choice.ReasoningContent = responseThinking + choice.Message.SetReasoningContent(responseThinking) } if len(tools) > 0 { choice.Message.SetToolCalls(tools) } - choice.Message.ReasoningContent = thinkingContent + if len(thinkingContent) > 0 { + choice.Message.SetReasoningContent(thinkingContent) + } fullTextResponse.Model = claudeResponse.Model choices = append(choices, choice) fullTextResponse.Choices = choices diff --git a/relay/channel/claude/relay_claude_test.go b/relay/channel/claude/relay_claude_test.go index fdc7b38e5ecc..feb3dacfaa16 100644 --- a/relay/channel/claude/relay_claude_test.go +++ b/relay/channel/claude/relay_claude_test.go @@ -380,3 +380,35 @@ func TestRequestOpenAI2ClaudeMessage_ConvertsTextFileContentToText(t *testing.T) require.NotNil(t, content[0].Text) require.Equal(t, "alpha\nbeta", *content[0].Text) } + +func TestRequestOpenAI2ClaudeMessagePreservesReasoningContent(t *testing.T) { + message := dto.Message{ + Role: "assistant", + Content: "", + } + message.SetReasoningContent(" ") + message.SetToolCalls([]dto.ToolCallRequest{ + { + ID: "call_123", + Type: "function", + Function: dto.FunctionRequest{ + Name: "lookup", + Arguments: `{"query":"moonshot"}`, + }, + }, + }) + request := dto.GeneralOpenAIRequest{ + Model: "kimi-k2-thinking", + Messages: []dto.Message{message}, + } + + claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request) + require.NoError(t, err) + require.Len(t, claudeRequest.Messages, 2) + require.JSONEq(t, `" "`, string(claudeRequest.Messages[1].ReasoningContent)) + + content, ok := claudeRequest.Messages[1].Content.([]dto.ClaudeMediaMessage) + require.True(t, ok) + require.NotEmpty(t, content) + require.Equal(t, "tool_use", content[len(content)-1].Type) +} diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 21641e483861..752a13117f79 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.SetReasoningContent(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 2a264b27e467..c105fe20a115 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.SetReasoningContent(rc) } full := dto.OpenAITextResponse{ Id: common.GetUUID(), diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index d33c5555f267..0b687418e7ae 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()+choice.Message.Reasoning, info.UpstreamModelName) completionTokens += ctkm } } diff --git a/service/convert.go b/service/convert.go index 95acf835ee46..06b5d93271c6 100644 --- a/service/convert.go +++ b/service/convert.go @@ -134,6 +134,9 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re openAIMessage := dto.Message{ Role: claudeMessage.Role, } + if len(claudeMessage.ReasoningContent) > 0 { + openAIMessage.ReasoningContent = claudeMessage.ReasoningContent + } //log.Printf("claudeMessage.Content: %v", claudeMessage.Content) if claudeMessage.IsStringContent() {