summaryrefslogtreecommitdiff
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
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
-rw-r--r--internal/cli/run.go16
-rw-r--r--internal/cli/serve.go17
-rw-r--r--internal/config/config.go33
-rw-r--r--internal/config/config_test.go12
-rw-r--r--internal/executor/anthropic_routing_test.go87
-rw-r--r--internal/provider/anthropic/anthropic.go365
-rw-r--r--internal/provider/anthropic/anthropic_test.go416
-rw-r--r--internal/provider/anthropic/pricing.go70
-rw-r--r--internal/retry/backoff.go11
-rw-r--r--internal/retry/backoff_test.go21
10 files changed, 1031 insertions, 17 deletions
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.<name>] 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.<name>] 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) {