From feef6033b47f6eb3fc5d5903d111763009cc26c9 Mon Sep 17 00:00:00 2001 From: Clansty Date: Sat, 2 May 2026 20:15:05 +0800 Subject: [PATCH 1/3] fix: add reasoning opaque fields --- dto/openai_request.go | 8 ++++++++ dto/openai_response.go | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/dto/openai_request.go b/dto/openai_request.go index 8c104ddd242..e99697c3614 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -281,6 +281,7 @@ type Message struct { Prefix *bool `json:"prefix,omitempty"` ReasoningContent *string `json:"reasoning_content,omitempty"` Reasoning *string `json:"reasoning,omitempty"` + ReasoningOpaque *string `json:"reasoning_opaque,omitempty"` ToolCalls json.RawMessage `json:"tool_calls,omitempty"` ToolCallId string `json:"tool_call_id,omitempty"` parsedContent []MediaContent @@ -441,6 +442,13 @@ func (m *Message) GetReasoningContent() string { return *m.Reasoning } +func (m *Message) GetReasoningOpaque() string { + if m.ReasoningOpaque == nil { + return "" + } + return *m.ReasoningOpaque +} + func (m *Message) GetPrefix() bool { if m.Prefix == nil { return false diff --git a/dto/openai_response.go b/dto/openai_response.go index 0e6b818dbd8..913227bdc61 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -89,6 +89,7 @@ type ChatCompletionsStreamResponseChoiceDelta struct { Content *string `json:"content,omitempty"` ReasoningContent *string `json:"reasoning_content,omitempty"` Reasoning *string `json:"reasoning,omitempty"` + ReasoningOpaque *string `json:"reasoning_opaque,omitempty"` Role string `json:"role,omitempty"` ToolCalls []ToolCallResponse `json:"tool_calls,omitempty"` } @@ -119,6 +120,13 @@ func (c *ChatCompletionsStreamResponseChoiceDelta) SetReasoningContent(s string) //c.Reasoning = &s } +func (c *ChatCompletionsStreamResponseChoiceDelta) GetReasoningOpaque() string { + if c.ReasoningOpaque == nil { + return "" + } + return *c.ReasoningOpaque +} + type ToolCallResponse struct { // Index is not nil only in chat completion chunk object Index *int `json:"index,omitempty"` From aa046a26dc6d4b49bedfdcec7f4c4beec8814d21 Mon Sep 17 00:00:00 2001 From: Clansty Date: Sat, 2 May 2026 20:15:05 +0800 Subject: [PATCH 2/3] fix: preserve reasoning replay metadata --- service/convert.go | 29 ++++++- service/convert_test.go | 176 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 service/convert_test.go diff --git a/service/convert.go b/service/convert.go index 95acf835ee4..ed0e8229e3b 100644 --- a/service/convert.go +++ b/service/convert.go @@ -32,7 +32,15 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re openAIRequest.Stream = lo.ToPtr(lo.FromPtr(claudeRequest.Stream)) } - isOpenRouter := info.ChannelType == constant.ChannelTypeOpenRouter + channelType := 0 + originModelName := "" + if info != nil && info.ChannelMeta != nil { + channelType = info.ChannelType + } + if info != nil { + originModelName = info.OriginModelName + } + isOpenRouter := channelType == constant.ChannelTypeOpenRouter if isOpenRouter { if effort := claudeRequest.GetEfforts(); effort != "" { @@ -59,7 +67,7 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re } } else { thinkingSuffix := "-thinking" - if strings.HasSuffix(info.OriginModelName, thinkingSuffix) && + if strings.HasSuffix(originModelName, thinkingSuffix) && !strings.HasSuffix(openAIRequest.Model, thinkingSuffix) { openAIRequest.Model = openAIRequest.Model + thinkingSuffix } @@ -149,6 +157,13 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re for _, mediaMsg := range contents { switch mediaMsg.Type { + case "thinking": + if mediaMsg.Thinking != nil { + openAIMessage.ReasoningContent = mediaMsg.Thinking + } + if mediaMsg.Signature != "" { + openAIMessage.ReasoningOpaque = common.GetPointer[string](mediaMsg.Signature) + } case "text", "input_text": message := dto.MediaContent{ Type: "text", @@ -615,6 +630,16 @@ func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relayco } for _, choice := range openAIResponse.Choices { stopReason = stopReasonOpenAI2Claude(choice.FinishReason) + if reasoning := choice.Message.GetReasoningContent(); reasoning != "" { + thinkingBlock := dto.ClaudeMediaMessage{ + Type: "thinking", + Thinking: common.GetPointer[string](reasoning), + } + if signature := choice.Message.GetReasoningOpaque(); signature != "" { + thinkingBlock.Signature = signature + } + contents = append(contents, thinkingBlock) + } if choice.FinishReason == "tool_calls" { for _, toolUse := range choice.Message.ParseToolCalls() { claudeContent := dto.ClaudeMediaMessage{} diff --git a/service/convert_test.go b/service/convert_test.go new file mode 100644 index 00000000000..a0f2e2831c6 --- /dev/null +++ b/service/convert_test.go @@ -0,0 +1,176 @@ +package service + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/stretchr/testify/require" +) + +func testRelayInfo() *relaycommon.RelayInfo { + return &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{}, + } +} + +func TestClaudeToOpenAIRequestPreservesThinkingForToolUse(t *testing.T) { + thinking := "I need to inspect the file before answering." + signature := "EqQBCgIYAhIM1xYvopaqueSignature" + claudeRequest := dto.ClaudeRequest{ + Model: "deepseek-v4-pro", + Messages: []dto.ClaudeMessage{ + { + Role: "assistant", + Content: []dto.ClaudeMediaMessage{ + { + Type: "thinking", + Thinking: &thinking, + Signature: signature, + }, + { + Type: "tool_use", + Id: "call_123", + Name: "read", + Input: map[string]any{"filePath": "/tmp/1.txt"}, + }, + }, + }, + }, + } + + openAIRequest, err := ClaudeToOpenAIRequest(claudeRequest, testRelayInfo()) + require.NoError(t, err) + require.Len(t, openAIRequest.Messages, 1) + + message := openAIRequest.Messages[0] + require.Equal(t, "assistant", message.Role) + require.Equal(t, thinking, message.GetReasoningContent()) + require.Equal(t, signature, message.GetReasoningOpaque()) + require.Len(t, message.ParseToolCalls(), 1) + require.JSONEq(t, `{"filePath":"/tmp/1.txt"}`, message.ParseToolCalls()[0].Function.Arguments) +} + +func TestClaudeToOpenAIRequestPreservesSignedThinkingContent(t *testing.T) { + thinking := "Visible reasoning that DeepSeek needs for tool replay." + signature := "EqQBCgIYAhIMsignedOpaqueBlob" + claudeRequest := dto.ClaudeRequest{ + Model: "deepseek-v4-pro", + Messages: []dto.ClaudeMessage{ + { + Role: "assistant", + Content: []dto.ClaudeMediaMessage{ + { + Type: "thinking", + Thinking: &thinking, + Signature: signature, + }, + { + Type: "tool_use", + Id: "call_456", + Name: "lookup", + Input: map[string]any{"query": "reasoning"}, + }, + }, + }, + }, + } + + openAIRequest, err := ClaudeToOpenAIRequest(claudeRequest, testRelayInfo()) + require.NoError(t, err) + require.Len(t, openAIRequest.Messages, 1) + require.Equal(t, thinking, openAIRequest.Messages[0].GetReasoningContent()) + require.Equal(t, signature, openAIRequest.Messages[0].GetReasoningOpaque()) + require.Len(t, openAIRequest.Messages[0].ParseToolCalls(), 1) +} + +func TestClaudeToOpenAIRequestSkipsEmptyThinkingOnlyMessage(t *testing.T) { + claudeRequest := dto.ClaudeRequest{ + Model: "deepseek-v4-pro", + Messages: []dto.ClaudeMessage{ + { + Role: "assistant", + Content: []dto.ClaudeMediaMessage{ + {Type: "thinking"}, + }, + }, + }, + } + + openAIRequest, err := ClaudeToOpenAIRequest(claudeRequest, testRelayInfo()) + require.NoError(t, err) + require.Empty(t, openAIRequest.Messages) +} + +func TestClaudeToOpenAIRequestHandlesNilRelayInfo(t *testing.T) { + claudeRequest := dto.ClaudeRequest{Model: "deepseek-v4-pro"} + + openAIRequest, err := ClaudeToOpenAIRequest(claudeRequest, nil) + require.NoError(t, err) + require.Equal(t, "deepseek-v4-pro", openAIRequest.Model) +} + +func TestClaudeToOpenAIRequestPreservesThinkingWithNilChannelMeta(t *testing.T) { + thinking := "Need to call a tool." + claudeRequest := dto.ClaudeRequest{ + Model: "deepseek-v4-pro", + Messages: []dto.ClaudeMessage{ + { + Role: "assistant", + Content: []dto.ClaudeMediaMessage{ + {Type: "thinking", Thinking: &thinking}, + {Type: "tool_use", Id: "call_123", Name: "read"}, + }, + }, + }, + } + + openAIRequest, err := ClaudeToOpenAIRequest(claudeRequest, &relaycommon.RelayInfo{}) + require.NoError(t, err) + require.Len(t, openAIRequest.Messages, 1) + require.Equal(t, thinking, openAIRequest.Messages[0].GetReasoningContent()) +} + +func TestResponseOpenAI2ClaudePreservesReasoningBeforeToolUse(t *testing.T) { + reasoning := "Need the file content first." + opaque := "EqQBCgIYAhIMsignedOpaqueBlob" + toolCalls := []dto.ToolCallRequest{ + { + ID: "call_123", + Type: "function", + Function: dto.FunctionRequest{ + Name: "read", + Arguments: `{"filePath":"/tmp/1.txt"}`, + }, + }, + } + toolCallsJSON, err := common.Marshal(toolCalls) + require.NoError(t, err) + + openAIResponse := &dto.OpenAITextResponse{ + Id: "chatcmpl_123", + Model: "deepseek-v4-pro", + Choices: []dto.OpenAITextResponseChoice{ + { + Message: dto.Message{ + Role: "assistant", + ReasoningContent: &reasoning, + ReasoningOpaque: &opaque, + ToolCalls: toolCallsJSON, + }, + FinishReason: "tool_calls", + }, + }, + } + + claudeResponse := ResponseOpenAI2Claude(openAIResponse, &relaycommon.RelayInfo{}) + require.Len(t, claudeResponse.Content, 2) + require.Equal(t, "thinking", claudeResponse.Content[0].Type) + require.NotNil(t, claudeResponse.Content[0].Thinking) + require.Equal(t, reasoning, *claudeResponse.Content[0].Thinking) + require.Equal(t, opaque, claudeResponse.Content[0].Signature) + require.Equal(t, "tool_use", claudeResponse.Content[1].Type) + require.Equal(t, "call_123", claudeResponse.Content[1].Id) + require.Equal(t, "read", claudeResponse.Content[1].Name) +} From c41bacf4a51d9dd94746ad270598612a4ace3000 Mon Sep 17 00:00:00 2001 From: Clansty Date: Sat, 2 May 2026 21:25:18 +0800 Subject: [PATCH 3/3] fix: emit Claude thinking signatures in streams --- dto/claude.go | 2 +- relay/common/relay_info.go | 4 + service/convert.go | 38 +++++++++- service/convert_test.go | 149 ++++++++++++++++++++++++++++++++++++- 4 files changed, 184 insertions(+), 9 deletions(-) diff --git a/dto/claude.go b/dto/claude.go index d7fed412aaa..7b5e1fd7cc9 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -25,7 +25,7 @@ type ClaudeMediaMessage struct { PartialJson *string `json:"partial_json,omitempty"` Role string `json:"role,omitempty"` Thinking *string `json:"thinking,omitempty"` - Signature string `json:"signature,omitempty"` + Signature *string `json:"signature,omitempty"` Delta string `json:"delta,omitempty"` CacheControl json.RawMessage `json:"cache_control,omitempty"` // tool_calls diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 64d4d4eedfa..0212c97fe5c 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -39,6 +39,10 @@ type ClaudeConvertInfo struct { Usage *dto.Usage FinishReason string Done bool + ReasoningContent string + ReasoningOpaque string + SentThinking bool + SentSignature bool ToolCallBaseIndex int ToolCallMaxIndexOffset int diff --git a/service/convert.go b/service/convert.go index ed0e8229e3b..1d8ebe2eda7 100644 --- a/service/convert.go +++ b/service/convert.go @@ -161,8 +161,8 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re if mediaMsg.Thinking != nil { openAIMessage.ReasoningContent = mediaMsg.Thinking } - if mediaMsg.Signature != "" { - openAIMessage.ReasoningOpaque = common.GetPointer[string](mediaMsg.Signature) + if mediaMsg.Signature != nil { + openAIMessage.ReasoningOpaque = mediaMsg.Signature } case "text", "input_text": message := dto.MediaContent{ @@ -282,7 +282,21 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon // so we may have multiple open blocks and must stop each one explicitly. stopOpenBlocks := func() { switch info.ClaudeConvertInfo.LastMessagesType { - case relaycommon.LastMessageTypeText, relaycommon.LastMessageTypeThinking: + case relaycommon.LastMessageTypeThinking: + if !info.ClaudeConvertInfo.SentSignature { + idx := info.ClaudeConvertInfo.Index + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &idx, + Type: "content_block_delta", + Delta: &dto.ClaudeMediaMessage{ + Type: "signature_delta", + Signature: common.GetPointer[string](info.ClaudeConvertInfo.ReasoningOpaque), + }, + }) + info.ClaudeConvertInfo.SentSignature = true + } + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + case relaycommon.LastMessageTypeText: claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) case relaycommon.LastMessageTypeTools: base := info.ClaudeConvertInfo.ToolCallBaseIndex @@ -374,6 +388,12 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon // 判断首个响应是否存在内容(非标准的 OpenAI 响应) if len(openAIResponse.Choices) > 0 { reasoning := openAIResponse.Choices[0].Delta.GetReasoningContent() + if reasoning != "" { + info.ClaudeConvertInfo.ReasoningContent += reasoning + } + if opaque := openAIResponse.Choices[0].Delta.GetReasoningOpaque(); opaque != "" { + info.ClaudeConvertInfo.ReasoningOpaque += opaque + } content := openAIResponse.Choices[0].Delta.GetContentString() if reasoning != "" { @@ -389,6 +409,8 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon Thinking: common.GetPointer[string](""), }, }) + info.ClaudeConvertInfo.SentThinking = true + info.ClaudeConvertInfo.SentSignature = false idx2 := idx claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ Index: &idx2, @@ -477,6 +499,12 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon return claudeResponses } else { chosenChoice := openAIResponse.Choices[0] + if reasoning := chosenChoice.Delta.GetReasoningContent(); reasoning != "" { + info.ClaudeConvertInfo.ReasoningContent += reasoning + } + if opaque := chosenChoice.Delta.GetReasoningOpaque(); opaque != "" { + info.ClaudeConvertInfo.ReasoningOpaque += opaque + } doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != "" if doneChunk { info.FinishReason = *chosenChoice.FinishReason @@ -558,6 +586,8 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon Thinking: common.GetPointer[string](""), }, }) + info.ClaudeConvertInfo.SentThinking = true + info.ClaudeConvertInfo.SentSignature = false } info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeThinking claudeResponse.Delta = &dto.ClaudeMediaMessage{ @@ -636,7 +666,7 @@ func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relayco Thinking: common.GetPointer[string](reasoning), } if signature := choice.Message.GetReasoningOpaque(); signature != "" { - thinkingBlock.Signature = signature + thinkingBlock.Signature = common.GetPointer[string](signature) } contents = append(contents, thinkingBlock) } diff --git a/service/convert_test.go b/service/convert_test.go index a0f2e2831c6..f471803d82f 100644 --- a/service/convert_test.go +++ b/service/convert_test.go @@ -6,15 +6,155 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" "github.com/stretchr/testify/require" ) func testRelayInfo() *relaycommon.RelayInfo { return &relaycommon.RelayInfo{ - ChannelMeta: &relaycommon.ChannelMeta{}, + ChannelMeta: &relaycommon.ChannelMeta{}, + ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{}, } } +func TestStreamResponseOpenAI2ClaudeEmitsSignatureBeforeToolUse(t *testing.T) { + info := testRelayInfo() + info.RelayFormat = types.RelayFormatClaude + info.SendResponseCount = 1 + reasoning := "Need to inspect the file." + opaque := "EqQBCgIYAhIMsignedOpaqueBlob" + + start := &dto.ChatCompletionsStreamResponse{ + Id: "chatcmpl_123", + Model: "deepseek-v4-pro", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + Role: "assistant", + ReasoningContent: &reasoning, + }, + }, + }, + } + responses := StreamResponseOpenAI2Claude(start, info) + require.Len(t, responses, 3) + require.Equal(t, "content_block_start", responses[1].Type) + require.Equal(t, "thinking", responses[1].ContentBlock.Type) + require.NotNil(t, responses[1].ContentBlock.Thinking) + require.Equal(t, "", *responses[1].ContentBlock.Thinking) + require.Equal(t, "thinking_delta", responses[2].Delta.Type) + require.Equal(t, reasoning, *responses[2].Delta.Thinking) + + info.SendResponseCount++ + signature := &dto.ChatCompletionsStreamResponse{ + Id: "chatcmpl_123", + Model: "deepseek-v4-pro", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + ReasoningOpaque: &opaque, + }, + }, + }, + } + responses = StreamResponseOpenAI2Claude(signature, info) + require.Empty(t, responses) + + info.SendResponseCount++ + args := `{"filePath":"/tmp/1.txt"}` + tool := &dto.ChatCompletionsStreamResponse{ + Id: "chatcmpl_123", + Model: "deepseek-v4-pro", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + ToolCalls: []dto.ToolCallResponse{ + { + Index: common.GetPointer[int](0), + ID: "call_123", + Type: "function", + Function: dto.FunctionResponse{ + Name: "read", + Arguments: args, + }, + }, + }, + }, + }, + }, + } + responses = StreamResponseOpenAI2Claude(tool, info) + require.GreaterOrEqual(t, len(responses), 4) + require.Equal(t, "content_block_delta", responses[0].Type) + require.Equal(t, "signature_delta", responses[0].Delta.Type) + require.NotNil(t, responses[0].Delta.Signature) + require.Equal(t, opaque, *responses[0].Delta.Signature) + require.Equal(t, "content_block_stop", responses[1].Type) + require.Equal(t, "content_block_start", responses[2].Type) + require.Equal(t, "tool_use", responses[2].ContentBlock.Type) +} + +func TestStreamResponseOpenAI2ClaudeEmitsBlankSignatureBeforeToolUse(t *testing.T) { + info := testRelayInfo() + info.RelayFormat = types.RelayFormatClaude + info.SendResponseCount = 1 + reasoning := "Need to inspect the file." + + start := &dto.ChatCompletionsStreamResponse{ + Id: "chatcmpl_123", + Model: "deepseek-v4-pro", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + Role: "assistant", + ReasoningContent: &reasoning, + }, + }, + }, + } + responses := StreamResponseOpenAI2Claude(start, info) + require.Len(t, responses, 3) + require.Equal(t, "thinking_delta", responses[2].Delta.Type) + + info.SendResponseCount++ + args := `{"filePath":"/tmp/1.txt"}` + tool := &dto.ChatCompletionsStreamResponse{ + Id: "chatcmpl_123", + Model: "deepseek-v4-pro", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + ToolCalls: []dto.ToolCallResponse{ + { + Index: common.GetPointer[int](0), + ID: "call_123", + Type: "function", + Function: dto.FunctionResponse{ + Name: "read", + Arguments: args, + }, + }, + }, + }, + }, + }, + } + responses = StreamResponseOpenAI2Claude(tool, info) + require.GreaterOrEqual(t, len(responses), 4) + require.Equal(t, "content_block_delta", responses[0].Type) + require.Equal(t, "signature_delta", responses[0].Delta.Type) + require.NotNil(t, responses[0].Delta.Signature) + require.Equal(t, "", *responses[0].Delta.Signature) + require.Equal(t, "content_block_stop", responses[1].Type) + require.Equal(t, "content_block_start", responses[2].Type) + require.Equal(t, "tool_use", responses[2].ContentBlock.Type) +} + func TestClaudeToOpenAIRequestPreservesThinkingForToolUse(t *testing.T) { thinking := "I need to inspect the file before answering." signature := "EqQBCgIYAhIM1xYvopaqueSignature" @@ -27,7 +167,7 @@ func TestClaudeToOpenAIRequestPreservesThinkingForToolUse(t *testing.T) { { Type: "thinking", Thinking: &thinking, - Signature: signature, + Signature: &signature, }, { Type: "tool_use", @@ -64,7 +204,7 @@ func TestClaudeToOpenAIRequestPreservesSignedThinkingContent(t *testing.T) { { Type: "thinking", Thinking: &thinking, - Signature: signature, + Signature: &signature, }, { Type: "tool_use", @@ -169,7 +309,8 @@ func TestResponseOpenAI2ClaudePreservesReasoningBeforeToolUse(t *testing.T) { require.Equal(t, "thinking", claudeResponse.Content[0].Type) require.NotNil(t, claudeResponse.Content[0].Thinking) require.Equal(t, reasoning, *claudeResponse.Content[0].Thinking) - require.Equal(t, opaque, claudeResponse.Content[0].Signature) + require.NotNil(t, claudeResponse.Content[0].Signature) + require.Equal(t, opaque, *claudeResponse.Content[0].Signature) require.Equal(t, "tool_use", claudeResponse.Content[1].Type) require.Equal(t, "call_123", claudeResponse.Content[1].Id) require.Equal(t, "read", claudeResponse.Content[1].Name)