package llm
import (
"bytes"
"context"
"encoding/json"
"errors"
"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
tokenSource func(context.Context) (string, error)
// 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
}
// Clone returns a copy that authenticates exactly as this client does and
// differs only in the model it asks for. A subagent runs on its own model, and
// must keep running on it even if the session switches the parent's — so the
// model is copied, not shared, and so is the lock that guards it.
func (c *Client) Clone(model string) *Client {
c.mu.Lock()
defer c.mu.Unlock()
if model == "" {
model = c.model
}
return &Client{
BaseURL: c.BaseURL,
MaxTokens: c.MaxTokens,
MaxRetries: c.MaxRetries,
RetryDelay: c.RetryDelay,
HTTP: c.HTTP,
apiKey: c.apiKey,
authToken: c.authToken,
tokenSource: c.tokenSource,
model: model,
}
}
// errNoCredentials is what every route reports when it has nothing to
// authenticate with. It is one value rather than three copies of a string
// because ListModels, CreateMessage and StreamMessage all reach it, and a
// caller comparing them should not have to hope they were typed alike.
var errNoCredentials = errors.New("llm: no credentials: set ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or log in with Claude Code")
// 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, ""
}
}
}
// WithTokenSource sets a bearer credential resolved per request, so a token
// that expires mid-session can be replaced without rebuilding the client. A
// nil function is ignored.
func WithTokenSource(f func(context.Context) (string, error)) Option {
return func(c *Client) {
if f != nil {
c.tokenSource, c.apiKey, c.authToken = f, "", ""
}
}
}
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 != "" || c.tokenSource != nil
}
// ClearCredentialsForTest drops any credential read from the environment, so a
// test exercising credential precedence is not at the mercy of the developer's
// own shell.
func (c *Client) ClearCredentialsForTest() {
c.apiKey, c.authToken, c.tokenSource = "", "", nil
}
// 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
}
// auth is one request's resolved credential. Resolving returns a value rather
// than mutating the client so concurrent requests cannot interleave.
type auth struct {
apiKey string
bearer string
}
// apply writes the credential headers. An OAuth bearer also needs the beta
// flag; an API key does not.
func (a auth) apply(h http.Header) {
if a.apiKey != "" {
h.Set("x-api-key", a.apiKey)
return
}
h.Set("authorization", "Bearer "+a.bearer)
h.Set("anthropic-beta", oauthBeta)
}
// resolveAuth produces the credential for one request. It runs before the
// retry loop: a credential that will not resolve is permanent, and the loop
// only short-circuits on a non-retryable *APIError, so resolving inside
// attempt would retry a dead credential three times with backoff.
func (c *Client) resolveAuth(ctx context.Context) (auth, error) {
if c.apiKey != "" {
return auth{apiKey: c.apiKey}, nil
}
if c.tokenSource != nil {
tok, err := c.tokenSource(ctx)
if err != nil {
return auth{}, err
}
return auth{bearer: tok}, nil
}
return auth{bearer: c.authToken}, nil
}
// 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, errNoCredentials
}
a, err := c.resolveAuth(ctx)
if err != nil {
return nil, err
}
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, a)
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.
//
// The credential arrives as a value rather than being read off the client,
// because it may have to be refreshed and that has to happen once per call,
// outside the retry loop — see resolveAuth.
func (c *Client) newRequest(ctx context.Context, method, path string, body []byte, a auth) (*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)
a.apply(req.Header)
return req, nil
}
func (c *Client) attempt(ctx context.Context, body []byte, a auth) (*Response, error) {
httpReq, err := c.newRequest(ctx, http.MethodPost, "/v1/messages", body, a)
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
}
}