// Package anthropic implements provider.Provider against Anthropic's native // Messages API (https://api.anthropic.com/v1/messages) — the first // genuinely new backend added on top of Phase 1's provider-neutral // tool-use loop (internal/agentloop), replacing CLI-subprocess invocation // as an *additional* execution path alongside the existing Docker-based // ContainerRunner (which still shells out to the `claude` CLI for the // "claude"/"gemini" agent types). // // Unlike internal/provider/openaicompat (which wraps the OpenAI-compatible // internal/llm.Client unchanged), this package talks Anthropic's wire // format directly, which is structurally different from OpenAI's: // // - The system prompt is a top-level "system" string field, not a // role:"system" message. // - Message content is an array of typed blocks ({"type":"text",...}, // {"type":"tool_use",...}, {"type":"tool_result",...}) rather than a // flat string plus a separate tool_calls array. // - Anthropic has no "tool" role: a turn carrying tool results // (provider.Message.ToolResults) becomes a user-role message whose // content is a list of tool_result blocks. // - Tools are declared with "input_schema", not "parameters". package anthropic import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "github.com/thepeterstone/claudomator/internal/provider" ) // DefaultEndpoint is Anthropic's native API base, used when Provider.Endpoint // is empty. const DefaultEndpoint = "https://api.anthropic.com" // APIVersion is the Anthropic Messages API version this adapter speaks, sent // as the required "anthropic-version" header. const APIVersion = "2023-06-01" // DefaultTimeout is used when New is called with a non-positive timeout. // Native Claude tasks can run long tool-use loops, so this is generous // relative to typical HTTP client defaults. const DefaultTimeout = 120 * time.Second // defaultMaxTokens is sent when a ChatRequest doesn't specify MaxTokens. // Anthropic's Messages API requires a positive max_tokens on every request // (unlike OpenAI-compatible APIs, where it's optional), so agentloop.Loop // requests that leave Agent.MaxTokens unset (0) need a server-side default // rather than failing with a 400. const defaultMaxTokens = 4096 // Provider implements provider.Provider against the Anthropic Messages API. type Provider struct { APIKey string Endpoint string HTTPClient *http.Client } var _ provider.Provider = (*Provider)(nil) // New returns a Provider configured with apiKey. endpoint defaults to // DefaultEndpoint when empty (the native Anthropic API base — most callers // should leave this blank; an override exists only for testing or for // pointing at a compatible proxy). timeout configures the underlying HTTP // client and defaults to DefaultTimeout when non-positive. func New(apiKey, endpoint string, timeout time.Duration) *Provider { if endpoint == "" { endpoint = DefaultEndpoint } if timeout <= 0 { timeout = DefaultTimeout } return &Provider{ APIKey: apiKey, Endpoint: endpoint, HTTPClient: &http.Client{Timeout: timeout}, } } // Name implements provider.Provider. func (p *Provider) Name() string { return "anthropic" } // Chat implements provider.Provider by translating req into an Anthropic // Messages API request, performing the HTTP call, and translating the // response (or error) back into provider-neutral shape. func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest) (*provider.ChatResponse, error) { if p == nil || p.APIKey == "" { return nil, fmt.Errorf("anthropic: no API key configured") } wireReq := toWireRequest(req) body, err := json.Marshal(wireReq) if err != nil { return nil, fmt.Errorf("anthropic: marshal request: %w", err) } endpoint := strings.TrimRight(p.Endpoint, "/") + "/v1/messages" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("anthropic: build http request: %w", err) } httpReq.Header.Set("content-type", "application/json") httpReq.Header.Set("x-api-key", p.APIKey) httpReq.Header.Set("anthropic-version", APIVersion) client := p.HTTPClient if client == nil { client = &http.Client{Timeout: DefaultTimeout} } httpResp, err := client.Do(httpReq) if err != nil { return nil, fmt.Errorf("anthropic: http: %w", err) } defer httpResp.Body.Close() raw, err := io.ReadAll(httpResp.Body) if err != nil { return nil, fmt.Errorf("anthropic: read response: %w", err) } if httpResp.StatusCode >= 400 { return nil, errFromStatus(httpResp.StatusCode, raw) } var wireResp messagesResponse if err := json.Unmarshal(raw, &wireResp); err != nil { return nil, fmt.Errorf("anthropic: decode response: %w", err) } return fromWireResponse(&wireResp), nil } // --- request/response wire types (Anthropic Messages API) --- type wireRequest struct { Model string `json:"model"` MaxTokens int `json:"max_tokens"` System string `json:"system,omitempty"` Messages []wireMessage `json:"messages"` Tools []wireTool `json:"tools,omitempty"` Temperature *float64 `json:"temperature,omitempty"` } type wireMessage struct { Role string `json:"role"` Content []wireContent `json:"content"` } // wireContent is a single Anthropic content block. Only the fields relevant // to the block's Type are populated on the way out; encoding/json's // "omitempty" drops the rest. On the way in (response parsing) only "text" // and "tool_use" blocks are ever produced by the API in a non-streaming // Messages response, so those are the only two branches fromWireResponse // handles. type wireContent struct { Type string `json:"type"` // text blocks Text string `json:"text,omitempty"` // tool_use blocks (assistant turns, request and response) ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Input json.RawMessage `json:"input,omitempty"` // tool_result blocks (user turns, request only) ToolUseID string `json:"tool_use_id,omitempty"` Content string `json:"content,omitempty"` IsError bool `json:"is_error,omitempty"` } type wireTool struct { Name string `json:"name"` Description string `json:"description,omitempty"` InputSchema map[string]any `json:"input_schema,omitempty"` } type messagesResponse struct { Content []wireContent `json:"content"` StopReason string `json:"stop_reason"` Model string `json:"model"` Usage wireUsage `json:"usage"` } type wireUsage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` } // wireErrorEnvelope is Anthropic's error response shape: // // {"type":"error","error":{"type":"rate_limit_error","message":"..."}} type wireErrorEnvelope struct { Type string `json:"type"` Error struct { Type string `json:"type"` Message string `json:"message"` } `json:"error"` } // --- translation: provider-neutral -> Anthropic wire shape --- // toWireRequest translates a provider-neutral ChatRequest into the // Anthropic Messages API request shape. func toWireRequest(req provider.ChatRequest) *wireRequest { maxTokens := req.MaxTokens if maxTokens <= 0 { maxTokens = defaultMaxTokens } messages := make([]wireMessage, 0, len(req.Messages)) for _, m := range req.Messages { if wm := toWireMessage(m); wm != nil { messages = append(messages, *wm) } } var tools []wireTool if len(req.Tools) > 0 { tools = make([]wireTool, 0, len(req.Tools)) for _, ts := range req.Tools { tools = append(tools, wireTool{ Name: ts.Name, Description: ts.Description, InputSchema: ts.ParametersJSONSchema, }) } } return &wireRequest{ Model: req.Model, MaxTokens: maxTokens, System: req.System, Messages: messages, Tools: tools, Temperature: req.Temperature, } } // toWireMessage translates a single provider-neutral Message into an // Anthropic wire message, or nil if it carries no content to send (e.g. an // assistant turn with neither text nor tool calls — Anthropic rejects // messages with an empty content array). // // The "tool" role has no Anthropic equivalent: a Message carrying // ToolResults (agentloop always emits these with Role "tool", one result // per turn today) is translated into a **user**-role message containing one // or more tool_result blocks — this is the standard Anthropic pattern for // feeding tool output back to the model. An assistant turn that requested // tool calls becomes a single message with a text block (if any) followed // by one tool_use block per call, matching how Anthropic itself returns // mixed text+tool_use content. func toWireMessage(m provider.Message) *wireMessage { if len(m.ToolResults) > 0 { content := make([]wireContent, 0, len(m.ToolResults)) for _, tr := range m.ToolResults { content = append(content, wireContent{ Type: "tool_result", ToolUseID: tr.ToolCallID, Content: tr.Content, IsError: tr.IsError, }) } return &wireMessage{Role: "user", Content: content} } role := m.Role if role != "user" && role != "assistant" { // agentloop never emits a Message with role "system" (the system // prompt travels in ChatRequest.System instead) or any other role, // but fall back to "user" defensively rather than silently // dropping content if some future caller does. role = "user" } var content []wireContent if m.Text != "" { content = append(content, wireContent{Type: "text", Text: m.Text}) } for _, tc := range m.ToolCalls { input := json.RawMessage(tc.ArgsJSON) if len(input) == 0 || !json.Valid(input) { input = json.RawMessage("{}") } content = append(content, wireContent{ Type: "tool_use", ID: tc.ID, Name: tc.Name, Input: input, }) } if len(content) == 0 { return nil } return &wireMessage{Role: role, Content: content} } // --- translation: Anthropic wire shape -> provider-neutral --- // fromWireResponse translates an Anthropic Messages API response into a // provider-neutral ChatResponse, concatenating any text blocks and // collecting tool_use blocks into ToolCalls, and computing CostUSD from the // per-model pricing table. func fromWireResponse(r *messagesResponse) *provider.ChatResponse { var text strings.Builder var calls []provider.ToolCall for _, block := range r.Content { switch block.Type { case "text": text.WriteString(block.Text) case "tool_use": args := string(block.Input) if args == "" { args = "{}" } calls = append(calls, provider.ToolCall{ ID: block.ID, Name: block.Name, ArgsJSON: args, }) } } inTok, outTok := r.Usage.InputTokens, r.Usage.OutputTokens inPerMTok, outPerMTok := pricingFor(r.Model) cost := float64(inTok)/1_000_000*inPerMTok + float64(outTok)/1_000_000*outPerMTok return &provider.ChatResponse{ Text: text.String(), ToolCalls: calls, StopReason: r.StopReason, Usage: provider.Usage{ InputTokens: inTok, OutputTokens: outTok, CostUSD: cost, }, } } // errFromStatus builds an error from a non-2xx Anthropic API response, // embedding the HTTP status code and Anthropic's own error "type" (e.g. // "rate_limit_error", "overloaded_error", "invalid_request_error") in the // message text so retry.IsRateLimitError can pattern-match rate-limit and // overload conditions. func errFromStatus(status int, body []byte) error { var env wireErrorEnvelope _ = json.Unmarshal(body, &env) errType := env.Error.Type if errType == "" { errType = "unknown_error" } msg := env.Error.Message if msg == "" { snippet := strings.TrimSpace(string(body)) if len(snippet) > 500 { snippet = snippet[:500] + "..." } msg = snippet } return fmt.Errorf("anthropic: http %d %s: %s", status, errType, msg) }