diff --git a/relaykit/dto/claude.go b/relaykit/dto/claude.go index a3a41e75019b..6ac162556f20 100644 --- a/relaykit/dto/claude.go +++ b/relaykit/dto/claude.go @@ -15,18 +15,20 @@ type ClaudeMetadata struct { } type ClaudeMediaMessage struct { - Type string `json:"type,omitempty"` - Text *string `json:"text,omitempty"` - Model string `json:"model,omitempty"` - Source *ClaudeMessageSource `json:"source,omitempty"` - Usage *ClaudeUsage `json:"usage,omitempty"` - StopReason *string `json:"stop_reason,omitempty"` - PartialJson *string `json:"partial_json,omitempty"` - Role string `json:"role,omitempty"` - Thinking *string `json:"thinking,omitempty"` - Signature string `json:"signature,omitempty"` - Delta string `json:"delta,omitempty"` - CacheControl json.RawMessage `json:"cache_control,omitempty"` + Type string `json:"type,omitempty"` + Text *string `json:"text,omitempty"` + Model string `json:"model,omitempty"` + Source *ClaudeMessageSource `json:"source,omitempty"` + Usage *ClaudeUsage `json:"usage,omitempty"` + StopReason *string `json:"stop_reason,omitempty"` + PartialJson *string `json:"partial_json,omitempty"` + Role string `json:"role,omitempty"` + Thinking *string `json:"thinking,omitempty"` + Signature string `json:"signature,omitempty"` + // Data carries the opaque payload of a redacted_thinking block. + Data string `json:"data,omitempty"` + Delta string `json:"delta,omitempty"` + CacheControl json.RawMessage `json:"cache_control,omitempty"` // tool_calls Id string `json:"id,omitempty"` Name string `json:"name,omitempty"` diff --git a/relaykit/relayconvert/internal/claude_messages/to_oai_chat_req.go b/relaykit/relayconvert/internal/claude_messages/to_oai_chat_req.go index 56e6663e1a03..408aabb97b37 100644 --- a/relaykit/relayconvert/internal/claude_messages/to_oai_chat_req.go +++ b/relaykit/relayconvert/internal/claude_messages/to_oai_chat_req.go @@ -146,6 +146,7 @@ func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info con } var toolCalls []dto.ToolCallRequest mediaMessages := make([]dto.MediaContent, 0, len(content)) + var reasoning strings.Builder for _, mediaMsg := range content { switch mediaMsg.Type { @@ -156,6 +157,19 @@ func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info con CacheControl: mediaMsg.CacheControl, } mediaMessages = append(mediaMessages, message) + case "thinking": + // Replayed thinking blocks keep multi-turn tool-call context for + // reasoning models. The signature cannot cross the chat format, so + // only the text is preserved. Thinking blocks are only valid on + // assistant turns; ignore them on any other role. + if mediaMsg.Thinking != nil && claudeMessage.Role == "assistant" { + reasoning.WriteString(*mediaMsg.Thinking) + } + case "redacted_thinking": + // redacted_thinking carries an opaque encrypted payload, not + // readable reasoning text; it cannot round-trip through the chat + // format without mutating into a plain (and invalid) thinking + // block, so it is deliberately not preserved. case "image": imageData := fmt.Sprintf("data:%s;base64,%s", mediaMsg.Source.MediaType, mediaMsg.Source.Data) mediaMessage := dto.MediaContent{ @@ -197,11 +211,18 @@ func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info con if len(toolCalls) > 0 { openAIMessage.SetToolCalls(toolCalls) } - if len(mediaMessages) > 0 && len(toolCalls) == 0 { + // Keep the assistant turn's text/media content even when tool calls are + // present: Claude turns commonly mix text ("我来查一下") with tool_use, + // and dropping the text loses the model's stated intent downstream. + if len(mediaMessages) > 0 { openAIMessage.SetMediaContent(mediaMessages) } + if reasoning.Len() > 0 { + reasoningContent := reasoning.String() + openAIMessage.ReasoningContent = &reasoningContent + } } - if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 { + if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 || openAIMessage.GetReasoningContent() != "" { openAIMessages = append(openAIMessages, openAIMessage) } } diff --git a/relaykit/relayconvert/internal/claude_messages/to_oai_chat_req_test.go b/relaykit/relayconvert/internal/claude_messages/to_oai_chat_req_test.go new file mode 100644 index 000000000000..ecda85f17010 --- /dev/null +++ b/relaykit/relayconvert/internal/claude_messages/to_oai_chat_req_test.go @@ -0,0 +1,180 @@ +package claudemessages + +import ( + "testing" + + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClaudeMessagesRequestToOpenAIChatKeepsTextAlongsideToolCalls(t *testing.T) { + // Regression (NB-2): an assistant turn mixing text with tool_use must keep + // its text; previously the media content was dropped whenever tool calls + // were present. + req := dto.ClaudeRequest{ + Model: "deepseek-r1", + Messages: []dto.ClaudeMessage{ + {Role: "user", Content: "北京天气"}, + {Role: "assistant", Content: []any{ + map[string]any{"type": "text", "text": "我来查一下"}, + map[string]any{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": map[string]any{}}, + }}, + }, + } + + got, err := ClaudeMessagesRequestToOpenAIChat(req, nil) + require.NoError(t, err) + + require.Len(t, got.Messages, 2) + assistant := got.Messages[1] + parts := assistant.ParseContent() + require.Len(t, parts, 1) + assert.Equal(t, dto.ContentTypeText, parts[0].Type) + assert.Equal(t, "我来查一下", parts[0].Text) + toolCalls := assistant.ParseToolCalls() + require.Len(t, toolCalls, 1) + assert.Equal(t, "call_1", toolCalls[0].ID) +} + +func TestClaudeMessagesRequestToOpenAIChatToolCallOnlyTurnKeepsNullContent(t *testing.T) { + // A pure tool_use assistant turn (no text) keeps the previous shape: + // null content with tool_calls. + req := dto.ClaudeRequest{ + Model: "deepseek-r1", + Messages: []dto.ClaudeMessage{ + {Role: "assistant", Content: []any{ + map[string]any{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": map[string]any{}}, + }}, + }, + } + + got, err := ClaudeMessagesRequestToOpenAIChat(req, nil) + require.NoError(t, err) + + require.Len(t, got.Messages, 1) + assert.Nil(t, got.Messages[0].Content) + assert.Len(t, got.Messages[0].ParseToolCalls(), 1) +} + +func TestClaudeMessagesRequestToOpenAIChatKeepsThinkingTextAndToolCallTogether(t *testing.T) { + // The full Claude assistant turn shape (thinking + text + tool_use) must + // survive with all three parts mapped. + req := dto.ClaudeRequest{ + Model: "deepseek-r1", + Messages: []dto.ClaudeMessage{ + {Role: "assistant", Content: []any{ + map[string]any{"type": "thinking", "thinking": "先定位城市"}, + map[string]any{"type": "text", "text": "我来查一下"}, + map[string]any{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": map[string]any{}}, + }}, + }, + } + + got, err := ClaudeMessagesRequestToOpenAIChat(req, nil) + require.NoError(t, err) + + require.Len(t, got.Messages, 1) + assistant := got.Messages[0] + assert.Equal(t, "先定位城市", assistant.GetReasoningContent()) + parts := assistant.ParseContent() + require.Len(t, parts, 1) + assert.Equal(t, "我来查一下", parts[0].Text) + assert.Len(t, assistant.ParseToolCalls(), 1) +} + +func TestClaudeMessagesRequestToOpenAIChatPreservesThinkingBlocks(t *testing.T) { + req := dto.ClaudeRequest{ + Model: "deepseek-r1", + Messages: []dto.ClaudeMessage{ + {Role: "user", Content: "查天气"}, + {Role: "assistant", Content: []any{ + map[string]any{"type": "thinking", "thinking": "需要先定位城市,", "signature": "sig1"}, + map[string]any{"type": "thinking", "thinking": "再调用天气接口", "signature": "sig2"}, + map[string]any{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": map[string]any{}}, + }}, + {Role: "user", Content: []any{ + map[string]any{"type": "tool_result", "tool_use_id": "call_1", "content": "晴"}, + }}, + }, + } + + got, err := ClaudeMessagesRequestToOpenAIChat(req, nil) + require.NoError(t, err) + + require.Len(t, got.Messages, 3) + assistant := got.Messages[1] + assert.Equal(t, "assistant", assistant.Role) + assert.Equal(t, "需要先定位城市,再调用天气接口", assistant.GetReasoningContent()) + toolCalls := assistant.ParseToolCalls() + require.Len(t, toolCalls, 1) + assert.Equal(t, "call_1", toolCalls[0].ID) + assert.Equal(t, "get_weather", toolCalls[0].Function.Name) + assert.Equal(t, "tool", got.Messages[2].Role) + assert.Equal(t, "call_1", got.Messages[2].ToolCallId) +} + +func TestClaudeMessagesRequestToOpenAIChatDropsRedactedThinking(t *testing.T) { + // redacted_thinking carries an opaque encrypted payload that cannot + // round-trip through the chat format without mutating into an invalid plain + // thinking block, so it is deliberately not preserved. + req := dto.ClaudeRequest{ + Model: "deepseek-r1", + Messages: []dto.ClaudeMessage{ + {Role: "assistant", Content: []any{ + map[string]any{"type": "redacted_thinking", "data": "opaque-blob"}, + map[string]any{"type": "tool_use", "id": "call_1", "name": "lookup", "input": map[string]any{}}, + }}, + }, + } + + got, err := ClaudeMessagesRequestToOpenAIChat(req, nil) + require.NoError(t, err) + + require.Len(t, got.Messages, 1) + assert.Empty(t, got.Messages[0].GetReasoningContent()) + assert.Len(t, got.Messages[0].ParseToolCalls(), 1) +} + +func TestClaudeMessagesRequestToOpenAIChatKeepsThinkingOnlyAssistantMessage(t *testing.T) { + // An assistant message carrying only thinking (no text, no tool_use) must not + // be dropped from the converted history. + req := dto.ClaudeRequest{ + Model: "deepseek-r1", + Messages: []dto.ClaudeMessage{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: []any{ + map[string]any{"type": "thinking", "thinking": "lone thought"}, + }}, + {Role: "user", Content: "continue"}, + }, + } + + got, err := ClaudeMessagesRequestToOpenAIChat(req, nil) + require.NoError(t, err) + + require.Len(t, got.Messages, 3) + assert.Equal(t, "assistant", got.Messages[1].Role) + assert.Equal(t, "lone thought", got.Messages[1].GetReasoningContent()) +} + +func TestClaudeMessagesRequestToOpenAIChatIgnoresThinkingOnUserTurn(t *testing.T) { + // Thinking blocks are only valid on assistant turns; a thinking block in a + // user turn must not leak into reasoning_content. + req := dto.ClaudeRequest{ + Model: "deepseek-r1", + Messages: []dto.ClaudeMessage{ + {Role: "user", Content: []any{ + map[string]any{"type": "thinking", "thinking": "user-turn thought"}, + map[string]any{"type": "text", "text": "hi"}, + }}, + }, + } + + got, err := ClaudeMessagesRequestToOpenAIChat(req, nil) + require.NoError(t, err) + + require.Len(t, got.Messages, 1) + assert.Equal(t, "user", got.Messages[0].Role) + assert.Empty(t, got.Messages[0].GetReasoningContent()) +} diff --git a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go index 54cb11d55487..cc3b352801d9 100644 --- a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go +++ b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go @@ -225,7 +225,22 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te } } + // Anthropic signature-verifies thinking blocks in the LATEST assistant + // message during tool-use continuation; a synthesized (unsigned) block + // there is rejected with a 400, so reasoning is only replayed as thinking + // blocks on earlier assistant turns, which the API tolerates. + lastAssistantIdx := -1 + for i, message := range textRequest.Messages { + if message.Role == "assistant" { + lastAssistantIdx = i + } + } + formatMessages := make([]dto.Message, 0) + // formatSrcIdx tracks each formatMessages entry's source index in + // textRequest.Messages (they skew once consecutive same-role messages + // merge), so the latest-assistant gate below compares source positions. + formatSrcIdx := make([]int, 0) lastMessage := dto.Message{ Role: "tool", } @@ -237,6 +252,9 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te Role: message.Role, Content: message.Content, } + if reasoning := message.GetReasoningContent(); reasoning != "" { + fmtMessage.ReasoningContent = &reasoning + } if message.Role == "tool" { fmtMessage.ToolCallId = message.ToolCallId } @@ -246,13 +264,22 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te if lastMessage.Role == message.Role && lastMessage.Role != "tool" { if lastMessage.IsStringContent() && message.IsStringContent() { fmtMessage.SetStringContent(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\"")) + // Merging is required for Claude's alternating-roles constraint; + // merge the reasoning too so the earlier turn's is not lost. + if mergedReasoning := lastMessage.GetReasoningContent() + message.GetReasoningContent(); mergedReasoning != "" { + fmtMessage.ReasoningContent = &mergedReasoning + } else { + fmtMessage.ReasoningContent = nil + } formatMessages = formatMessages[:len(formatMessages)-1] + formatSrcIdx = formatSrcIdx[:len(formatSrcIdx)-1] } } if fmtMessage.Content == nil || (fmtMessage.IsStringContent() && fmtMessage.StringContent() == "") { fmtMessage.SetStringContent("...") } formatMessages = append(formatMessages, fmtMessage) + formatSrcIdx = append(formatSrcIdx, i) lastMessage = fmtMessage } @@ -260,7 +287,7 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te isFirstMessage := true var systemMessages []dto.ClaudeMediaMessage - for _, message := range formatMessages { + for i, message := range formatMessages { if message.Role == "system" { if message.IsStringContent() { if text := message.StringContent(); text != "" { @@ -329,7 +356,11 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te Content: message.Content, }, } - } else if message.IsStringContent() && message.ToolCalls == nil { + // Plain-string path for everything except reasoning-bearing earlier + // assistant turns; those take the media branch so their reasoning can be + // replayed as an unsigned thinking block (see below for why the latest + // assistant turn is excluded). + } else if message.IsStringContent() && message.ToolCalls == nil && (message.Role != "assistant" || message.GetReasoningContent() == "" || formatSrcIdx[i] == lastAssistantIdx) { text := message.StringContent() if text == "" { text = "..." @@ -337,10 +368,31 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te claudeMessage.Content = text } else { claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0) + thinkingEmitted := false + if message.Role == "assistant" && formatSrcIdx[i] != lastAssistantIdx { + // Replayed reasoning becomes a thinking block ahead of text/tool_use, + // mirroring the assistant turn shape Claude returns. The chat format + // cannot carry a signature, so the block is emitted unsigned — which + // is why it is never emitted on the latest assistant turn (the API + // signature-verifies thinking blocks there during tool-use + // continuation and would reject the request with a 400). + if reasoning := message.GetReasoningContent(); reasoning != "" { + claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{ + Type: "thinking", + Thinking: kitutil.GetPointer(reasoning), + }) + thinkingEmitted = true + } + } for _, mediaMessage := range message.ParseContent() { switch mediaMessage.Type { case "text": if mediaMessage.Text != "" { + // The "..." placeholder only fills otherwise-empty turns; it + // adds noise next to a real thinking block. + if thinkingEmitted && mediaMessage.Text == "..." { + continue + } claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{ Type: "text", Text: kitutil.GetPointer[string](mediaMessage.Text), diff --git a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req_test.go b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req_test.go new file mode 100644 index 000000000000..1eff81138cf9 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req_test.go @@ -0,0 +1,273 @@ +package oaichat + +import ( + "testing" + + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func claudeContentBlocks(t *testing.T, message dto.ClaudeMessage) []dto.ClaudeMediaMessage { + t.Helper() + blocks, ok := message.Content.([]dto.ClaudeMediaMessage) + require.Truef(t, ok, "expected media blocks, got %T", message.Content) + return blocks +} + +func assistantMsgWithToolCall(reasoning string) dto.Message { + msg := dto.Message{Role: "assistant"} + if reasoning != "" { + msg.ReasoningContent = lo.ToPtr(reasoning) + } + msg.SetToolCalls([]dto.ToolCallRequest{ + { + ID: "call_1", + Type: "function", + Function: dto.FunctionRequest{ + Name: "get_weather", + Arguments: `{"city":"beijing"}`, + }, + }, + }) + return msg +} + +func TestOpenAIChatRequestToClaudeMessagesPreservesAssistantReasoning(t *testing.T) { + reasoning := "需要先定位城市,再调用天气接口" + + t.Run("reasoning with tool calls preserved on earlier turn", func(t *testing.T) { + got, err := OpenAIChatRequestToClaudeMessages(nil, nil, dto.GeneralOpenAIRequest{ + Model: "claude-test", + MaxTokens: lo.ToPtr(uint(1024)), + Messages: []dto.Message{ + {Role: "user", Content: "查天气"}, + assistantMsgWithToolCall(reasoning), + {Role: "tool", ToolCallId: "call_1", Content: "晴"}, + {Role: "assistant", Content: "已查到结果"}, + }, + }) + require.NoError(t, err) + require.Len(t, got.Messages, 4) + + blocks := claudeContentBlocks(t, got.Messages[1]) + require.Len(t, blocks, 2) + assert.Equal(t, "thinking", blocks[0].Type) + require.NotNil(t, blocks[0].Thinking) + assert.Equal(t, reasoning, *blocks[0].Thinking) + assert.Empty(t, blocks[0].Signature) + assert.Equal(t, "tool_use", blocks[1].Type) + assert.Equal(t, "call_1", blocks[1].Id) + + toolResult := claudeContentBlocks(t, got.Messages[2]) + require.Len(t, toolResult, 1) + assert.Equal(t, "tool_result", toolResult[0].Type) + assert.Equal(t, "已查到结果", got.Messages[3].Content) + }) + + t.Run("reasoning withheld on latest assistant tool-call turn", func(t *testing.T) { + // The reasoning-bearing assistant message is the LATEST assistant turn: + // Anthropic signature-verifies thinking blocks there during tool-use + // continuation, so the converter keeps the pre-fix accepted shape + // (text placeholder + tool_use, no thinking block). + got, err := OpenAIChatRequestToClaudeMessages(nil, nil, dto.GeneralOpenAIRequest{ + Model: "claude-test", + MaxTokens: lo.ToPtr(uint(1024)), + Messages: []dto.Message{ + {Role: "user", Content: "查天气"}, + assistantMsgWithToolCall(reasoning), + {Role: "tool", ToolCallId: "call_1", Content: "晴"}, + }, + }) + require.NoError(t, err) + require.Len(t, got.Messages, 3) + + blocks := claudeContentBlocks(t, got.Messages[1]) + require.Len(t, blocks, 2) + assert.Equal(t, "text", blocks[0].Type) + assert.Equal(t, "...", blocks[0].GetText()) + assert.Equal(t, "tool_use", blocks[1].Type) + assert.Equal(t, "call_1", blocks[1].Id) + }) + + t.Run("reasoning with text content on earlier turn", func(t *testing.T) { + got, err := OpenAIChatRequestToClaudeMessages(nil, nil, dto.GeneralOpenAIRequest{ + Model: "claude-test", + MaxTokens: lo.ToPtr(uint(1024)), + Messages: []dto.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "已查到结果", ReasoningContent: lo.ToPtr(reasoning)}, + {Role: "user", Content: "thanks"}, + {Role: "assistant", Content: "不客气"}, + }, + }) + require.NoError(t, err) + require.Len(t, got.Messages, 4) + + blocks := claudeContentBlocks(t, got.Messages[1]) + require.Len(t, blocks, 2) + assert.Equal(t, "thinking", blocks[0].Type) + require.NotNil(t, blocks[0].Thinking) + assert.Equal(t, reasoning, *blocks[0].Thinking) + assert.Equal(t, "text", blocks[1].Type) + assert.Equal(t, "已查到结果", blocks[1].GetText()) + }) + + t.Run("reasoning with text content on latest turn falls back to plain text", func(t *testing.T) { + got, err := OpenAIChatRequestToClaudeMessages(nil, nil, dto.GeneralOpenAIRequest{ + Model: "claude-test", + MaxTokens: lo.ToPtr(uint(1024)), + Messages: []dto.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "已查到结果", ReasoningContent: lo.ToPtr(reasoning)}, + }, + }) + require.NoError(t, err) + require.Len(t, got.Messages, 2) + assert.Equal(t, "已查到结果", got.Messages[1].Content) + }) + + t.Run("reasoning only earlier turn skips placeholder text", func(t *testing.T) { + got, err := OpenAIChatRequestToClaudeMessages(nil, nil, dto.GeneralOpenAIRequest{ + Model: "claude-test", + MaxTokens: lo.ToPtr(uint(1024)), + Messages: []dto.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", ReasoningContent: lo.ToPtr(reasoning)}, + {Role: "user", Content: "continue"}, + {Role: "assistant", Content: "done"}, + }, + }) + require.NoError(t, err) + require.Len(t, got.Messages, 4) + + blocks := claudeContentBlocks(t, got.Messages[1]) + require.Len(t, blocks, 1) + assert.Equal(t, "thinking", blocks[0].Type) + require.NotNil(t, blocks[0].Thinking) + assert.Equal(t, reasoning, *blocks[0].Thinking) + }) + + t.Run("reasoning only latest turn keeps placeholder", func(t *testing.T) { + got, err := OpenAIChatRequestToClaudeMessages(nil, nil, dto.GeneralOpenAIRequest{ + Model: "claude-test", + MaxTokens: lo.ToPtr(uint(1024)), + Messages: []dto.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", ReasoningContent: lo.ToPtr(reasoning)}, + }, + }) + require.NoError(t, err) + require.Len(t, got.Messages, 2) + assert.Equal(t, "...", got.Messages[1].Content) + }) + + t.Run("reasoning only first turn keeps thinking after synthetic user injection", func(t *testing.T) { + // The conversation does not start with a user turn, so a synthetic user + // "..." message is injected first; the first assistant turn is not the + // latest one, so its reasoning is still emitted as a thinking block. + got, err := OpenAIChatRequestToClaudeMessages(nil, nil, dto.GeneralOpenAIRequest{ + Model: "claude-test", + MaxTokens: lo.ToPtr(uint(1024)), + Messages: []dto.Message{ + {Role: "assistant", ReasoningContent: lo.ToPtr(reasoning)}, + {Role: "user", Content: "go on"}, + {Role: "assistant", Content: "done"}, + }, + }) + require.NoError(t, err) + require.Len(t, got.Messages, 4) + assert.Equal(t, "user", got.Messages[0].Role) + + blocks := claudeContentBlocks(t, got.Messages[1]) + require.Len(t, blocks, 1) + assert.Equal(t, "thinking", blocks[0].Type) + require.NotNil(t, blocks[0].Thinking) + assert.Equal(t, reasoning, *blocks[0].Thinking) + }) + + t.Run("nil content reasoning assistant merges with following assistant", func(t *testing.T) { + // A reasoning-only assistant turn followed by another assistant turn must + // not produce consecutive same-role messages (Claude requires alternating + // roles); the turns merge and the reasoning is kept as a thinking block. + got, err := OpenAIChatRequestToClaudeMessages(nil, nil, dto.GeneralOpenAIRequest{ + Model: "claude-test", + MaxTokens: lo.ToPtr(uint(1024)), + Messages: []dto.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", ReasoningContent: lo.ToPtr(reasoning)}, + {Role: "assistant", Content: "hello"}, + {Role: "user", Content: "continue"}, + {Role: "assistant", Content: "done"}, + }, + }) + require.NoError(t, err) + require.Len(t, got.Messages, 4) + for i := 1; i < len(got.Messages); i++ { + assert.NotEqualf(t, got.Messages[i-1].Role, got.Messages[i].Role, "consecutive same-role messages at %d/%d", i-1, i) + } + + blocks := claudeContentBlocks(t, got.Messages[1]) + require.NotEmpty(t, blocks) + assert.Equal(t, "thinking", blocks[0].Type) + require.NotNil(t, blocks[0].Thinking) + assert.Equal(t, reasoning, *blocks[0].Thinking) + }) + + t.Run("consecutive assistant turns merge reasoning on earlier turn", func(t *testing.T) { + // Claude requires alternating roles, so consecutive assistant messages + // merge; the earlier turn's reasoning must be merged too, not dropped. + got, err := OpenAIChatRequestToClaudeMessages(nil, nil, dto.GeneralOpenAIRequest{ + Model: "claude-test", + MaxTokens: lo.ToPtr(uint(1024)), + Messages: []dto.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "t1", ReasoningContent: lo.ToPtr("R1")}, + {Role: "assistant", Content: "t2", ReasoningContent: lo.ToPtr("R2")}, + {Role: "user", Content: "go on"}, + {Role: "assistant", Content: "done"}, + }, + }) + require.NoError(t, err) + require.Len(t, got.Messages, 4) + + blocks := claudeContentBlocks(t, got.Messages[1]) + require.Len(t, blocks, 2) + assert.Equal(t, "thinking", blocks[0].Type) + require.NotNil(t, blocks[0].Thinking) + assert.Equal(t, "R1R2", *blocks[0].Thinking) + assert.Equal(t, "text", blocks[1].Type) + assert.Equal(t, "t1 t2", blocks[1].GetText()) + }) + + t.Run("merged latest assistant turn keeps placeholder shape", func(t *testing.T) { + // The merged message represents the latest assistant turn, so no + // unsigned thinking block is emitted even though reasoning was merged. + got, err := OpenAIChatRequestToClaudeMessages(nil, nil, dto.GeneralOpenAIRequest{ + Model: "claude-test", + MaxTokens: lo.ToPtr(uint(1024)), + Messages: []dto.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "t1", ReasoningContent: lo.ToPtr("R1")}, + {Role: "assistant", Content: "t2", ReasoningContent: lo.ToPtr("R2")}, + }, + }) + require.NoError(t, err) + require.Len(t, got.Messages, 2) + assert.Equal(t, "t1 t2", got.Messages[1].Content) + }) + + t.Run("user message reasoning is not emitted", func(t *testing.T) { + got, err := OpenAIChatRequestToClaudeMessages(nil, nil, dto.GeneralOpenAIRequest{ + Model: "claude-test", + MaxTokens: lo.ToPtr(uint(1024)), + Messages: []dto.Message{ + {Role: "user", Content: "hi", ReasoningContent: lo.ToPtr(reasoning)}, + }, + }) + require.NoError(t, err) + require.Len(t, got.Messages, 1) + assert.Equal(t, "hi", got.Messages[0].Content) + }) +} diff --git a/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req.go b/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req.go index 2ebb9348d4c7..e938a8a49bcc 100644 --- a/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req.go +++ b/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req.go @@ -283,6 +283,14 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto } shouldAttachThoughtSignature := (message.Role == "assistant" || message.Role == "model") && sharedgemini.ShouldAttachThoughtSignature(opts) signatureAttached := false + if (message.Role == "assistant" || message.Role == "model") && message.GetReasoningContent() != "" { + // Replayed reasoning becomes a thought part ahead of any function_call, + // mirroring the model-turn shape Gemini returns. + parts = append(parts, dto.GeminiPart{ + Text: message.GetReasoningContent(), + Thought: true, + }) + } if message.ToolCalls != nil { for _, call := range message.ParseToolCalls() { args := map[string]interface{}{} diff --git a/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req_test.go b/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req_test.go new file mode 100644 index 000000000000..d1bf3222f600 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req_test.go @@ -0,0 +1,83 @@ +package oaichat + +import ( + "testing" + + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenAIChatRequestToGeminiGenerateContentPreservesAssistantReasoning(t *testing.T) { + reasoning := "需要先定位城市,再调用天气接口" + + t.Run("reasoning precedes function call", func(t *testing.T) { + assistant := dto.Message{Role: "assistant", ReasoningContent: lo.ToPtr(reasoning)} + assistant.SetToolCalls([]dto.ToolCallRequest{ + { + ID: "call_1", + Type: "function", + Function: dto.FunctionRequest{ + Name: "get_weather", + Arguments: `{"city":"beijing"}`, + }, + }, + }) + + got, err := OpenAIChatRequestToGeminiGenerateContent(nil, dto.GeneralOpenAIRequest{ + Model: "gemini-test", + Messages: []dto.Message{ + {Role: "user", Content: "查天气"}, + assistant, + {Role: "tool", ToolCallId: "call_1", Content: "晴"}, + }, + }, nil) + require.NoError(t, err) + require.NotEmpty(t, got.Contents) + + var modelTurn *dto.GeminiChatContent + for i := range got.Contents { + if got.Contents[i].Role == "model" { + modelTurn = &got.Contents[i] + break + } + } + require.NotNil(t, modelTurn) + require.Len(t, modelTurn.Parts, 2) + assert.True(t, modelTurn.Parts[0].Thought) + assert.Equal(t, reasoning, modelTurn.Parts[0].Text) + assert.Empty(t, modelTurn.Parts[0].ThoughtSignature) + require.NotNil(t, modelTurn.Parts[1].FunctionCall) + assert.Equal(t, "get_weather", modelTurn.Parts[1].FunctionCall.FunctionName) + }) + + t.Run("reasoning only turn is kept", func(t *testing.T) { + got, err := OpenAIChatRequestToGeminiGenerateContent(nil, dto.GeneralOpenAIRequest{ + Model: "gemini-test", + Messages: []dto.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", ReasoningContent: lo.ToPtr(reasoning)}, + }, + }, nil) + require.NoError(t, err) + require.Len(t, got.Contents, 2) + require.Len(t, got.Contents[1].Parts, 1) + assert.True(t, got.Contents[1].Parts[0].Thought) + assert.Equal(t, reasoning, got.Contents[1].Parts[0].Text) + }) + + t.Run("user message reasoning is not emitted", func(t *testing.T) { + got, err := OpenAIChatRequestToGeminiGenerateContent(nil, dto.GeneralOpenAIRequest{ + Model: "gemini-test", + Messages: []dto.Message{ + {Role: "user", Content: "hi", ReasoningContent: lo.ToPtr(reasoning)}, + }, + }, nil) + require.NoError(t, err) + require.Len(t, got.Contents, 1) + for _, part := range got.Contents[0].Parts { + assert.False(t, part.Thought) + } + }) +} diff --git a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_req.go b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_req.go index ec51248b46ea..d1e546b8ff61 100644 --- a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_req.go +++ b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_req.go @@ -73,6 +73,23 @@ func convertChatResponseFormatToResponsesText(reqFormat *dto.ResponseFormat) jso return textRaw } +// chatReasoningToResponsesInputItem converts replayed assistant reasoning +// (reasoning_content in chat history) into a Responses reasoning input item. +// The summary array carries the text, matching the shape OpenAI returns and +// clients replay. +func chatReasoningToResponsesInputItem(reasoning string) map[string]any { + if reasoning == "" { + return nil + } + return map[string]any{ + "type": "reasoning", + "id": fmt.Sprintf("rs_%s", kitutil.GetUUID()), + "summary": []map[string]any{ + {"type": "summary_text", "text": reasoning}, + }, + } +} + func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) { if req == nil { return nil, errors.New("request is nil") @@ -152,6 +169,15 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d continue } + // Replay assistant reasoning as its own input item before the + // message/function_call items, so reasoning-capable upstream models keep + // multi-turn tool-call context. + if role == "assistant" { + if reasoningItem := chatReasoningToResponsesInputItem(msg.GetReasoningContent()); reasoningItem != nil { + inputItems = append(inputItems, reasoningItem) + } + } + item := map[string]any{ "role": role, } diff --git a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_req_test.go b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_req_test.go index 91bcae4e27f3..06f41ee1f097 100644 --- a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_req_test.go +++ b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_req_test.go @@ -2,6 +2,7 @@ package oaichat import ( "encoding/json" + "strings" "testing" "github.com/QuantumNous/new-api/relaykit/dto" @@ -75,6 +76,88 @@ func TestChatCompletionsRequestToResponsesRequestPreservesQwenThinkingBudget(t * } } +func TestChatCompletionsRequestToResponsesRequestPreservesAssistantReasoning(t *testing.T) { + reasoning := "需要先定位城市,再调用天气接口" + + newAssistantMsg := func() dto.Message { + msg := dto.Message{Role: "assistant", ReasoningContent: lo.ToPtr(reasoning)} + msg.SetToolCalls([]dto.ToolCallRequest{ + { + ID: "call_1", + Type: "function", + Function: dto.FunctionRequest{ + Name: "get_weather", + Arguments: `{}`, + }, + }, + }) + return msg + } + + t.Run("reasoning with tool calls", func(t *testing.T) { + got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{ + Model: "gpt-test", + Messages: []dto.Message{ + {Role: "user", Content: "查天气"}, + newAssistantMsg(), + {Role: "tool", ToolCallId: "call_1", Content: "晴"}, + {Role: "user", Content: "继续"}, + }, + }) + require.NoError(t, err) + + assert.Equal(t, "user", gjson.GetBytes(got.Input, "0.role").String()) + assert.Equal(t, "reasoning", gjson.GetBytes(got.Input, "1.type").String()) + assert.True(t, strings.HasPrefix(gjson.GetBytes(got.Input, "1.id").String(), "rs_")) + assert.Equal(t, "summary_text", gjson.GetBytes(got.Input, "1.summary.0.type").String()) + assert.Equal(t, reasoning, gjson.GetBytes(got.Input, "1.summary.0.text").String()) + assert.Equal(t, "assistant", gjson.GetBytes(got.Input, "2.role").String()) + assert.Equal(t, "function_call", gjson.GetBytes(got.Input, "3.type").String()) + assert.Equal(t, "call_1", gjson.GetBytes(got.Input, "3.call_id").String()) + assert.Equal(t, "function_call_output", gjson.GetBytes(got.Input, "4.type").String()) + assert.Equal(t, "user", gjson.GetBytes(got.Input, "5.role").String()) + }) + + t.Run("reasoning with text content", func(t *testing.T) { + msg := dto.Message{Role: "assistant", Content: "已查到结果", ReasoningContent: lo.ToPtr(reasoning)} + got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{ + Model: "gpt-test", + Messages: []dto.Message{msg}, + }) + require.NoError(t, err) + + assert.Equal(t, "reasoning", gjson.GetBytes(got.Input, "0.type").String()) + assert.Equal(t, reasoning, gjson.GetBytes(got.Input, "0.summary.0.text").String()) + assert.Equal(t, "assistant", gjson.GetBytes(got.Input, "1.role").String()) + assert.Equal(t, "已查到结果", gjson.GetBytes(got.Input, "1.content").String()) + }) + + t.Run("reasoning alias field", func(t *testing.T) { + msg := dto.Message{Role: "assistant", Content: "ok", Reasoning: lo.ToPtr(reasoning)} + got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{ + Model: "gpt-test", + Messages: []dto.Message{msg}, + }) + require.NoError(t, err) + + assert.Equal(t, "reasoning", gjson.GetBytes(got.Input, "0.type").String()) + assert.Equal(t, reasoning, gjson.GetBytes(got.Input, "0.summary.0.text").String()) + }) + + t.Run("user message reasoning is not emitted", func(t *testing.T) { + msg := dto.Message{Role: "user", Content: "hi", ReasoningContent: lo.ToPtr(reasoning)} + got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{ + Model: "gpt-test", + Messages: []dto.Message{msg}, + }) + require.NoError(t, err) + + assert.Equal(t, "user", gjson.GetBytes(got.Input, "0.role").String()) + assert.Equal(t, "", gjson.GetBytes(got.Input, "0.type").String()) + assert.Equal(t, "", gjson.GetBytes(got.Input, "1.type").String()) + }) +} + func TestChatCompletionsRequestToResponsesRequestRejectsMultipleChoices(t *testing.T) { _, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{ Model: "gpt-test", diff --git a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_req.go b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_req.go index 23b4bc70c031..ab38e4a5fce6 100644 --- a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_req.go +++ b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_req.go @@ -15,6 +15,7 @@ const ( responsesInputTypeFunctionCallOutput = "function_call_output" responsesInputTypeCustomToolCall = "custom_tool_call" responsesInputTypeCustomToolOutput = "custom_tool_call_output" + responsesInputTypeReasoning = "reasoning" ) const ( @@ -151,35 +152,44 @@ func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Mess if err := kitutil.Unmarshal(req.Input, &items); err != nil { return nil, fmt.Errorf("invalid input array: %w", err) } + pendingReasoning := "" for _, item := range items { - nextMessages, err := responsesInputItemToChatMessages(item, messages) + nextMessages, err := responsesInputItemToChatMessages(item, messages, &pendingReasoning) if err != nil { return nil, err } messages = nextMessages } - return messages, nil + return flushPendingReasoning(messages, &pendingReasoning), nil default: return nil, fmt.Errorf("unsupported responses input type %q", kitutil.GetJsonType(req.Input)) } } -func responsesInputItemToChatMessages(item map[string]any, messages []dto.Message) ([]dto.Message, error) { +func responsesInputItemToChatMessages(item map[string]any, messages []dto.Message, pendingReasoning *string) ([]dto.Message, error) { itemType := strings.TrimSpace(kitutil.Interface2String(item["type"])) switch itemType { + case responsesInputTypeReasoning: + appendPendingReasoning(pendingReasoning, responsesItemReasoningText(item)) + return messages, nil case responsesInputTypeFunctionCall: toolCall, err := responsesFunctionCallItemToChatToolCall(item) if err != nil { return nil, err } - return appendToolCallToLastAssistant(messages, toolCall), nil + appendPendingReasoning(pendingReasoning, responsesItemReasoningText(item)) + messages = appendToolCallToLastAssistant(messages, toolCall) + return flushPendingReasoning(messages, pendingReasoning), nil case responsesInputTypeCustomToolCall: toolCall, err := responsesCustomToolCallItemToChatToolCall(item) if err != nil { return nil, err } - return appendToolCallToLastAssistant(messages, toolCall), nil + appendPendingReasoning(pendingReasoning, responsesItemReasoningText(item)) + messages = appendToolCallToLastAssistant(messages, toolCall) + return flushPendingReasoning(messages, pendingReasoning), nil case responsesInputTypeFunctionCallOutput: + messages = flushPendingReasoning(messages, pendingReasoning) callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"])) content := responseToolOutputToChatContent(item["output"]) return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), nil @@ -193,9 +203,80 @@ func responsesInputItemToChatMessages(item map[string]any, messages []dto.Messag if err != nil { return nil, err } + if role == "assistant" { + message := dto.Message{Role: role, Content: content} + // Pending reasoning items precede this message on the wire, so they go + // first; any inline reasoning on the message itself follows. + if pendingReasoning != nil && *pendingReasoning != "" { + appendReasoningContent(&message, *pendingReasoning) + *pendingReasoning = "" + } + appendReasoningContent(&message, responsesItemReasoningText(item)) + messages = append(messages, message) + return flushPendingReasoning(messages, pendingReasoning), nil + } + messages = flushPendingReasoning(messages, pendingReasoning) return append(messages, dto.Message{Role: role, Content: content}), nil } +// responsesItemReasoningText extracts replayed assistant reasoning from an input +// item: inline reasoning_content/reasoning keys on any item, or the summary and +// content arrays of a standalone reasoning item (summary preferred, matching the +// shape OpenAI returns and clients replay). +func responsesItemReasoningText(item map[string]any) string { + for _, key := range []string{"reasoning_content", "reasoning"} { + if text, ok := item[key].(string); ok && text != "" { + return text + } + } + if strings.TrimSpace(kitutil.Interface2String(item["type"])) != responsesInputTypeReasoning { + return "" + } + for _, key := range []string{"summary", "content"} { + content, err := responsesInputContentToChatContent(item[key]) + if text, ok := content.(string); err == nil && ok && text != "" { + return text + } + } + return kitutil.Interface2String(item["text"]) +} + +func appendPendingReasoning(pending *string, reasoning string) { + if pending == nil || reasoning == "" || *pending == reasoning { + return + } + *pending += reasoning +} + +// flushPendingReasoning folds buffered reasoning into the last assistant message +// so multi-turn tool-call context survives conversion; when there is no assistant +// turn to attach to, the reasoning becomes a standalone assistant message rather +// than being dropped or mis-roled. +func flushPendingReasoning(messages []dto.Message, pending *string) []dto.Message { + if pending == nil || *pending == "" { + return messages + } + reasoning := *pending + *pending = "" + if len(messages) > 0 && messages[len(messages)-1].Role == "assistant" { + appendReasoningContent(&messages[len(messages)-1], reasoning) + return messages + } + return append(messages, dto.Message{Role: "assistant", ReasoningContent: &reasoning}) +} + +func appendReasoningContent(message *dto.Message, reasoning string) { + if message == nil || reasoning == "" { + return + } + current := message.GetReasoningContent() + if current == reasoning { + return + } + merged := current + reasoning + message.ReasoningContent = &merged +} + func responsesInputContentToChatContent(content any) (any, error) { if content == nil { return "", nil @@ -232,7 +313,7 @@ func responsesContentPartsToChatContent(parts []any) (any, error) { partType := strings.TrimSpace(kitutil.Interface2String(part["type"])) switch partType { - case "input_text", "output_text", "text": + case "input_text", "output_text", "summary_text", "reasoning_text", "text": text := kitutil.Interface2String(part["text"]) textOnly.WriteString(text) chatParts = append(chatParts, map[string]any{ diff --git a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_req_test.go b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_req_test.go index d27f2829c59f..de5f2469d84a 100644 --- a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_req_test.go +++ b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_req_test.go @@ -122,6 +122,211 @@ func TestResponsesRequestToChatCompletionsRequestMultimodalInput(t *testing.T) { assert.Equal(t, "https://example.test/v.mp4", parts[4].GetVideoUrl().Url) } +func TestResponsesRequestToChatCompletionsRequestPreservesToolCallReasoning(t *testing.T) { + tests := []struct { + name string + input []map[string]any + wantContent string + wantReasoning string + wantCalls int + }{ + { + name: "reasoning before parallel function calls", + input: []map[string]any{ + { + "type": "reasoning", + "summary": []map[string]any{ + {"type": "summary_text", "text": "Need both "}, + {"type": "summary_text", "text": "tool results."}, + }, + }, + {"type": "function_call", "call_id": "call_1", "name": "first", "arguments": `{}`}, + {"type": "function_call", "call_id": "call_2", "name": "second", "arguments": `{}`}, + {"type": "function_call_output", "call_id": "call_1", "output": "one"}, + {"type": "function_call_output", "call_id": "call_2", "output": "two"}, + }, + wantReasoning: "Need both tool results.", + wantCalls: 2, + }, + { + name: "reasoning between assistant text and function call", + input: []map[string]any{ + { + "role": "assistant", + "content": []map[string]any{{"type": "output_text", "text": "Checking now."}}, + }, + { + "type": "reasoning", + "content": []map[string]any{{"type": "summary_text", "text": "Need the lookup."}}, + }, + {"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": `{}`}, + {"type": "function_call_output", "call_id": "call_1", "output": "done"}, + }, + wantContent: "Checking now.", + wantReasoning: "Need the lookup.", + wantCalls: 1, + }, + { + name: "reasoning embedded in function call", + input: []map[string]any{ + { + "type": "function_call", + "call_id": "call_1", + "name": "lookup", + "arguments": `{}`, + "reasoning_content": "Need the lookup.", + }, + {"type": "function_call_output", "call_id": "call_1", "output": "done"}, + }, + wantReasoning: "Need the lookup.", + wantCalls: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustRawMessage(t, tt.input), + }) + require.NoError(t, err) + + require.Len(t, got.Messages, tt.wantCalls+1) + assistant := got.Messages[0] + assert.Equal(t, "assistant", assistant.Role) + assert.Equal(t, tt.wantContent, assistant.StringContent()) + assert.Equal(t, tt.wantReasoning, assistant.GetReasoningContent()) + assert.Len(t, assistant.ParseToolCalls(), tt.wantCalls) + }) + } +} + +func TestResponsesRequestToChatCompletionsRequestReasoningItemNeverBecomesUserMessage(t *testing.T) { + // Regression: a standalone reasoning item without a role previously fell into + // the default branch and was emitted as role="user", corrupting the history. + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustRawMessage(t, []map[string]any{ + {"role": "user", "content": "hi"}, + { + "type": "reasoning", + "summary": []map[string]any{{"type": "summary_text", "text": "thinking..."}}, + }, + {"role": "user", "content": "continue"}, + }), + }) + require.NoError(t, err) + + require.Len(t, got.Messages, 3) + assert.Equal(t, "user", got.Messages[0].Role) + assert.Equal(t, "assistant", got.Messages[1].Role) + assert.Equal(t, "thinking...", got.Messages[1].GetReasoningContent()) + assert.Equal(t, "user", got.Messages[2].Role) + assert.Equal(t, "continue", got.Messages[2].StringContent()) + for i, message := range got.Messages { + assert.NotEqualf(t, "thinking...", message.StringContent(), "reasoning text leaked into content of message %d", i) + } +} + +func TestResponsesRequestToChatCompletionsRequestOrphanReasoningBecomesStandaloneAssistant(t *testing.T) { + // Reasoning with no following function_call must not be dropped: it is kept as + // a standalone assistant message (mirrors the modelbridge decode behavior). + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustRawMessage(t, []map[string]any{ + {"type": "reasoning", "summary": []map[string]any{{"type": "summary_text", "text": "orphan thought"}}}, + }), + }) + require.NoError(t, err) + + require.Len(t, got.Messages, 1) + assert.Equal(t, "assistant", got.Messages[0].Role) + assert.Equal(t, "orphan thought", got.Messages[0].GetReasoningContent()) +} + +func TestResponsesRequestToChatCompletionsRequestMergesConsecutiveAssistantReasoning(t *testing.T) { + // An assistant message with inline reasoning followed by a standalone + // reasoning item and a function_call: both reasoning parts land on the single + // assistant message carrying the tool call (inline first, then pending). + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustRawMessage(t, []map[string]any{ + {"role": "assistant", "content": "先做第一步", "reasoning_content": "推理A"}, + {"type": "reasoning", "summary": []map[string]any{{"type": "summary_text", "text": "推理B"}}}, + {"type": "function_call", "call_id": "c1", "name": "f", "arguments": `{}`}, + {"type": "function_call_output", "call_id": "c1", "output": "ok"}, + }), + }) + require.NoError(t, err) + + require.Len(t, got.Messages, 2) + assert.Equal(t, "推理A推理B", got.Messages[0].GetReasoningContent()) + assert.Len(t, got.Messages[0].ParseToolCalls(), 1) +} + +func TestResponsesRequestToChatCompletionsRequestReasoningBeforeToolOutput(t *testing.T) { + // Reasoning immediately before a function_call_output (no function_call): + // it becomes a standalone assistant message placed before the tool message. + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustRawMessage(t, []map[string]any{ + {"type": "reasoning", "summary": []map[string]any{{"type": "summary_text", "text": "孤立推理"}}}, + {"type": "function_call_output", "call_id": "c1", "output": "ok"}, + }), + }) + require.NoError(t, err) + + require.Len(t, got.Messages, 2) + assert.Equal(t, "assistant", got.Messages[0].Role) + assert.Equal(t, "孤立推理", got.Messages[0].GetReasoningContent()) + assert.Equal(t, "tool", got.Messages[1].Role) +} + +func TestResponsesRequestToChatCompletionsRequestPendingReasoningPrecedesInline(t *testing.T) { + // A standalone reasoning item precedes an assistant message carrying an + // inline reasoning_content key: the merged reasoning must keep wire order + // (pending first, inline second). + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustRawMessage(t, []map[string]any{ + {"type": "reasoning", "summary": []map[string]any{{"type": "summary_text", "text": "B"}}}, + {"role": "assistant", "content": "hi", "reasoning_content": "A"}, + }), + }) + require.NoError(t, err) + + require.Len(t, got.Messages, 1) + assert.Equal(t, "BA", got.Messages[0].GetReasoningContent()) +} + +func TestResponsesRequestToChatCompletionsRequestReasoningStaysInOwnTurn(t *testing.T) { + // After a tool output message, the next function_call starts a new assistant + // message, so each turn keeps its own reasoning: + // [assistant(r1, f1), tool, assistant(r2, f2), tool]. + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustRawMessage(t, []map[string]any{ + {"type": "reasoning", "summary": []map[string]any{{"type": "summary_text", "text": "回合1推理"}}}, + {"type": "function_call", "call_id": "c1", "name": "f1", "arguments": `{}`}, + {"type": "function_call_output", "call_id": "c1", "output": "r1"}, + {"type": "reasoning", "summary": []map[string]any{{"type": "summary_text", "text": "回合2推理"}}}, + {"type": "function_call", "call_id": "c2", "name": "f2", "arguments": `{}`}, + {"type": "function_call_output", "call_id": "c2", "output": "r2"}, + }), + }) + require.NoError(t, err) + + require.Len(t, got.Messages, 4) + assert.Equal(t, "assistant", got.Messages[0].Role) + assert.Equal(t, "回合1推理", got.Messages[0].GetReasoningContent()) + assert.Len(t, got.Messages[0].ParseToolCalls(), 1) + assert.Equal(t, "tool", got.Messages[1].Role) + assert.Equal(t, "assistant", got.Messages[2].Role) + assert.Equal(t, "回合2推理", got.Messages[2].GetReasoningContent()) + assert.Len(t, got.Messages[2].ParseToolCalls(), 1) + assert.Equal(t, "tool", got.Messages[3].Role) +} + func TestResponsesRequestToChatCompletionsRequestAssistantTextAndFunctionCallCoexist(t *testing.T) { got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ Model: "gpt-test", diff --git a/relaykit/relayconvert/reasoning_roundtrip_test.go b/relaykit/relayconvert/reasoning_roundtrip_test.go new file mode 100644 index 000000000000..f446b7d7545a --- /dev/null +++ b/relaykit/relayconvert/reasoning_roundtrip_test.go @@ -0,0 +1,148 @@ +package relayconvert_test + +import ( + "testing" + + "github.com/QuantumNous/new-api/relaykit/dto" + claudemessages "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/claude_messages" + oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat" + oairesponses "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_responses" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// chat -> responses -> chat: replayed assistant reasoning must survive the full +// circle on the same assistant message that carries the tool call. +func TestReasoningRoundTripChatResponsesChat(t *testing.T) { + reasoning := "先定位城市,再查天气" + assistant := dto.Message{Role: "assistant", ReasoningContent: lo.ToPtr(reasoning)} + assistant.SetToolCalls([]dto.ToolCallRequest{ + { + ID: "call_1", + Type: "function", + Function: dto.FunctionRequest{ + Name: "get_weather", + Arguments: `{"city":"beijing"}`, + }, + }, + }) + + responsesReq, err := oaichat.ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{ + Model: "gpt-test", + Messages: []dto.Message{ + {Role: "user", Content: "查天气"}, + assistant, + {Role: "tool", ToolCallId: "call_1", Content: "晴"}, + {Role: "user", Content: "继续"}, + }, + }) + require.NoError(t, err) + + back, err := oairesponses.ResponsesRequestToChatCompletionsRequest(responsesReq) + require.NoError(t, err) + + var found *dto.Message + for i := range back.Messages { + if back.Messages[i].Role == "assistant" && len(back.Messages[i].ParseToolCalls()) > 0 { + found = &back.Messages[i] + } + } + require.NotNil(t, found, "assistant message carrying the tool call is missing") + assert.Equal(t, reasoning, found.GetReasoningContent()) + toolCalls := found.ParseToolCalls() + require.Len(t, toolCalls, 1) + assert.Equal(t, "call_1", toolCalls[0].ID) +} + +// claude -> chat -> claude: thinking text survives the full circle (the +// signature cannot cross the chat format and is intentionally dropped). The +// thinking-bearing assistant turn is followed by a later assistant turn, so it +// is not the latest assistant message and its thinking block is replayed. +func TestReasoningRoundTripClaudeChatClaude(t *testing.T) { + chatReq, err := claudemessages.ClaudeMessagesRequestToOpenAIChat(dto.ClaudeRequest{ + Model: "claude-test", + Messages: []dto.ClaudeMessage{ + {Role: "user", Content: "查天气"}, + {Role: "assistant", Content: []any{ + map[string]any{"type": "thinking", "thinking": "先定位城市", "signature": "sig"}, + map[string]any{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": map[string]any{}}, + }}, + {Role: "user", Content: []any{ + map[string]any{"type": "tool_result", "tool_use_id": "call_1", "content": "晴"}, + }}, + {Role: "assistant", Content: "北京今天晴"}, + }, + }, nil) + require.NoError(t, err) + chatReq.MaxTokens = lo.ToPtr(uint(1024)) + + back, err := oaichat.OpenAIChatRequestToClaudeMessages(nil, nil, *chatReq) + require.NoError(t, err) + + var thinkingText string + toolUseFound := false + for _, message := range back.Messages { + if message.Role != "assistant" { + continue + } + blocks, ok := message.Content.([]dto.ClaudeMediaMessage) + if !ok { + continue + } + for _, block := range blocks { + if block.Type == "thinking" && block.Thinking != nil { + thinkingText = *block.Thinking + } + if block.Type == "tool_use" { + toolUseFound = true + } + } + } + assert.Equal(t, "先定位城市", thinkingText) + assert.True(t, toolUseFound, "tool_use block missing after round trip") +} + +// claude -> chat -> claude with the thinking-bearing assistant turn as the +// LATEST assistant message (tool-use continuation): Anthropic signature-verifies +// thinking blocks in that position and a synthesized unsigned block would be +// rejected with a 400, so the converter deliberately withholds it and keeps the +// pre-fix accepted shape (text + tool_use, no thinking block). +func TestReasoningRoundTripClaudeChatClaudeLatestTurnWithheld(t *testing.T) { + chatReq, err := claudemessages.ClaudeMessagesRequestToOpenAIChat(dto.ClaudeRequest{ + Model: "claude-test", + Messages: []dto.ClaudeMessage{ + {Role: "user", Content: "查天气"}, + {Role: "assistant", Content: []any{ + map[string]any{"type": "thinking", "thinking": "先定位城市", "signature": "sig"}, + map[string]any{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": map[string]any{}}, + }}, + {Role: "user", Content: []any{ + map[string]any{"type": "tool_result", "tool_use_id": "call_1", "content": "晴"}, + }}, + }, + }, nil) + require.NoError(t, err) + chatReq.MaxTokens = lo.ToPtr(uint(1024)) + + back, err := oaichat.OpenAIChatRequestToClaudeMessages(nil, nil, *chatReq) + require.NoError(t, err) + + toolUseFound := false + for _, message := range back.Messages { + if message.Role != "assistant" { + continue + } + blocks, ok := message.Content.([]dto.ClaudeMediaMessage) + if !ok { + continue + } + for _, block := range blocks { + assert.NotEqual(t, "thinking", block.Type, "unsigned thinking block must not be emitted on the latest assistant turn") + if block.Type == "tool_use" { + toolUseFound = true + } + } + } + assert.True(t, toolUseFound, "tool_use block missing after round trip") +} diff --git a/relaykit/relayconvert/testdata/golden/request/claude_to_gemini.golden.json b/relaykit/relayconvert/testdata/golden/request/claude_to_gemini.golden.json index 8ec074a6f41d..f874b8f81c84 100644 --- a/relaykit/relayconvert/testdata/golden/request/claude_to_gemini.golden.json +++ b/relaykit/relayconvert/testdata/golden/request/claude_to_gemini.golden.json @@ -17,6 +17,10 @@ { "role": "model", "parts": [ + { + "text": "Let me look.", + "thought": true + }, { "functionCall": { "name": "get_weather", diff --git a/relaykit/relayconvert/testdata/golden/request/claude_to_openai.golden.json b/relaykit/relayconvert/testdata/golden/request/claude_to_openai.golden.json index 59a20e45a6eb..d8c4a8450610 100644 --- a/relaykit/relayconvert/testdata/golden/request/claude_to_openai.golden.json +++ b/relaykit/relayconvert/testdata/golden/request/claude_to_openai.golden.json @@ -24,6 +24,7 @@ { "role": "assistant", "content": null, + "reasoning_content": "Let me look.", "tool_calls": [ { "id": "toolu_abc", diff --git a/relaykit/relayconvert/testdata/golden/request/claude_to_openai_responses.golden.json b/relaykit/relayconvert/testdata/golden/request/claude_to_openai_responses.golden.json index e5f688638916..f7dbaadb692e 100644 --- a/relaykit/relayconvert/testdata/golden/request/claude_to_openai_responses.golden.json +++ b/relaykit/relayconvert/testdata/golden/request/claude_to_openai_responses.golden.json @@ -14,6 +14,16 @@ ], "role": "user" }, + { + "id": "rs_", + "summary": [ + { + "text": "Let me look.", + "type": "summary_text" + } + ], + "type": "reasoning" + }, { "content": "", "role": "assistant"