package llm import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" applogger "github.com/gochat/gochat/pkg/logger" ) // AnthropicProvider implements the Provider interface using Anthropic's Claude API. // Claude API has a different format from OpenAI: messages use content blocks, // system prompt is a top-level parameter, and streaming uses SSE with different event types. type AnthropicProvider struct { apiKey string baseURL string model string embedModel string // Anthropic doesn't offer embeddings; we delegate to OpenAI httpClient *http.Client maxRetries int } // AnthropicProviderConfig holds configuration for creating an AnthropicProvider. type AnthropicProviderConfig struct { APIKey string BaseURL string // defaults to "https://api.anthropic.com" Model string // defaults to "claude-sonnet-4-20250514" EmbedModel string // not used (Anthropic has no embeddings API); kept for interface compat MaxRetries int // defaults to 3 Timeout int // HTTP timeout in seconds, defaults to 60 } // NewAnthropicProvider creates a new AnthropicProvider. func NewAnthropicProvider(cfg AnthropicProviderConfig) *AnthropicProvider { if cfg.BaseURL == "" { cfg.BaseURL = "https://api.anthropic.com" } cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/") if cfg.Model == "" { cfg.Model = "claude-sonnet-4-20250514" } if cfg.MaxRetries == 0 { cfg.MaxRetries = 3 } if cfg.Timeout == 0 { cfg.Timeout = 60 } return &AnthropicProvider{ apiKey: cfg.APIKey, baseURL: cfg.BaseURL, model: cfg.Model, embedModel: cfg.EmbedModel, httpClient: &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second}, maxRetries: cfg.MaxRetries, } } // anthropicRequest is the request body for Anthropic's messages API. type anthropicRequest struct { Model string `json:"model"` MaxTokens int `json:"max_tokens"` System string `json:"system,omitempty"` Messages []anthropicMsg `json:"messages"` Temperature float64 `json:"temperature,omitempty"` Tools []anthropicTool `json:"tools,omitempty"` Stream bool `json:"stream,omitempty"` } type anthropicMsg struct { Role string `json:"role"` Content string `json:"content"` // simplified: text content only } type anthropicTool struct { Name string `json:"name"` Description string `json:"description"` InputSchema map[string]interface{} `json:"input_schema"` } // anthropicResponse is the response from Anthropic's messages API. type anthropicResponse struct { ID string `json:"id"` Type string `json:"type"` Role string `json:"role"` Content []struct { Type string `json:"type"` Text string `json:"text,omitempty"` } `json:"content"` Model string `json:"model"` StopReason string `json:"stop_reason"` Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` } `json:"usage"` } // ChatCompletion sends a chat completion request to the Anthropic Claude API. func (p *AnthropicProvider) ChatCompletion(ctx context.Context, req ChatRequest) (*ChatResponse, error) { if req.Model == "" { req.Model = p.model } // Convert OpenAI-format messages to Anthropic format // First message with role "system" becomes the top-level system param var systemPrompt string var anthropicMsgs []anthropicMsg for _, msg := range req.Messages { if msg.Role == "system" { if systemPrompt == "" { systemPrompt = msg.Content } else { systemPrompt += "\n" + msg.Content } continue } anthropicMsgs = append(anthropicMsgs, anthropicMsg{ Role: msg.Role, Content: msg.Content, }) } maxTokens := req.MaxTokens if maxTokens == 0 { maxTokens = 1024 } body := anthropicRequest{ Model: req.Model, MaxTokens: maxTokens, System: systemPrompt, Messages: anthropicMsgs, Temperature: req.Temperature, } // Convert tools if present for _, td := range req.Tools { body.Tools = append(body.Tools, anthropicTool{ Name: td.Function.Name, Description: td.Function.Description, InputSchema: td.Function.Parameters, }) } bodyBytes, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("marshal anthropic request: %w", err) } respBody, err := p.doRequestWithRetry(ctx, "/v1/messages", bodyBytes) if err != nil { return nil, fmt.Errorf("anthropic chat completion: %w", err) } var aResp anthropicResponse if err := json.Unmarshal(respBody, &aResp); err != nil { return nil, fmt.Errorf("unmarshal anthropic response: %w", err) } // Convert to standard ChatResponse content := "" for _, block := range aResp.Content { if block.Type == "text" { content += block.Text } } return &ChatResponse{ ID: aResp.ID, Object: "chat.completion", Created: time.Now().Unix(), Model: aResp.Model, Choices: []ChatChoice{ { Index: 0, Message: ChatMessage{Role: "assistant", Content: content}, FinishReason: aResp.StopReason, }, }, Usage: TokenUsage{ PromptTokens: aResp.Usage.InputTokens, CompletionTokens: aResp.Usage.OutputTokens, TotalTokens: aResp.Usage.InputTokens + aResp.Usage.OutputTokens, }, }, nil } // ChatCompletionStream sends a streaming chat completion request. // Anthropic uses SSE with event types: message_start, content_block_start, // content_block_delta, content_block_stop, message_stop. func (p *AnthropicProvider) ChatCompletionStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk) error) error { if req.Model == "" { req.Model = p.model } req.Stream = true var systemPrompt string var anthropicMsgs []anthropicMsg for _, msg := range req.Messages { if msg.Role == "system" { if systemPrompt == "" { systemPrompt = msg.Content } else { systemPrompt += "\n" + msg.Content } continue } anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: msg.Role, Content: msg.Content}) } maxTokens := req.MaxTokens if maxTokens == 0 { maxTokens = 1024 } body := anthropicRequest{ Model: req.Model, MaxTokens: maxTokens, System: systemPrompt, Messages: anthropicMsgs, Temperature: req.Temperature, Stream: true, } bodyBytes, err := json.Marshal(body) if err != nil { return fmt.Errorf("marshal anthropic stream request: %w", err) } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/v1/messages", bytes.NewReader(bodyBytes)) if err != nil { return fmt.Errorf("create stream request: %w", err) } p.setHeaders(httpReq) httpResp, err := p.httpClient.Do(httpReq) if err != nil { return fmt.Errorf("stream request: %w", err) } defer httpResp.Body.Close() if httpResp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(httpResp.Body) return fmt.Errorf("stream request status %d: %s", httpResp.StatusCode, string(respBody)) } // Parse Anthropic SSE format reader := newSSEReader(httpResp.Body) for { event, err := reader.Next() if err != nil { return fmt.Errorf("read SSE: %w", err) } if event == nil { return nil // stream ended } if event.Type != "message" || event.Data == "" { continue } // Parse Anthropic event data var delta struct { Type string `json:"type"` Delta struct { Type string `json:"type"` Text string `json:"text"` } `json:"delta"` } if err := json.Unmarshal([]byte(event.Data), &delta); err != nil { continue } if delta.Type == "content_block_delta" && delta.Delta.Text != "" { chunk := StreamChunk{ Model: req.Model, Choices: []StreamChoice{ {Index: 0, Delta: StreamDelta{Content: delta.Delta.Text}}, }, } if err := onChunk(chunk); err != nil { return fmt.Errorf("chunk callback: %w", err) } } if delta.Type == "message_stop" { return nil } } } // CreateEmbedding — Anthropic does not offer an embeddings API. // This delegates to an OpenAI-compatible endpoint if configured, // otherwise returns an error. func (p *AnthropicProvider) CreateEmbedding(ctx context.Context, req EmbeddingRequest) (*EmbeddingResponse, error) { return nil, fmt.Errorf("anthropic does not support embeddings; configure an OpenAI-compatible embedding provider") } // doRequestWithRetry performs an HTTP request with retry logic. func (p *AnthropicProvider) doRequestWithRetry(ctx context.Context, path string, body []byte) ([]byte, error) { var lastErr error for attempt := 0; attempt <= p.maxRetries; attempt++ { if attempt > 0 { backoff := time.Duration(1<= 400 { return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) } return respBody, nil } // setHeaders sets Anthropic-specific headers. func (p *AnthropicProvider) setHeaders(req *http.Request) { req.Header.Set("Content-Type", "application/json") req.Header.Set("x-api-key", p.apiKey) req.Header.Set("anthropic-version", "2023-06-01") }