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
156 changes: 64 additions & 92 deletions pkg/model/provider/anthropic/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,136 +344,110 @@ func (c *Client) CreateChatCompletionStream(
return ad, nil
}

// 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)
if len(msg.MultiContent) > 0 {
contentBlocks := make([]anthropic.ContentBlockParamUnion, 0, len(msg.MultiContent))
for _, part := range msg.MultiContent {
if part.Type == chat.MessagePartTypeText {
if txt := strings.TrimSpace(part.Text); txt != "" {
contentBlocks = append(contentBlocks, anthropic.NewTextBlock(txt))
}
} else if part.Type == chat.MessagePartTypeImageURL && part.ImageURL != nil {
// Anthropic expects base64 image data
// Extract base64 data from data URL
if strings.HasPrefix(part.ImageURL.URL, "data:") {
parts := strings.SplitN(part.ImageURL.URL, ",", 2)
if len(parts) == 2 {
// Extract media type from data URL
mediaTypePart := parts[0]
base64Data := parts[1]

var mediaType string
switch {
case strings.Contains(mediaTypePart, "image/jpeg"):
mediaType = "image/jpeg"
case strings.Contains(mediaTypePart, "image/png"):
mediaType = "image/png"
case strings.Contains(mediaTypePart, "image/gif"):
mediaType = "image/gif"
case strings.Contains(mediaTypePart, "image/webp"):
mediaType = "image/webp"
default:
// Default to jpeg if not recognized
mediaType = "image/jpeg"
}

// Use SDK helper with proper typed source for better performance
// (avoids JSON marshal/unmarshal round trip)
contentBlocks = append(contentBlocks, anthropic.NewImageBlock(anthropic.Base64ImageSourceParam{
Data: base64Data,
MediaType: anthropic.Base64ImageSourceMediaType(mediaType),
}))
}
} else if strings.HasPrefix(part.ImageURL.URL, "http://") || strings.HasPrefix(part.ImageURL.URL, "https://") {
// Support URL-based images - Anthropic can fetch images directly from URLs
contentBlocks = append(contentBlocks, anthropic.NewImageBlock(anthropic.URLImageSourceParam{
URL: part.ImageURL.URL,
}))
}
}
}
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 @@ -482,28 +456,25 @@ func convertMessages(messages []chat.Message) []anthropic.MessageParam {
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
}

// 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 @@ -513,6 +484,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
Loading