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/cli/run.go | 16 +- internal/cli/serve.go | 17 +- internal/config/config.go | 33 +- internal/config/config_test.go | 12 +- internal/executor/anthropic_routing_test.go | 87 ++++++ internal/provider/anthropic/anthropic.go | 365 ++++++++++++++++++++++ internal/provider/anthropic/anthropic_test.go | 416 ++++++++++++++++++++++++++ internal/provider/anthropic/pricing.go | 70 +++++ internal/retry/backoff.go | 11 +- internal/retry/backoff_test.go | 21 ++ 10 files changed, 1031 insertions(+), 17 deletions(-) create mode 100644 internal/executor/anthropic_routing_test.go create mode 100644 internal/provider/anthropic/anthropic.go create mode 100644 internal/provider/anthropic/anthropic_test.go create mode 100644 internal/provider/anthropic/pricing.go (limited to 'internal') diff --git a/internal/cli/run.go b/internal/cli/run.go index 1c53b1a..a5a6419 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -6,12 +6,14 @@ import ( "os" "os/signal" "syscall" + "time" + "github.com/spf13/cobra" "github.com/thepeterstone/claudomator/internal/executor" + "github.com/thepeterstone/claudomator/internal/provider/anthropic" "github.com/thepeterstone/claudomator/internal/provider/openaicompat" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" - "github.com/spf13/cobra" ) func newRunCmd() *cobra.Command { @@ -116,6 +118,18 @@ func runTasks(file string, parallel int, dryRun bool) error { } } + if pc, ok := cfg.Providers["anthropic"]; ok && pc.APIKey != "" && cfg.Runners.AnthropicEnabled() { + timeout := time.Duration(0) + if pc.TimeoutSeconds > 0 { + timeout = time.Duration(pc.TimeoutSeconds) * time.Second + } + runners["anthropic"] = &executor.NativeRunner{ + Provider: anthropic.New(pc.APIKey, pc.Endpoint, timeout), + Logger: logger, + LogDir: cfg.LogDir, + DefaultModel: pc.DefaultModel, + } + } pool := executor.NewPool(parallel, runners, store, logger) pool.Classifier = &executor.Classifier{ diff --git a/internal/cli/serve.go b/internal/cli/serve.go index d8ec6a1..d9bf609 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -10,15 +10,16 @@ import ( "syscall" "time" + "github.com/spf13/cobra" "github.com/thepeterstone/claudomator/internal/api" "github.com/thepeterstone/claudomator/internal/budget" "github.com/thepeterstone/claudomator/internal/config" "github.com/thepeterstone/claudomator/internal/executor" "github.com/thepeterstone/claudomator/internal/notify" + "github.com/thepeterstone/claudomator/internal/provider/anthropic" "github.com/thepeterstone/claudomator/internal/provider/openaicompat" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/version" - "github.com/spf13/cobra" ) func newServeCmd() *cobra.Command { @@ -150,6 +151,20 @@ func serve(addr, basePath string) error { logger.Info("local runner registered", "endpoint", cfg.LocalModel.Endpoint, "model", cfg.LocalModel.Model) } + if pc, ok := cfg.Providers["anthropic"]; ok && pc.APIKey != "" && cfg.Runners.AnthropicEnabled() { + timeout := time.Duration(0) + if pc.TimeoutSeconds > 0 { + timeout = time.Duration(pc.TimeoutSeconds) * time.Second + } + runners["anthropic"] = &executor.NativeRunner{ + Provider: anthropic.New(pc.APIKey, pc.Endpoint, timeout), + Logger: logger, + LogDir: cfg.LogDir, + DefaultModel: pc.DefaultModel, + } + logger.Info("anthropic runner registered", "default_model", pc.DefaultModel) + } + pool := executor.NewPool(cfg.MaxConcurrent, runners, store, logger) pool.Classifier = &executor.Classifier{ LLM: localClient, diff --git a/internal/config/config.go b/internal/config/config.go index 640e933..de087d6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,12 +25,12 @@ type Project struct { // uses this client. It defaults to true when Endpoint is set; users with a // slow or low-quality local model can disable it. type LocalModel struct { - Endpoint string `toml:"endpoint"` // e.g. "http://localhost:11434/v1" - Model string `toml:"model"` // e.g. "llama3.1:8b" - TimeoutSeconds int `toml:"timeout_seconds"` // default 60 - DefaultTemperature float64 `toml:"default_temperature"` // default 0.2 - APIKey string `toml:"api_key"` // optional bearer token - PreferForElaborate *bool `toml:"prefer_for_elaborate"` // pointer so default-true survives parse + Endpoint string `toml:"endpoint"` // e.g. "http://localhost:11434/v1" + Model string `toml:"model"` // e.g. "llama3.1:8b" + TimeoutSeconds int `toml:"timeout_seconds"` // default 60 + DefaultTemperature float64 `toml:"default_temperature"` // default 0.2 + APIKey string `toml:"api_key"` // optional bearer token + PreferForElaborate *bool `toml:"prefer_for_elaborate"` // pointer so default-true survives parse } // UseForElaborate returns true when elaboration should try this local model @@ -56,20 +56,31 @@ type RunnersConfig struct { Gemini *bool `toml:"gemini"` Local *bool `toml:"local"` Container *bool `toml:"container"` + // Anthropic gates the native provider.Provider-backed NativeRunner + // registered under agent type "anthropic" (internal/provider/anthropic). + // It is distinct from Claude, which gates the Docker/CLI-subprocess + // ContainerRunner for agent type "claude" — the two are separate + // execution paths (and separate budget buckets) even though both bill + // the same Anthropic account. Also requires [providers.anthropic].api_key + // to be set; Anthropic = true with no configured key has no effect. + Anthropic *bool `toml:"anthropic"` } func (r RunnersConfig) ClaudeEnabled() bool { return r.Claude == nil || *r.Claude } func (r RunnersConfig) GeminiEnabled() bool { return r.Gemini == nil || *r.Gemini } func (r RunnersConfig) LocalEnabled() bool { return r.Local == nil || *r.Local } func (r RunnersConfig) ContainerEnabled() bool { return r.Container == nil || *r.Container } +func (r RunnersConfig) AnthropicEnabled() bool { return r.Anthropic == nil || *r.Anthropic } // ProviderConfig configures a native (or OpenAI-compatible) LLM provider // backend for the multi-provider harness — see docs/api-keys-setup.md. Phase -// 1 only adds this type so config has somewhere for it to land; it is not yet -// read by runner construction (no native provider adapters exist yet besides -// the openaicompat one LocalModel already configures). Keyed by provider name -// in the [providers.] TOML table, e.g. [providers.groq], -// [providers.anthropic]. +// 1 added this type so config had somewhere for it to land, without being +// read by runner construction. Phase 2 is the first consumer: +// [providers.anthropic] (APIKey + optional Endpoint/DefaultModel/ +// TimeoutSeconds) wires internal/provider/anthropic.New into a NativeRunner +// registered under agent type "anthropic" — see internal/cli/serve.go and +// internal/cli/run.go. Keyed by provider name in the [providers.] TOML +// table, e.g. [providers.groq], [providers.anthropic]. type ProviderConfig struct { Endpoint string `toml:"endpoint"` APIKey string `toml:"api_key"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6991354..f71b9cc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -68,11 +68,14 @@ func TestRunnersConfig_DefaultsAllEnabled(t *testing.T) { if !r.ContainerEnabled() { t.Error("container should be enabled by default (nil)") } + if !r.AnthropicEnabled() { + t.Error("anthropic should be enabled by default (nil)") + } } func TestRunnersConfig_ExplicitFalseDisables(t *testing.T) { f := false - r := RunnersConfig{Claude: &f, Gemini: &f, Local: &f, Container: &f} + r := RunnersConfig{Claude: &f, Gemini: &f, Local: &f, Container: &f, Anthropic: &f} if r.ClaudeEnabled() { t.Error("explicit false should disable claude") } @@ -85,12 +88,15 @@ func TestRunnersConfig_ExplicitFalseDisables(t *testing.T) { if r.ContainerEnabled() { t.Error("explicit false should disable container") } + if r.AnthropicEnabled() { + t.Error("explicit false should disable anthropic") + } } func TestRunnersConfig_ExplicitTrueEnables(t *testing.T) { tr := true - r := RunnersConfig{Claude: &tr, Gemini: &tr, Local: &tr, Container: &tr} - if !r.ClaudeEnabled() || !r.GeminiEnabled() || !r.LocalEnabled() || !r.ContainerEnabled() { + r := RunnersConfig{Claude: &tr, Gemini: &tr, Local: &tr, Container: &tr, Anthropic: &tr} + if !r.ClaudeEnabled() || !r.GeminiEnabled() || !r.LocalEnabled() || !r.ContainerEnabled() || !r.AnthropicEnabled() { t.Error("explicit true should keep all runners enabled") } } diff --git a/internal/executor/anthropic_routing_test.go b/internal/executor/anthropic_routing_test.go new file mode 100644 index 0000000..8794dd8 --- /dev/null +++ b/internal/executor/anthropic_routing_test.go @@ -0,0 +1,87 @@ +package executor + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + + "github.com/thepeterstone/claudomator/internal/provider/anthropic" +) + +// TestPool_Submit_AnthropicAgentType_RoutesThroughAnthropicProvider is the +// NativeRunner-level confirmation (Phase 2 verification item 5) that a task +// with agent.type: "anthropic" is actually routed through the new +// internal/provider/anthropic.Provider when the pool is wired with an +// "anthropic" runner — exactly how internal/cli/serve.go and +// internal/cli/run.go register it from cfg.Providers["anthropic"] when a +// non-empty APIKey is configured. This exercises the full path: Pool.Submit +// -> execute() -> NativeRunner.Run() -> agentloop.Loop.Run() -> +// anthropic.Provider.Chat() -> HTTP POST to the (fake) Anthropic Messages +// API endpoint. +func TestPool_Submit_AnthropicAgentType_RoutesThroughAnthropicProvider(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + if r.URL.Path != "/v1/messages" { + t.Errorf("unexpected path %q", r.URL.Path) + } + if r.Header.Get("x-api-key") != "test-anthropic-key" { + t.Errorf("wrong x-api-key: %q", r.Header.Get("x-api-key")) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "content": [{"type": "text", "text": "## Summary\ndone"}], + "stop_reason": "end_turn", + "model": "claude-sonnet-5", + "usage": {"input_tokens": 12, "output_tokens": 4} + }`)) + })) + defer srv.Close() + + // Mirrors the exact construction in internal/cli/serve.go's + // cfg.Providers["anthropic"] wiring block: anthropic.New(apiKey, + // endpoint, timeout) wrapped in a NativeRunner registered under the + // "anthropic" runner-map key. + runners := map[string]Runner{ + "anthropic": &NativeRunner{ + Provider: anthropic.New("test-anthropic-key", srv.URL, 0), + Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), + LogDir: t.TempDir(), + }, + } + + store := testStore(t) + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + pool := NewPool(2, runners, store, logger) + + tk := makeTask("anthropic-routing-1") + tk.Agent.Type = "anthropic" + tk.Agent.Model = "claude-sonnet-5" + tk.Agent.SkipPlanning = true + if err := store.CreateTask(tk); err != nil { + t.Fatalf("CreateTask: %v", err) + } + + if err := pool.Submit(context.Background(), tk); err != nil { + t.Fatalf("Submit: %v", err) + } + + result := <-pool.Results() + if result.Err != nil { + t.Fatalf("expected no error, got: %v", result.Err) + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("expected exactly 1 call to the fake Anthropic server, got %d", got) + } + if result.Execution.Agent != "anthropic" { + t.Errorf("execution.Agent: want %q, got %q", "anthropic", result.Execution.Agent) + } + if result.Execution.TokensIn != 12 || result.Execution.TokensOut != 4 { + t.Errorf("tokens should come from the anthropic provider's usage: got in=%d out=%d", + result.Execution.TokensIn, result.Execution.TokensOut) + } +} 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) +} diff --git a/internal/provider/anthropic/anthropic_test.go b/internal/provider/anthropic/anthropic_test.go new file mode 100644 index 0000000..ca7e778 --- /dev/null +++ b/internal/provider/anthropic/anthropic_test.go @@ -0,0 +1,416 @@ +package anthropic + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/thepeterstone/claudomator/internal/provider" + "github.com/thepeterstone/claudomator/internal/retry" +) + +// fakeAnthropicServer replies to POST /v1/messages with the given status and +// raw JSON body, capturing the last decoded request for assertions. Modeled +// on the fake OpenAI-compatible server pattern used in +// internal/llm/client_test.go and internal/executor/nativerunner_test.go +// (fakeChatServer), adapted to the Anthropic Messages API wire shape. +func fakeAnthropicServer(t *testing.T, status int, body string, capture *wireRequest) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/messages" { + t.Errorf("unexpected path %q", r.URL.Path) + } + if r.Header.Get("x-api-key") != "test-key" { + t.Errorf("missing/wrong x-api-key header: %q", r.Header.Get("x-api-key")) + } + if r.Header.Get("anthropic-version") != APIVersion { + t.Errorf("missing/wrong anthropic-version header: %q", r.Header.Get("anthropic-version")) + } + if r.Header.Get("content-type") != "application/json" { + t.Errorf("missing/wrong content-type header: %q", r.Header.Get("content-type")) + } + if capture != nil { + if err := json.NewDecoder(r.Body).Decode(capture); err != nil { + t.Fatalf("decode request body: %v", err) + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write([]byte(body)) + })) +} + +func newTestProvider(url string) *Provider { + return New("test-key", url, 5*time.Second) +} + +// TestChat_PlainTextResponse covers a plain assistant text reply: no tool +// calls, stop_reason "end_turn", usage translated to provider.Usage with a +// non-zero CostUSD computed from the pricing table. +func TestChat_PlainTextResponse(t *testing.T) { + var got wireRequest + srv := fakeAnthropicServer(t, http.StatusOK, `{ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-5", + "content": [{"type": "text", "text": "Hello there!"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 10, "output_tokens": 5} + }`, &got) + defer srv.Close() + + p := newTestProvider(srv.URL) + resp, err := p.Chat(context.Background(), provider.ChatRequest{ + Model: "claude-sonnet-5", + System: "You are helpful.", + Messages: []provider.Message{ + {Role: "user", Text: "hi"}, + }, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Text != "Hello there!" { + t.Errorf("Text: want %q got %q", "Hello there!", resp.Text) + } + if len(resp.ToolCalls) != 0 { + t.Errorf("expected no tool calls, got %+v", resp.ToolCalls) + } + if resp.StopReason != "end_turn" { + t.Errorf("StopReason: want end_turn got %q", resp.StopReason) + } + if resp.Usage.InputTokens != 10 || resp.Usage.OutputTokens != 5 { + t.Errorf("usage tokens: got %+v", resp.Usage) + } + wantCost := float64(10)/1_000_000*3.00 + float64(5)/1_000_000*15.00 + if resp.Usage.CostUSD != wantCost { + t.Errorf("CostUSD: want %v got %v", wantCost, resp.Usage.CostUSD) + } + + // Verify the outgoing request shape: system is top-level, not a message. + if got.System != "You are helpful." { + t.Errorf("system: want %q got %q", "You are helpful.", got.System) + } + if len(got.Messages) != 1 || got.Messages[0].Role != "user" { + t.Fatalf("messages: %+v", got.Messages) + } + if len(got.Messages[0].Content) != 1 || got.Messages[0].Content[0].Type != "text" || got.Messages[0].Content[0].Text != "hi" { + t.Errorf("user content block: %+v", got.Messages[0].Content) + } + if got.MaxTokens != defaultMaxTokens { + t.Errorf("max_tokens should default to %d, got %d", defaultMaxTokens, got.MaxTokens) + } +} + +// TestChat_ToolUseResponse verifies that a response whose content mixes a +// text block and a tool_use block round-trips into ChatResponse.ToolCalls +// correctly (id/name/args), and that tools declared on the request are +// translated to Anthropic's {name, description, input_schema} shape. +func TestChat_ToolUseResponse(t *testing.T) { + var got wireRequest + srv := fakeAnthropicServer(t, http.StatusOK, `{ + "id": "msg_2", + "type": "message", + "role": "assistant", + "model": "claude-opus-4-8", + "content": [ + {"type": "text", "text": "Let me check that."}, + {"type": "tool_use", "id": "toolu_01", "name": "read_file", "input": {"path": "a.txt"}} + ], + "stop_reason": "tool_use", + "usage": {"input_tokens": 20, "output_tokens": 8} + }`, &got) + defer srv.Close() + + p := newTestProvider(srv.URL) + resp, err := p.Chat(context.Background(), provider.ChatRequest{ + Model: "claude-opus-4-8", + Messages: []provider.Message{{Role: "user", Text: "read a.txt"}}, + Tools: []provider.ToolSpec{ + { + Name: "read_file", + Description: "Read a file.", + ParametersJSONSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"path": map[string]any{"type": "string"}}, + "required": []string{"path"}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Text != "Let me check that." { + t.Errorf("Text: got %q", resp.Text) + } + if resp.StopReason != "tool_use" { + t.Errorf("StopReason: want tool_use got %q", resp.StopReason) + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("expected 1 tool call, got %d: %+v", len(resp.ToolCalls), resp.ToolCalls) + } + tc := resp.ToolCalls[0] + if tc.ID != "toolu_01" || tc.Name != "read_file" { + t.Errorf("tool call id/name: got %+v", tc) + } + var args struct { + Path string `json:"path"` + } + if err := json.Unmarshal([]byte(tc.ArgsJSON), &args); err != nil { + t.Fatalf("tool call args not valid JSON: %v (%q)", err, tc.ArgsJSON) + } + if args.Path != "a.txt" { + t.Errorf("tool call args.path: got %q", args.Path) + } + + // Verify tools were declared with input_schema (not "parameters"). + if len(got.Tools) != 1 { + t.Fatalf("expected 1 tool declared, got %d", len(got.Tools)) + } + if got.Tools[0].Name != "read_file" || got.Tools[0].Description != "Read a file." { + t.Errorf("tool declaration: %+v", got.Tools[0]) + } + if got.Tools[0].InputSchema == nil || got.Tools[0].InputSchema["type"] != "object" { + t.Errorf("tool input_schema not forwarded: %+v", got.Tools[0].InputSchema) + } +} + +// TestChat_MultiTurnWithPriorToolResult exercises the tool_result-as-a- +// user-message translation: a provider.Message carrying ToolResults (as +// agentloop.Loop emits with Role "tool" after dispatching a tool call) must +// serialize as a user-role message with tool_result content blocks — +// Anthropic has no "tool" role. Also verifies a prior assistant turn with a +// tool call round-trips into a single assistant message with a tool_use +// block matching the id used in the subsequent tool_result. +func TestChat_MultiTurnWithPriorToolResult(t *testing.T) { + var got wireRequest + srv := fakeAnthropicServer(t, http.StatusOK, `{ + "id": "msg_3", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-5", + "content": [{"type": "text", "text": "The file says hello."}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 30, "output_tokens": 6} + }`, &got) + defer srv.Close() + + p := newTestProvider(srv.URL) + _, err := p.Chat(context.Background(), provider.ChatRequest{ + Model: "claude-sonnet-5", + Messages: []provider.Message{ + {Role: "user", Text: "read a.txt"}, + { + Role: "assistant", + Text: "", + ToolCalls: []provider.ToolCall{ + {ID: "toolu_01", Name: "read_file", ArgsJSON: `{"path":"a.txt"}`}, + }, + }, + { + Role: "tool", + ToolResults: []provider.ToolResult{ + {ToolCallID: "toolu_01", Name: "read_file", Content: "hello", IsError: false}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + + if len(got.Messages) != 3 { + t.Fatalf("expected 3 wire messages, got %d: %+v", len(got.Messages), got.Messages) + } + + // Turn 2: assistant tool_use, no text block since Text was empty. + asst := got.Messages[1] + if asst.Role != "assistant" { + t.Errorf("turn 2 role: want assistant got %q", asst.Role) + } + if len(asst.Content) != 1 || asst.Content[0].Type != "tool_use" { + t.Fatalf("turn 2 content: %+v", asst.Content) + } + if asst.Content[0].ID != "toolu_01" || asst.Content[0].Name != "read_file" { + t.Errorf("turn 2 tool_use block: %+v", asst.Content[0]) + } + + // Turn 3: the tool result must be a USER-role message with a + // tool_result block (Anthropic has no "tool" role). + toolTurn := got.Messages[2] + if toolTurn.Role != "user" { + t.Errorf("tool result turn role: want user (Anthropic has no tool role) got %q", toolTurn.Role) + } + if len(toolTurn.Content) != 1 || toolTurn.Content[0].Type != "tool_result" { + t.Fatalf("tool result content: %+v", toolTurn.Content) + } + block := toolTurn.Content[0] + if block.ToolUseID != "toolu_01" { + t.Errorf("tool_use_id: want toolu_01 got %q", block.ToolUseID) + } + if block.Content != "hello" { + t.Errorf("tool_result content: want hello got %q", block.Content) + } + if block.IsError { + t.Errorf("is_error should be false") + } +} + +// TestChat_ToolResultIsError verifies IsError round-trips onto the +// tool_result block's is_error field. +func TestChat_ToolResultIsError(t *testing.T) { + var got wireRequest + srv := fakeAnthropicServer(t, http.StatusOK, `{ + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1} + }`, &got) + defer srv.Close() + + p := newTestProvider(srv.URL) + _, err := p.Chat(context.Background(), provider.ChatRequest{ + Model: "claude-sonnet-5", + Messages: []provider.Message{ + {Role: "tool", ToolResults: []provider.ToolResult{ + {ToolCallID: "toolu_x", Name: "run_bash", Content: "boom", IsError: true}, + }}, + }, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if len(got.Messages) != 1 || len(got.Messages[0].Content) != 1 { + t.Fatalf("unexpected messages: %+v", got.Messages) + } + if !got.Messages[0].Content[0].IsError { + t.Errorf("expected is_error true") + } +} + +// TestChat_RateLimitError429 verifies a 429 rate_limit_error response +// produces a Go error whose text retry.IsRateLimitError recognizes. +func TestChat_RateLimitError429(t *testing.T) { + srv := fakeAnthropicServer(t, http.StatusTooManyRequests, `{ + "type": "error", + "error": {"type": "rate_limit_error", "message": "Number of requests has exceeded your rate limit."} + }`, nil) + defer srv.Close() + + p := newTestProvider(srv.URL) + _, err := p.Chat(context.Background(), provider.ChatRequest{ + Model: "claude-sonnet-5", + Messages: []provider.Message{{Role: "user", Text: "hi"}}, + }) + if err == nil { + t.Fatal("expected an error for HTTP 429") + } + if !strings.Contains(err.Error(), "429") || !strings.Contains(err.Error(), "rate_limit_error") { + t.Errorf("error should mention 429 and rate_limit_error, got: %v", err) + } + if !retry.IsRateLimitError(err) { + t.Errorf("retry.IsRateLimitError should recognize: %v", err) + } +} + +// TestChat_OverloadedError529 verifies a 529 overloaded_error response is +// also recognized as retryable. +func TestChat_OverloadedError529(t *testing.T) { + srv := fakeAnthropicServer(t, 529, `{ + "type": "error", + "error": {"type": "overloaded_error", "message": "Overloaded"} + }`, nil) + defer srv.Close() + + p := newTestProvider(srv.URL) + _, err := p.Chat(context.Background(), provider.ChatRequest{ + Model: "claude-sonnet-5", + Messages: []provider.Message{{Role: "user", Text: "hi"}}, + }) + if err == nil { + t.Fatal("expected an error for HTTP 529") + } + if !retry.IsRateLimitError(err) { + t.Errorf("retry.IsRateLimitError should recognize overloaded_error: %v", err) + } +} + +// TestChat_InvalidRequestError400 is a non-retryable error case: confirms +// the error text carries the invalid_request_error type and is NOT +// misclassified as a rate-limit error. +func TestChat_InvalidRequestError400(t *testing.T) { + srv := fakeAnthropicServer(t, http.StatusBadRequest, `{ + "type": "error", + "error": {"type": "invalid_request_error", "message": "max_tokens: field required"} + }`, nil) + defer srv.Close() + + p := newTestProvider(srv.URL) + _, err := p.Chat(context.Background(), provider.ChatRequest{ + Model: "claude-sonnet-5", + Messages: []provider.Message{{Role: "user", Text: "hi"}}, + }) + if err == nil { + t.Fatal("expected an error for HTTP 400") + } + if !strings.Contains(err.Error(), "invalid_request_error") { + t.Errorf("error should mention invalid_request_error, got: %v", err) + } + if retry.IsRateLimitError(err) { + t.Errorf("400 invalid_request_error should NOT be classified as a rate-limit error: %v", err) + } +} + +// TestChat_NoAPIKey_Errors confirms Chat fails fast without ever making an +// HTTP request when no API key is configured. +func TestChat_NoAPIKey_Errors(t *testing.T) { + p := New("", "http://127.0.0.1:1", time.Second) + _, err := p.Chat(context.Background(), provider.ChatRequest{Model: "claude-sonnet-5"}) + if err == nil || !strings.Contains(err.Error(), "no API key") { + t.Errorf("expected 'no API key' error, got %v", err) + } +} + +// TestNew_DefaultsEndpointAndTimeout verifies New falls back to +// DefaultEndpoint/DefaultTimeout for zero-value arguments. +func TestNew_DefaultsEndpointAndTimeout(t *testing.T) { + p := New("key", "", 0) + if p.Endpoint != DefaultEndpoint { + t.Errorf("Endpoint: want %q got %q", DefaultEndpoint, p.Endpoint) + } + if p.HTTPClient.Timeout != DefaultTimeout { + t.Errorf("Timeout: want %v got %v", DefaultTimeout, p.HTTPClient.Timeout) + } +} + +// TestName returns the provider's identifier, used as the agent-type/budget +// key ("anthropic") once wired into a NativeRunner. +func TestName(t *testing.T) { + p := New("key", "", 0) + if p.Name() != "anthropic" { + t.Errorf("Name: got %q", p.Name()) + } +} + +// TestPricingFor_KnownAndUnknownModels sanity-checks the pricing table +// lookup: exact/prefix match for known models, and a non-erroring fallback +// for an unrecognized model string. +func TestPricingFor_KnownAndUnknownModels(t *testing.T) { + in, out := pricingFor("claude-opus-4-8") + if in != 5.00 || out != 25.00 { + t.Errorf("opus 4.8 pricing: got %v/%v", in, out) + } + in, out = pricingFor("claude-haiku-4-5-20251001") + if in != 1.00 || out != 5.00 { + t.Errorf("haiku 4.5 dated snapshot pricing: got %v/%v", in, out) + } + in, out = pricingFor("some-future-unknown-model") + if in != defaultPricing.InputPerMTok || out != defaultPricing.OutputPerMTok { + t.Errorf("unknown model should fall back to default pricing, got %v/%v", in, out) + } +} diff --git a/internal/provider/anthropic/pricing.go b/internal/provider/anthropic/pricing.go new file mode 100644 index 0000000..c5b5298 --- /dev/null +++ b/internal/provider/anthropic/pricing.go @@ -0,0 +1,70 @@ +package anthropic + +import "strings" + +// modelPrice is USD cost per million tokens for one model (or model family +// prefix). +type modelPrice struct { + InputPerMTok float64 + OutputPerMTok float64 +} + +// pricingTable maps a model-name *prefix* to its per-million-token pricing. +// Keyed by prefix (rather than exact model string) so dated/versioned model +// IDs (e.g. "claude-3-5-sonnet-20241022") still resolve without needing an +// entry per snapshot. Edit this table as Anthropic's pricing or model +// lineup changes — it intentionally has no other logic attached. +// +// Prices below are cached from training/skill data as of mid-2026 and are +// not fetched live; verify against https://platform.claude.com/docs/en/pricing +// before relying on them for real billing decisions. +var pricingTable = map[string]modelPrice{ + // Claude Fable 5 / Mythos family (most capable, most expensive tier). + "claude-fable-5": {InputPerMTok: 10.00, OutputPerMTok: 50.00}, + "claude-mythos-5": {InputPerMTok: 10.00, OutputPerMTok: 50.00}, + "claude-mythos-preview": {InputPerMTok: 10.00, OutputPerMTok: 50.00}, + + // Opus tier. + "claude-opus-4-8": {InputPerMTok: 5.00, OutputPerMTok: 25.00}, + "claude-opus-4-7": {InputPerMTok: 5.00, OutputPerMTok: 25.00}, + "claude-opus-4-6": {InputPerMTok: 5.00, OutputPerMTok: 25.00}, + "claude-opus-4-5": {InputPerMTok: 5.00, OutputPerMTok: 25.00}, + "claude-opus-4-1": {InputPerMTok: 5.00, OutputPerMTok: 25.00}, + "claude-opus-4-0": {InputPerMTok: 5.00, OutputPerMTok: 25.00}, + "claude-opus-4": {InputPerMTok: 5.00, OutputPerMTok: 25.00}, + "claude-3-opus": {InputPerMTok: 15.00, OutputPerMTok: 75.00}, + + // Sonnet tier. + "claude-sonnet-5": {InputPerMTok: 3.00, OutputPerMTok: 15.00}, + "claude-sonnet-4-6": {InputPerMTok: 3.00, OutputPerMTok: 15.00}, + "claude-sonnet-4-5": {InputPerMTok: 3.00, OutputPerMTok: 15.00}, + "claude-sonnet-4-0": {InputPerMTok: 3.00, OutputPerMTok: 15.00}, + "claude-sonnet-4": {InputPerMTok: 3.00, OutputPerMTok: 15.00}, + "claude-3-7-sonnet": {InputPerMTok: 3.00, OutputPerMTok: 15.00}, + "claude-3-5-sonnet": {InputPerMTok: 3.00, OutputPerMTok: 15.00}, + "claude-3-sonnet": {InputPerMTok: 3.00, OutputPerMTok: 15.00}, + + // Haiku tier. + "claude-haiku-4-5": {InputPerMTok: 1.00, OutputPerMTok: 5.00}, + "claude-3-5-haiku": {InputPerMTok: 0.80, OutputPerMTok: 4.00}, + "claude-3-haiku": {InputPerMTok: 0.25, OutputPerMTok: 1.25}, +} + +// defaultPricing is used for unrecognized models rather than erroring — +// Sonnet-tier pricing is a reasonable mid-point default. +var defaultPricing = modelPrice{InputPerMTok: 3.00, OutputPerMTok: 15.00} + +// pricingFor returns the per-million-token input/output pricing for model, +// matching the longest pricingTable prefix that model starts with. Falls +// back to defaultPricing for unrecognized models. +func pricingFor(model string) (inputPerMTok, outputPerMTok float64) { + price := defaultPricing + bestLen := -1 + for prefix, p := range pricingTable { + if len(prefix) > bestLen && strings.HasPrefix(model, prefix) { + bestLen = len(prefix) + price = p + } + } + return price.InputPerMTok, price.OutputPerMTok +} diff --git a/internal/retry/backoff.go b/internal/retry/backoff.go index b91abc4..a372b37 100644 --- a/internal/retry/backoff.go +++ b/internal/retry/backoff.go @@ -17,6 +17,12 @@ const maxBackoffDelay = 5 * time.Minute // IsRateLimitError returns true if err looks like a transient rate-limit // (e.g. HTTP 429, "too many requests", "overloaded") that is worth retrying. +// +// Patterns are additive across providers: the original set matches +// OpenAI-compatible error text (internal/llm); "rate_limit_error", +// "overloaded_error", and "529" additionally match Anthropic's Messages API +// error shape (HTTP 429 with error.type "rate_limit_error", or HTTP 529 +// with error.type "overloaded_error" — see internal/provider/anthropic). func IsRateLimitError(err error) bool { if err == nil { return false @@ -25,7 +31,10 @@ func IsRateLimitError(err error) bool { return strings.Contains(msg, "rate limit") || strings.Contains(msg, "too many requests") || strings.Contains(msg, "429") || - strings.Contains(msg, "overloaded") + strings.Contains(msg, "overloaded") || + strings.Contains(msg, "rate_limit_error") || + strings.Contains(msg, "overloaded_error") || + strings.Contains(msg, "529") } // ParseRetryAfter extracts a Retry-After duration from an error message. diff --git a/internal/retry/backoff_test.go b/internal/retry/backoff_test.go index a963fc2..d605f29 100644 --- a/internal/retry/backoff_test.go +++ b/internal/retry/backoff_test.go @@ -38,6 +38,27 @@ func TestIsRateLimitError_Overloaded(t *testing.T) { } } +func TestIsRateLimitError_AnthropicRateLimitError(t *testing.T) { + err := errors.New(`anthropic: http 429 rate_limit_error: Number of requests has exceeded your rate limit.`) + if !IsRateLimitError(err) { + t.Error("want true for Anthropic 'rate_limit_error' shape, got false") + } +} + +func TestIsRateLimitError_AnthropicOverloadedError(t *testing.T) { + err := errors.New(`anthropic: http 529 overloaded_error: Overloaded`) + if !IsRateLimitError(err) { + t.Error("want true for Anthropic 'overloaded_error' shape (HTTP 529), got false") + } +} + +func TestIsRateLimitError_HTTP529(t *testing.T) { + err := errors.New("anthropic: http 529 unexpected_error: service unavailable") + if !IsRateLimitError(err) { + t.Error("want true for '529', got false") + } +} + func TestIsRateLimitError_NonRateLimitError(t *testing.T) { err := errors.New("claude exited with error: exit status 1") if IsRateLimitError(err) { -- cgit v1.2.3