package llm import ( "bytes" "context" "encoding/json" "fmt" "io" "math" "net/http" "os" "strconv" "strings" "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 tokenSource func(context.Context) (string, error) } // 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, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or log in with Claude Code") } 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 } func (c *Client) attempt(ctx context.Context, body []byte, a auth) (*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) a.apply(httpReq.Header) 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 } }