Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions relaykit/dto/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
25 changes: 23 additions & 2 deletions relaykit/relayconvert/internal/claude_messages/to_oai_chat_req.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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{
Expand Down Expand Up @@ -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)
}
}
Expand Down
180 changes: 180 additions & 0 deletions relaykit/relayconvert/internal/claude_messages/to_oai_chat_req_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
56 changes: 54 additions & 2 deletions relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand All @@ -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
}
Expand All @@ -246,21 +264,30 @@ 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
}

claudeMessages := make([]dto.ClaudeMessage, 0)
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 != "" {
Expand Down Expand Up @@ -329,18 +356,43 @@ 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 = "..."
}
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),
Expand Down
Loading