package model import ( "fmt" "strings" ) // buildAnthropicMessages splits a normalized message list into a top-level system // string and the remaining messages in Anthropic's content-block form. All // RoleSystem messages are concatenated into a single system prompt (Anthropic // carries it at the request root, not in messages); messages with no content // parts are dropped because Anthropic rejects empty content. func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) { var sys strings.Builder out := make([]anthropicMessage, 0, len(msgs)) for _, m := range msgs { if m.Role == RoleSystem { if t := messageText(m); t != "" { if sys.Len() > 0 { sys.WriteString("\n") } sys.WriteString(t) } continue } blocks, err := encodeAnthropicMessage(m) if err != nil { return "", nil, err } if len(blocks) == 0 { continue } out = append(out, anthropicMessage{Role: anthropicRole(m.Role), Content: blocks}) } return sys.String(), out, nil } // messageText concatenates the text-bearing parts of a message (system prompts // and tool-result bodies). Tool-result parts carry their body in Text, so a // RoleTool turn's content serializes correctly for OpenAI's tool message. func messageText(m Message) string { var b strings.Builder for _, part := range m.Content { if part.Type == ContentTypeText || part.Type == ContentTypeToolResult { b.WriteString(part.Text) } } return b.String() } // anthropicRole maps a normalized Role to the Anthropic role vocabulary. system // messages are extracted to the top-level system field and never reach here. func anthropicRole(r Role) string { switch r { case RoleAssistant: return "assistant" default: // user, tool and any future roles all land on the user turn; tool_result // blocks (M2) belong to the user role in Anthropic. return "user" } } // encodeAnthropicMessage turns a normalized message into Anthropic content // blocks. A RoleTool turn is ALWAYS rendered as native tool_result blocks (never // a user plain-text turn), because Anthropic requires a tool result to arrive as // a tool_result block carrying the tool_use_id and the is_error flag. func encodeAnthropicMessage(m Message) ([]any, error) { if m.Role == RoleTool { return encodeAnthropicToolResult(m) } return encodeAnthropicContent(m.Content) } // encodeAnthropicToolResult renders a RoleTool turn as tool_result blocks. Each // ContentTypeToolResult part becomes one block; legacy text parts are wrapped as // tool_result blocks too so the turn never degrades into a plain user message. // The tool_use_id falls back to the message-level ToolCallID when a part does not // carry its own. func encodeAnthropicToolResult(m Message) ([]any, error) { blocks := make([]any, 0, len(m.Content)) for _, p := range m.Content { switch p.Type { case ContentTypeToolResult, ContentTypeText: id := p.ToolCallID if id == "" { id = m.ToolCallID } blocks = append(blocks, AnthropicToolResult(id, p.Text, p.IsError)) } } if len(blocks) == 0 { blocks = append(blocks, AnthropicToolResult(m.ToolCallID, "", false)) } return blocks, nil } // encodeAnthropicContent turns normalized content parts into Anthropic content // blocks. Text / image / tool_use (assistant) parts are handled here; tool_result // parts are rendered at the message level by encodeAnthropicToolResult. func encodeAnthropicContent(parts []Content) ([]any, error) { blocks := make([]any, 0, len(parts)) for _, p := range parts { switch p.Type { case ContentTypeText: blocks = append(blocks, map[string]any{"type": "text", "text": p.Text}) case ContentTypeImage: mediaType, data, err := parseAnthropicImageData(p.ImageURL) if err != nil { return nil, err } blocks = append(blocks, map[string]any{ "type": "image", "source": map[string]any{ "type": "base64", "media_type": mediaType, "data": data, }, }) case ContentTypeToolCall: args := p.ToolArgs if args == nil { args = map[string]any{} } blocks = append(blocks, map[string]any{ "type": "tool_use", "id": p.ToolCallID, "name": p.ToolCallName, "input": args, }) case ContentTypeToolResult: blocks = append(blocks, AnthropicToolResult(p.ToolCallID, p.Text, p.IsError)) default: return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type) } } return blocks, nil } // parseAnthropicImageData extracts the media type and base64 payload from a data: // URI. Anthropic requires a base64 image source and cannot reference a remote // URL, so any other URI form is rejected as a bad request. func parseAnthropicImageData(uri string) (string, string, error) { if !strings.HasPrefix(uri, anthropicDataPrefix) { return "", "", fmt.Errorf("model: %w: anthropic image requires a data: URI, got %q", ErrBadRequest, uri) } rest := strings.TrimPrefix(uri, anthropicDataPrefix) comma := strings.Index(rest, ",") if comma < 0 { return "", "", fmt.Errorf("model: %w: malformed data: URI %q", ErrBadRequest, uri) } meta := rest[:comma] data := rest[comma+1:] mediaType := meta if idx := strings.Index(meta, ";"); idx >= 0 { mediaType = meta[:idx] } if mediaType == "" { return "", "", fmt.Errorf("model: %w: data: URI missing media type in %q", ErrBadRequest, uri) } return mediaType, data, nil } // buildAnthropicTools converts ToolSpec schemas into Anthropic tools. func buildAnthropicTools(tools []ToolSpec) []anthropicTool { if len(tools) == 0 { return nil } out := make([]anthropicTool, 0, len(tools)) for _, t := range tools { out = append(out, anthropicTool{ Name: t.Name, Description: t.Description, InputSchema: t.Parameters, }) } return out }