Vectrum
Sign In
330 lines · 8.8 KB · Go
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
	}
}