// Package anthropic implements the go-llm v2 provider interface for Anthropic. package anthropic import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "strings" "gitea.stevedudenhoeffer.com/steve/go-llm/v2/internal/imageutil" "gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider" anth "github.com/liushuangls/go-anthropic/v2" ) // Provider implements the provider.Provider interface for Anthropic. type Provider struct { apiKey string } // New creates a new Anthropic provider. func New(apiKey string) *Provider { return &Provider{apiKey: apiKey} } // Complete performs a non-streaming completion. func (p *Provider) Complete(ctx context.Context, req provider.Request) (provider.Response, error) { cl := anth.NewClient(p.apiKey) anthReq := p.buildRequest(req) resp, err := cl.CreateMessages(ctx, anthReq) if err != nil { return provider.Response{}, fmt.Errorf("anthropic completion error: %w", err) } return p.convertResponse(resp), nil } // Stream performs a streaming completion. func (p *Provider) Stream(ctx context.Context, req provider.Request, events chan<- provider.StreamEvent) error { cl := anth.NewClient(p.apiKey) anthReq := p.buildRequest(req) resp, err := cl.CreateMessagesStream(ctx, anth.MessagesStreamRequest{ MessagesRequest: anthReq, OnContentBlockDelta: func(data anth.MessagesEventContentBlockDeltaData) { if data.Delta.Type == "text_delta" && data.Delta.Text != nil { events <- provider.StreamEvent{ Type: provider.StreamEventText, Text: *data.Delta.Text, } } }, }) if err != nil { return fmt.Errorf("anthropic stream error: %w", err) } result := p.convertResponse(resp) events <- provider.StreamEvent{ Type: provider.StreamEventDone, Response: &result, } return nil } func (p *Provider) buildRequest(req provider.Request) anth.MessagesRequest { anthReq := anth.MessagesRequest{ Model: anth.Model(req.Model), MaxTokens: 4096, } if req.MaxTokens != nil { anthReq.MaxTokens = *req.MaxTokens } var msgs []anth.Message var systemText string // sourceLanding[srcIdx] = (msgIndex, blockIndex) of the LAST content block // corresponding to that source message, after merging. System-role source // messages map to {-1, -1} since they don't appear in msgs. type landing struct{ msg, block int } sourceLanding := make([]landing, len(req.Messages)) for i := range sourceLanding { sourceLanding[i] = landing{-1, -1} } for srcIdx, msg := range req.Messages { if msg.Role == "system" { if len(systemText) > 0 { systemText += "\n" } systemText += msg.Content continue } if msg.Role == "tool" { // Tool results in Anthropic format - use the helper toolUseID := msg.ToolCallID content := msg.Content isError := false newMsg := anth.Message{ Role: anth.RoleUser, Content: []anth.MessageContent{ { Type: anth.MessagesContentTypeToolResult, MessageContentToolResult: &anth.MessageContentToolResult{ ToolUseID: &toolUseID, Content: []anth.MessageContent{ { Type: anth.MessagesContentTypeText, Text: &content, }, }, IsError: &isError, }, }, }, } // Tool-result messages bypass the role-merge logic — they always // create a new msgs entry. Preserve that. msgs = append(msgs, newMsg) sourceLanding[srcIdx] = landing{ msg: len(msgs) - 1, block: len(newMsg.Content) - 1, } continue } role := anth.RoleUser if msg.Role == "assistant" { role = anth.RoleAssistant } m := anth.Message{ Role: role, Content: []anth.MessageContent{}, } if msg.Content != "" { m.Content = append(m.Content, anth.MessageContent{ Type: anth.MessagesContentTypeText, Text: &msg.Content, }) } // Handle tool calls in assistant messages for _, tc := range msg.ToolCalls { var input json.RawMessage if tc.Arguments != "" { input = json.RawMessage(tc.Arguments) } else { input = json.RawMessage("{}") } m.Content = append(m.Content, anth.MessageContent{ Type: anth.MessagesContentTypeToolUse, MessageContentToolUse: &anth.MessageContentToolUse{ ID: tc.ID, Name: tc.Name, Input: input, }, }) } // Handle images for _, img := range msg.Images { if role == anth.RoleAssistant { role = anth.RoleUser m.Role = anth.RoleUser } if img.Base64 != "" { b64 := img.Base64 contentType := img.ContentType // Compress if > 5MiB raw, err := base64.StdEncoding.DecodeString(b64) if err == nil && len(raw) >= 5242880 { compressed, mime, cerr := imageutil.CompressImage(b64, 5*1024*1024) if cerr == nil { b64 = compressed contentType = mime } } m.Content = append(m.Content, anth.NewImageMessageContent( anth.NewMessageContentSource( anth.MessagesContentSourceTypeBase64, contentType, b64, ))) } else if img.URL != "" { // Download and convert to base64 (Anthropic doesn't support URLs directly) resp, err := http.Get(img.URL) if err != nil { continue } data, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { continue } contentType := resp.Header.Get("Content-Type") b64 := base64.StdEncoding.EncodeToString(data) m.Content = append(m.Content, anth.NewImageMessageContent( anth.NewMessageContentSource( anth.MessagesContentSourceTypeBase64, contentType, b64, ))) } } // Audio is not supported by Anthropic — skip silently. // Merge consecutive same-role messages (Anthropic requires alternating). if len(msgs) > 0 && msgs[len(msgs)-1].Role == role { // Track the landing BEFORE mutating: the source message lands in // the existing last msgs entry, and its last block is at the // current end-of-content plus len(m.Content)-1. existingEnd := len(msgs[len(msgs)-1].Content) msgs[len(msgs)-1].Content = append(msgs[len(msgs)-1].Content, m.Content...) sourceLanding[srcIdx] = landing{ msg: len(msgs) - 1, block: existingEnd + len(m.Content) - 1, } } else { msgs = append(msgs, m) sourceLanding[srcIdx] = landing{ msg: len(msgs) - 1, block: len(m.Content) - 1, } } } for _, tool := range req.Tools { anthReq.Tools = append(anthReq.Tools, anth.ToolDefinition{ Name: tool.Name, Description: tool.Description, InputSchema: tool.Schema, }) } anthReq.Messages = msgs if systemText != "" { anthReq.MultiSystem = []anth.MessageSystemPart{ anth.NewSystemMessagePart(systemText), } } if req.Temperature != nil { f := float32(*req.Temperature) anthReq.Temperature = &f } if req.TopP != nil { f := float32(*req.TopP) anthReq.TopP = &f } if len(req.Stop) > 0 { anthReq.StopSequences = req.Stop } // Apply cache_control markers from hints. if req.CacheHints != nil { h := req.CacheHints if h.CacheTools && len(anthReq.Tools) > 0 { anthReq.Tools[len(anthReq.Tools)-1].CacheControl = &anth.MessageCacheControl{ Type: anth.CacheControlTypeEphemeral, } } if h.CacheSystem && len(anthReq.MultiSystem) > 0 { anthReq.MultiSystem[len(anthReq.MultiSystem)-1].CacheControl = &anth.MessageCacheControl{ Type: anth.CacheControlTypeEphemeral, } } if h.LastCacheableMessageIndex >= 0 && h.LastCacheableMessageIndex < len(sourceLanding) { land := sourceLanding[h.LastCacheableMessageIndex] if land.msg >= 0 && land.msg < len(anthReq.Messages) { blocks := anthReq.Messages[land.msg].Content if land.block >= 0 && land.block < len(blocks) { blocks[land.block].CacheControl = &anth.MessageCacheControl{ Type: anth.CacheControlTypeEphemeral, } anthReq.Messages[land.msg].Content = blocks } } } } return anthReq } func (p *Provider) convertResponse(resp anth.MessagesResponse) provider.Response { var res provider.Response var textParts []string for _, block := range resp.Content { switch block.Type { case anth.MessagesContentTypeText: if block.Text != nil { textParts = append(textParts, *block.Text) } case anth.MessagesContentTypeToolUse: if block.MessageContentToolUse != nil { args, _ := json.Marshal(block.MessageContentToolUse.Input) res.ToolCalls = append(res.ToolCalls, provider.ToolCall{ ID: block.MessageContentToolUse.ID, Name: block.MessageContentToolUse.Name, Arguments: string(args), }) } } } res.Text = strings.Join(textParts, "") res.Usage = &provider.Usage{ InputTokens: resp.Usage.InputTokens, OutputTokens: resp.Usage.OutputTokens, TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens, } details := map[string]int{} if resp.Usage.CacheCreationInputTokens > 0 { details[provider.UsageDetailCacheCreationTokens] = resp.Usage.CacheCreationInputTokens } if resp.Usage.CacheReadInputTokens > 0 { details[provider.UsageDetailCachedInputTokens] = resp.Usage.CacheReadInputTokens } if len(details) > 0 { res.Usage.Details = details } return res }