From e38f673edc7428c0c836c39f40707cb681defb14 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Fri, 3 Jul 2026 09:03:01 +0000 Subject: 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 Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs --- internal/provider/anthropic/anthropic.go | 365 +++++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 internal/provider/anthropic/anthropic.go (limited to 'internal/provider/anthropic/anthropic.go') 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) +} -- cgit v1.2.3