package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"strconv"
"time"
)
const (
DefaultBaseURL = "https://api.anthropic.com"
DefaultModel = "claude-opus-5"
// Non-streaming requests need to finish inside the HTTP timeout; 16k is the
// documented ceiling for that. Phase 5 raises this once we stream.
DefaultMaxTokens = 16000
apiVersion = "2023-06-01"
oauthBeta = "oauth-2025-04-20"
defaultMaxRetries = 2
)
// Client talks to POST /v1/messages.
type Client struct {
BaseURL string
Model string
MaxTokens int
MaxRetries int
// RetryDelay is the base of the exponential backoff between retries.
RetryDelay time.Duration
HTTP *http.Client
apiKey string
authToken string
}
// Option configures a Client.
type Option func(*Client)
func WithBaseURL(u string) Option { return func(c *Client) { c.BaseURL = u } }
func WithModel(m string) Option { return func(c *Client) { c.Model = m } }
func WithMaxTokens(n int) Option { return func(c *Client) { c.MaxTokens = n } }
func WithAPIKey(k string) Option { return func(c *Client) { c.apiKey = k; c.authToken = "" } }
func WithAuthToken(t string) Option { return func(c *Client) { c.authToken = t; c.apiKey = "" } }
func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.HTTP = h } }
// New builds a client, reading credentials from the environment unless an
// option overrides them. An unset ANTHROPIC_API_KEY is not fatal here: the
// caller decides, because ANTHROPIC_AUTH_TOKEN or an `ant auth login` profile
// may be in play.
func New(opts ...Option) *Client {
c := &Client{
BaseURL: DefaultBaseURL,
Model: DefaultModel,
MaxTokens: DefaultMaxTokens,
MaxRetries: defaultMaxRetries,
RetryDelay: 500 * time.Millisecond,
HTTP: &http.Client{Timeout: 10 * time.Minute},
apiKey: os.Getenv("ANTHROPIC_API_KEY"),
authToken: os.Getenv("ANTHROPIC_AUTH_TOKEN"),
}
if c.apiKey != "" {
c.authToken = ""
}
if u := os.Getenv("ANTHROPIC_BASE_URL"); u != "" {
c.BaseURL = u
}
for _, o := range opts {
o(c)
}
return c
}
// HasCredentials reports whether the client can authenticate.
func (c *Client) HasCredentials() bool { return c.apiKey != "" || c.authToken != "" }
// APIError is a non-2xx response.
type APIError struct {
StatusCode int
Type string
Message string
Body string
}
func (e *APIError) Error() string {
if e.Message != "" {
return fmt.Sprintf("anthropic: %d %s: %s", e.StatusCode, e.Type, e.Message)
}
return fmt.Sprintf("anthropic: %d: %s", e.StatusCode, e.Body)
}
// Retryable reports whether resending the identical request could succeed.
func (e *APIError) Retryable() bool {
return e.StatusCode == http.StatusTooManyRequests ||
e.StatusCode == http.StatusRequestTimeout ||
e.StatusCode == http.StatusConflict ||
e.StatusCode >= 500
}
// CreateMessage sends one request, filling in the client's defaults and
// retrying the errors worth retrying.
func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, error) {
// Checked once, up front: a missing credential is permanent, and retrying
// it three times with backoff just delays the error.
if !c.HasCredentials() {
return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`")
}
if req.Model == "" {
req.Model = c.Model
}
if req.MaxTokens == 0 {
req.MaxTokens = c.MaxTokens
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("llm: encoding request: %w", err)
}
var lastErr error
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
if attempt > 0 {
if err := sleep(ctx, c.backoff(attempt, lastErr)); err != nil {
return nil, err
}
}
resp, err := c.attempt(ctx, body)
if err == nil {
return resp, nil
}
lastErr = err
if ctx.Err() != nil {
return nil, ctx.Err()
}
var apiErr *APIError
if ok := asAPIError(err, &apiErr); ok && !apiErr.Retryable() {
return nil, err
}
}
return nil, lastErr
}
func (c *Client) attempt(ctx context.Context, body []byte) (*Response, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v1/messages", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("content-type", "application/json")
httpReq.Header.Set("anthropic-version", apiVersion)
switch {
case c.apiKey != "":
httpReq.Header.Set("x-api-key", c.apiKey)
default:
// OAuth tokens go on Authorization, not x-api-key, and need the beta flag.
httpReq.Header.Set("authorization", "Bearer "+c.authToken)
httpReq.Header.Set("anthropic-beta", oauthBeta)
}
httpResp, err := c.HTTP.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("llm: %w", err)
}
defer httpResp.Body.Close()
raw, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, fmt.Errorf("llm: reading response: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
apiErr := &APIError{StatusCode: httpResp.StatusCode, Body: string(raw)}
var envelope struct {
Error struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(raw, &envelope) == nil {
apiErr.Type = envelope.Error.Type
apiErr.Message = envelope.Error.Message
}
if ra := httpResp.Header.Get("retry-after"); ra != "" {
apiErr.Message += " (retry-after " + ra + "s)"
}
return nil, apiErr
}
var out Response
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("llm: decoding response: %w", err)
}
return &out, nil
}
func asAPIError(err error, target **APIError) bool {
if e, ok := err.(*APIError); ok {
*target = e
return true
}
return false
}
// backoff grows exponentially, but honours a retry-after hint when the server
// sent one.
func (c *Client) backoff(attempt int, lastErr error) time.Duration {
var apiErr *APIError
if asAPIError(lastErr, &apiErr) {
if d, ok := retryAfter(apiErr.Message); ok {
return d
}
}
return time.Duration(math.Pow(2, float64(attempt))) * c.RetryDelay
}
func retryAfter(msg string) (time.Duration, bool) {
const marker = "(retry-after "
i := indexOf(msg, marker)
if i < 0 {
return 0, false
}
rest := msg[i+len(marker):]
j := indexOf(rest, "s)")
if j < 0 {
return 0, false
}
secs, err := strconv.Atoi(rest[:j])
if err != nil || secs < 0 {
return 0, false
}
return time.Duration(secs) * time.Second, true
}
func indexOf(s, sub string) int { return bytes.Index([]byte(s), []byte(sub)) }
func sleep(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}