package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"sync"
"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
MaxTokens int
MaxRetries int
// RetryDelay is the base of the exponential backoff between retries.
RetryDelay time.Duration
HTTP *http.Client
apiKey string
authToken string
// model is behind a lock because a front end can switch it while the
// agent is between turns. It is the one field that moves after
// construction, so it is the one that is not public.
mu sync.Mutex
model string
}
// Model reports the model requests default to.
func (c *Client) Model() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.model
}
// SetModel changes the model the requests that follow will ask for.
func (c *Client) SetModel(id string) {
c.mu.Lock()
defer c.mu.Unlock()
c.model = id
}
// Option configures a Client.
type Option func(*Client)
// WithBaseURL points the client at a different endpoint: a proxy, a gateway,
// or a local server speaking the Messages API. An empty string is ignored, so
// callers can pass an unset flag without clobbering the environment.
func WithBaseURL(u string) Option {
return func(c *Client) {
if u = normalizeBaseURL(u); u != "" {
c.BaseURL = u
}
}
}
func WithModel(m string) Option {
return func(c *Client) {
if m != "" {
c.model = m
}
}
}
func WithMaxTokens(n int) Option { return func(c *Client) { c.MaxTokens = n } }
// WithAPIKey sets the x-api-key credential. An empty string is ignored.
func WithAPIKey(k string) Option {
return func(c *Client) {
if k != "" {
c.apiKey, c.authToken = k, ""
}
}
}
// WithAuthToken sets a bearer credential, which is what OAuth tokens and most
// third-party gateways expect. An empty string is ignored.
func WithAuthToken(t string) Option {
return func(c *Client) {
if t != "" {
c.authToken, c.apiKey = t, ""
}
}
}
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 := normalizeBaseURL(os.Getenv("ANTHROPIC_BASE_URL")); u != "" {
c.BaseURL = u
}
for _, o := range opts {
o(c)
}
return c
}
// normalizeBaseURL trims whitespace and trailing slashes, so a base URL pasted
// with a trailing "/" does not produce a request to "//v1/messages" — which
// some proxies 404 rather than normalize.
func normalizeBaseURL(u string) string {
return strings.TrimRight(strings.TrimSpace(u), "/")
}
// IsDefaultEndpoint reports whether the client is talking to Anthropic.
func (c *Client) IsDefaultEndpoint() bool { return c.BaseURL == DefaultBaseURL }
// 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
}
// newRequest builds a request to the endpoint with the version and the
// credential already on it. Every call the client makes goes through here, so
// a second route cannot end up authenticating differently from the first.
func (c *Client) newRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("content-type", "application/json")
req.Header.Set("anthropic-version", apiVersion)
switch {
case c.apiKey != "":
req.Header.Set("x-api-key", c.apiKey)
default:
// OAuth tokens go on Authorization, not x-api-key, and need the beta flag.
req.Header.Set("authorization", "Bearer "+c.authToken)
req.Header.Set("anthropic-beta", oauthBeta)
}
return req, nil
}
func (c *Client) attempt(ctx context.Context, body []byte) (*Response, error) {
httpReq, err := c.newRequest(ctx, http.MethodPost, "/v1/messages", body)
if err != nil {
return nil, err
}
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 {
return nil, parseAPIError(httpResp.StatusCode, httpResp.Header.Get("retry-after"), raw)
}
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
}
}