summaryrefslogtreecommitdiff
path: root/internal/provider/anthropic/anthropic.go
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 09:03:01 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 09:03:13 +0000
commite38f673edc7428c0c836c39f40707cb681defb14 (patch)
tree97a704b2d83b9d572ae67d057315a6781ddf1470 /internal/provider/anthropic/anthropic.go
parentbbd58e8d77f7c996add29b69f600e835a922542e (diff)
feat(provider): add native Anthropic Messages API adapter (Phase 2)
Adds internal/provider/anthropic, the first genuinely new provider.Provider implementation on top of Phase 1's provider-neutral tool-use loop, alongside (not replacing) the existing Docker/CLI-subprocess ContainerRunner path for the "claude" agent type: - internal/provider/anthropic: translates the neutral ChatRequest/ChatResponse shape to/from Anthropic's Messages API content-block format (system as a top-level field, tool_use/tool_result blocks, no "tool" role -- tool results become user-role messages), with a per-model-prefix pricing table for CostUSD - internal/retry: IsRateLimitError additively extended to recognize Anthropic's rate_limit_error/overloaded_error/529 shapes - internal/config: RunnersConfig.Anthropic/AnthropicEnabled() gate - internal/cli/serve.go, run.go: register runners["anthropic"] as a NativeRunner when [providers.anthropic].api_key is set and enabled -- tracked as a distinct executions.agent="anthropic" budget bucket, separate from the CLI-subprocess "claude" runner even though both bill the same Anthropic account go build/vet/test -race all pass. No live Anthropic API key is available in this environment, so verification is via fake-httptest-server adapter tests (12 cases, incl. multi-turn tool_result round-trip and rate-limit error matching) plus a Pool/NativeRunner routing test proving agent.type: "anthropic" actually reaches the new provider. Live end-to-end verification against the real API is a follow-up once a key is configured. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'internal/provider/anthropic/anthropic.go')
-rw-r--r--internal/provider/anthropic/anthropic.go365
1 files changed, 365 insertions, 0 deletions
diff --git a/internal/provider/anthropic/anthropic.go b/internal/provider/anthropic/anthropic.go
new file mode 100644
index 0000000..57966fc
--- /dev/null
+++ b/internal/provider/anthropic/anthropic.go
@@ -0,0 +1,365 @@
+// 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)
+}