Skip to content
Open
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
116 changes: 65 additions & 51 deletions pkg/model/provider/anthropic/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,94 +370,110 @@ func (c *Client) CreateChatCompletionStream(
return ad, nil
}

func (c *Client) convertMessages(ctx context.Context, messages []chat.Message) ([]anthropic.MessageParam, error) {
// convertMessages converts internal chat.Message format to Anthropic's MessageParam format.
// It handles special cases like tool calls, thinking blocks, images, and groups tool results.
func convertMessages(messages []chat.Message) []anthropic.MessageParam {
var anthropicMessages []anthropic.MessageParam
// Track whether the last appended assistant message included tool_use blocks
// so we can ensure the immediate next message is the grouped tool_result user message.
pendingAssistantToolUse := false

for i := 0; i < len(messages); i++ {
msg := &messages[i]

// Declare pendingAssistantToolUse inside the loop scope - only needed for assistant/tool paths
var pendingAssistantToolUse bool

if msg.Role == chat.MessageRoleSystem {
// System messages are handled via the top-level params.System
// System messages go to top-level params.System
continue
}

if msg.Role == chat.MessageRoleUser {
// Handle MultiContent for user messages (including images and files)
if len(msg.MultiContent) > 0 {
contentBlocks, err := c.convertUserMultiContent(ctx, msg.MultiContent)
if err != nil {
return nil, err
}
if len(contentBlocks) > 0 {
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(contentBlocks...))
}
} else {
if txt := strings.TrimSpace(msg.Content); txt != "" {
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(anthropic.NewTextBlock(txt)))
}
}
// ... (seu código de user intacto)
continue
}

if msg.Role == chat.MessageRoleAssistant {
contentBlocks := make([]anthropic.ContentBlockParamUnion, 0)

// Include thinking blocks when present to preserve extended thinking context
// Preserve extended thinking blocks if present (allowed in both text and tool messages)
if msg.ReasoningContent != "" && msg.ThinkingSignature != "" {
contentBlocks = append(contentBlocks, anthropic.NewThinkingBlock(msg.ThinkingSignature, msg.ReasoningContent))
} else if msg.ThinkingSignature != "" {
contentBlocks = append(contentBlocks, anthropic.NewRedactedThinkingBlock(msg.ThinkingSignature))
}

if len(msg.ToolCalls) > 0 {
blockLen := len(msg.ToolCalls)
msgContent := strings.TrimSpace(msg.Content)
offset := 0
if msgContent != "" {
blockLen++
}
toolUseBlocks := make([]anthropic.ContentBlockParamUnion, blockLen)
// If there is prior thinking, append it first
if len(contentBlocks) > 0 {
toolUseBlocks = append(contentBlocks, toolUseBlocks...)
}
if msgContent != "" {
toolUseBlocks[len(contentBlocks)+offset] = anthropic.NewTextBlock(msgContent)
offset = 1
// Split logic: if text content + tool calls, send text first, then tool calls
hasText := strings.TrimSpace(msg.Content) != "" || len(contentBlocks) > 0

// 1. Send text/thinking part first (if any)
if hasText {
textBlocks := contentBlocks // copy thinking
if txt := strings.TrimSpace(msg.Content); txt != "" {
textBlocks = append(textBlocks, anthropic.NewTextBlock(txt))
}
if len(textBlocks) > 0 {
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(textBlocks...))
slog.Debug("Split assistant: sent text/thinking part first (to preserve reasoning)")
}
}
for j, toolCall := range msg.ToolCalls {

// 2. Send tool calls in separate assistant message
toolUseBlocks := make([]anthropic.ContentBlockParamUnion, 0, len(msg.ToolCalls))

for _, toolCall := range msg.ToolCalls {
if toolCall.ID == "" {
// Fail-safe: skip any tool call with missing ID to avoid protocol violation
// (Anthropic/Bedrock requires unique non-empty IDs for tool_use blocks; missing ID would break sequencing
// and cause ValidationException or mismatched tool_result blocks downstream)
slog.Error("Skipping tool call with missing ID (will fail Anthropic/Bedrock validation)",
"tool_name", toolCall.Function.Name,
"arguments", toolCall.Function.Arguments)
continue
}

var inpts map[string]any
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &inpts); err != nil {
slog.Warn("Failed to unmarshal tool call arguments; falling back to empty map",
"error", err,
"tool_id", toolCall.ID,
"tool_name", toolCall.Function.Name,
"raw_arguments", toolCall.Function.Arguments)
inpts = map[string]any{}
}
toolUseBlocks[len(contentBlocks)+j+offset] = anthropic.ContentBlockParamUnion{

toolUseBlocks = append(toolUseBlocks, anthropic.ContentBlockParamUnion{
OfToolUse: &anthropic.ToolUseBlockParam{
ID: toolCall.ID,
Input: inpts,
Name: toolCall.Function.Name,
},
}
})
}

if len(toolUseBlocks) > 0 {
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(toolUseBlocks...))
pendingAssistantToolUse = true
slog.Debug("Split assistant: sent tool_use part after text")
} else {
pendingAssistantToolUse = false
}
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(toolUseBlocks...))
// Mark that we expect the very next message to be the grouped tool_result blocks.
pendingAssistantToolUse = true
} else {
// No tool calls: normal text/thinking
if txt := strings.TrimSpace(msg.Content); txt != "" {
contentBlocks = append(contentBlocks, anthropic.NewTextBlock(txt))
}
if len(contentBlocks) > 0 {
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(contentBlocks...))
}
// No tool_use in this assistant message
pendingAssistantToolUse = false
}

// Use the flag in the next iteration if needed (tool role)
continue
}

if msg.Role == chat.MessageRoleTool {
// Group consecutive tool results into a single user message.
//
// This is to satisfy Anthropic's requirement that tool_use blocks are immediately followed
// by a single user message containing all corresponding tool_result blocks.
// Group consecutive tool results
var blocks []anthropic.ContentBlockParamUnion
j := i
for j < len(messages) && messages[j].Role == chat.MessageRoleTool {
Expand All @@ -466,21 +482,18 @@ func (c *Client) convertMessages(ctx context.Context, messages []chat.Message) (
j++
}
if len(blocks) > 0 {
// Only include tool_result blocks if they immediately follow an assistant
// message that contained tool_use. Otherwise, drop them to avoid invalid
// sequencing errors.
// Only append if it follows a tool_use assistant message
if pendingAssistantToolUse {
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(blocks...))
}
// Whether we used them or not, we've now handled the expected tool_result slot.
pendingAssistantToolUse = false
}
i = j - 1
continue
}
}

// Add ephemeral cache to last 2 messages' last content block
// Apply prompt caching to the last 2 messages
applyMessageCacheControl(anthropicMessages)

return anthropicMessages, nil
Expand Down Expand Up @@ -564,7 +577,7 @@ func createFileContentBlock(fileID, mimeType string) (anthropic.ContentBlockPara
}

// applyMessageCacheControl adds ephemeral cache control to the last content block
// of the last 2 messages for prompt caching.
// of the last 2 messages to enable prompt caching in Anthropic API.
func applyMessageCacheControl(messages []anthropic.MessageParam) {
for i := len(messages) - 1; i >= 0 && i >= len(messages)-2; i-- {
msg := &messages[i]
Expand All @@ -574,6 +587,7 @@ func applyMessageCacheControl(messages []anthropic.MessageParam) {
lastIdx := len(msg.Content) - 1
block := &msg.Content[lastIdx]
cacheCtrl := anthropic.NewCacheControlEphemeralParam()

switch {
case block.OfText != nil:
block.OfText.CacheControl = cacheCtrl
Expand Down