package ai import ( "bufio" "bytes" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" ) const ( defaultModel = "claude-sonnet-4-20250514" anthropicVersion = "2023-06-01" apiBaseURL = "https://api.anthropic.com/v1/messages" ) // ClaudeClient is the HTTP client for the Anthropic Messages API. type ClaudeClient struct { oauth *OAuthManager apiKey string // fallback for non-OAuth users model string httpClient *http.Client } // NewClaudeClient creates a client that authenticates via OAuth (primary) or API key (fallback). func NewClaudeClient(oauth *OAuthManager, model string) *ClaudeClient { if model == "" { model = defaultModel } return &ClaudeClient{ oauth: oauth, model: model, httpClient: &http.Client{}, } } // SetAPIKey sets a fallback API key for users without Max subscriptions. func (c *ClaudeClient) SetAPIKey(key string) { c.apiKey = key } // apiRequest is the JSON body sent to the Messages API. type apiRequest struct { Model string `json:"model"` Messages []Message `json:"messages"` MaxTokens int `json:"max_tokens"` Stream bool `json:"stream"` System string `json:"system,omitempty"` Tools []Tool `json:"tools,omitempty"` } // SendMessage sends a request to the Messages API with streaming enabled. // It returns a channel of StreamEvents that the caller reads until the channel is closed. func (c *ClaudeClient) SendMessage(messages []Message, tools []Tool, systemPrompt string) (<-chan StreamEvent, error) { reqBody := apiRequest{ Model: c.model, Messages: messages, MaxTokens: 8192, Stream: true, System: systemPrompt, Tools: tools, } jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } req, err := http.NewRequest("POST", apiBaseURL, bytes.NewReader(jsonData)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("anthropic-version", anthropicVersion) if err := c.setAuthHeader(req); err != nil { return nil, fmt.Errorf("set auth header: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) resp.Body.Close() return nil, fmt.Errorf("API returned %d: %s", resp.StatusCode, string(body)) } ch := make(chan StreamEvent, 64) go c.parseSSEStream(resp.Body, ch) return ch, nil } // setAuthHeader sets the appropriate authorization header on the request. func (c *ClaudeClient) setAuthHeader(req *http.Request) error { // Try OAuth first if c.oauth != nil && c.oauth.IsAuthenticated() { token, err := c.oauth.GetAccessToken() if err == nil { req.Header.Set("Authorization", "Bearer "+token) return nil } slog.Warn("oauth token failed, falling back to api key", "error", err) } // Fallback to API key if c.apiKey != "" { req.Header.Set("x-api-key", c.apiKey) return nil } return fmt.Errorf("no authentication method available — log in via OAuth or set an API key") } // parseSSEStream reads the SSE response body and sends StreamEvents on the channel. func (c *ClaudeClient) parseSSEStream(body io.ReadCloser, ch chan<- StreamEvent) { defer body.Close() defer close(ch) scanner := bufio.NewScanner(body) var currentEventType string var currentToolID string var currentToolName string for scanner.Scan() { line := scanner.Text() if line == "" { continue } if strings.HasPrefix(line, "event: ") { currentEventType = strings.TrimPrefix(line, "event: ") continue } if !strings.HasPrefix(line, "data: ") { continue } data := strings.TrimPrefix(line, "data: ") switch currentEventType { case "content_block_start": var block struct { Index int `json:"index"` ContentBlock struct { Type string `json:"type"` ID string `json:"id"` Name string `json:"name"` Text string `json:"text"` Input string `json:"input"` } `json:"content_block"` } if err := json.Unmarshal([]byte(data), &block); err != nil { slog.Warn("failed to parse content_block_start", "error", err) continue } if block.ContentBlock.Type == "tool_use" { currentToolID = block.ContentBlock.ID currentToolName = block.ContentBlock.Name ch <- StreamEvent{ Type: "tool_use_start", ToolID: currentToolID, ToolName: currentToolName, } } else if block.ContentBlock.Type == "text" && block.ContentBlock.Text != "" { ch <- StreamEvent{Type: "text_delta", Data: block.ContentBlock.Text} } case "content_block_delta": var delta struct { Delta struct { Type string `json:"type"` Text string `json:"text"` PartialJSON string `json:"partial_json"` } `json:"delta"` } if err := json.Unmarshal([]byte(data), &delta); err != nil { slog.Warn("failed to parse content_block_delta", "error", err) continue } if delta.Delta.Type == "text_delta" { ch <- StreamEvent{Type: "text_delta", Data: delta.Delta.Text} } else if delta.Delta.Type == "input_json_delta" { ch <- StreamEvent{ Type: "tool_use_delta", Data: delta.Delta.PartialJSON, ToolID: currentToolID, ToolName: currentToolName, } } case "content_block_stop": // nothing to do; tool input is accumulated by the caller case "message_delta": // contains stop_reason and usage; emit usage info ch <- StreamEvent{Type: "done", Data: data} case "message_stop": // end of message stream return case "message_start": // contains message metadata; could extract usage but we handle it at message_delta case "ping": // heartbeat; ignore case "error": ch <- StreamEvent{Type: "error", Data: data} return } } if err := scanner.Err(); err != nil { ch <- StreamEvent{Type: "error", Data: err.Error()} } } // BuildRequestBody creates the JSON request body for testing purposes. func BuildRequestBody(messages []Message, tools []Tool, systemPrompt, model string) ([]byte, error) { if model == "" { model = defaultModel } req := apiRequest{ Model: model, Messages: messages, MaxTokens: 8192, Stream: true, System: systemPrompt, Tools: tools, } return json.Marshal(req) } // ParseSSELine parses a single SSE data line. Returns the event type and data payload. func ParseSSELine(line string) (eventType, data string) { if strings.HasPrefix(line, "event: ") { return "event", strings.TrimPrefix(line, "event: ") } if strings.HasPrefix(line, "data: ") { return "data", strings.TrimPrefix(line, "data: ") } return "", "" }