Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
561 lines
17 KiB
Go
561 lines
17 KiB
Go
// Package anthropic is the llm.Provider adapter for Anthropic's Messages
|
|
// API. It calls POST {base}/v1/messages and GET {base}/v1/models over plain
|
|
// net/http.
|
|
//
|
|
// The adapter never logs, and no error it returns contains the API key:
|
|
// errors do not quote caller- or vendor-supplied text, and every exported
|
|
// method passes its error through redact as a backstop.
|
|
package anthropic
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
|
)
|
|
|
|
// APIVersion is the Messages API version sent as the anthropic-version
|
|
// header.
|
|
const APIVersion = "2023-06-01"
|
|
|
|
// Config configures a Provider.
|
|
type Config struct {
|
|
// BaseURL is the API root the /v1 endpoint paths are appended to, e.g.
|
|
// "https://api.anthropic.com".
|
|
BaseURL string
|
|
// APIKey is sent as the x-api-key header. It must not be empty.
|
|
APIKey string
|
|
// Model is the model Generate requests. ListModels and Verify do not
|
|
// need it.
|
|
Model string
|
|
// MaxTokens is the max_tokens Generate sends when a Request sets none:
|
|
// the Messages API requires one. It must be positive.
|
|
MaxTokens int
|
|
}
|
|
|
|
// Provider is an Anthropic Messages API llm.Provider.
|
|
type Provider struct {
|
|
base string
|
|
apiKey string
|
|
model string
|
|
maxTokens int
|
|
client *http.Client
|
|
}
|
|
|
|
var _ llm.Provider = (*Provider)(nil)
|
|
|
|
// New validates cfg and returns a Provider.
|
|
func New(cfg Config) (*Provider, error) {
|
|
p, err := newFromConfig(cfg)
|
|
if err != nil {
|
|
return nil, redact(err, cfg.APIKey)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func newFromConfig(cfg Config) (*Provider, error) {
|
|
// No error from New quotes caller-supplied text: any of it may equal or
|
|
// contain the API key. Base URL errors never echo the URL.
|
|
u, err := url.Parse(cfg.BaseURL)
|
|
if err != nil {
|
|
return nil, errors.New("anthropic: base URL does not parse")
|
|
}
|
|
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return nil, errors.New("anthropic: base URL must be an absolute http or https URL")
|
|
}
|
|
// A trailing "?" or "#" parses to an empty query or fragment, yet the
|
|
// endpoint path appended to it would land in the query or fragment.
|
|
if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") {
|
|
return nil, errors.New("anthropic: base URL must not carry credentials, a query or a fragment")
|
|
}
|
|
if cfg.APIKey == "" {
|
|
return nil, errors.New("anthropic: no API key configured")
|
|
}
|
|
if !validHeaderValue(cfg.APIKey) {
|
|
return nil, errors.New("anthropic: API key has a control character")
|
|
}
|
|
if cfg.MaxTokens <= 0 {
|
|
return nil, errors.New("anthropic: max tokens must be positive")
|
|
}
|
|
return &Provider{
|
|
base: strings.TrimRight(cfg.BaseURL, "/"),
|
|
apiKey: cfg.APIKey,
|
|
model: cfg.Model,
|
|
maxTokens: cfg.MaxTokens,
|
|
client: noRedirectClient(),
|
|
}, nil
|
|
}
|
|
|
|
// validHeaderValue reports whether value is an RFC 9110 field-value: no
|
|
// control characters other than horizontal tab, so no CR, LF or DEL.
|
|
func validHeaderValue(value string) bool {
|
|
for i := 0; i < len(value); i++ {
|
|
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// toolName is the pattern the Messages API requires of a tool name.
|
|
var toolName = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
|
|
|
// maxResponseBody caps a successful response body; a larger one is an error.
|
|
const maxResponseBody = 16 << 20
|
|
|
|
// maxErrorBody caps how much of an error response body is read. It is far
|
|
// above llm.MaxBodyExcerpt, so redaction still sees every secret that could
|
|
// reach the excerpt.
|
|
const maxErrorBody = 64 << 10
|
|
|
|
type message struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type tool struct {
|
|
Name string `json:"name"`
|
|
InputSchema json.RawMessage `json:"input_schema"`
|
|
}
|
|
|
|
type toolChoice struct {
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type messagesRequest struct {
|
|
Model string `json:"model"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
System string `json:"system,omitempty"`
|
|
Messages []message `json:"messages"`
|
|
Temperature *float64 `json:"temperature,omitempty"`
|
|
Tools []tool `json:"tools,omitempty"`
|
|
ToolChoice *toolChoice `json:"tool_choice,omitempty"`
|
|
}
|
|
|
|
type messagesResponse struct {
|
|
Type string `json:"type"`
|
|
Model string `json:"model"`
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
Name string `json:"name"`
|
|
Input json.RawMessage `json:"input"`
|
|
} `json:"content"`
|
|
StopReason string `json:"stop_reason"`
|
|
Usage struct {
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
} `json:"usage"`
|
|
}
|
|
|
|
// Generate sends req to {base}/v1/messages. A Request with a Schema is sent
|
|
// as one tool whose input_schema is the schema, with tool_choice forcing
|
|
// it; Response.JSON is then that tool's input.
|
|
func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
|
resp, err := p.generate(ctx, req)
|
|
return resp, redact(err, p.apiKey)
|
|
}
|
|
|
|
func (p *Provider) generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
|
if err := req.Validate(); err != nil {
|
|
return llm.Response{}, fmt.Errorf("anthropic: generate: %w", err)
|
|
}
|
|
if p.model == "" {
|
|
return llm.Response{}, errors.New("anthropic: generate: no model configured")
|
|
}
|
|
body := messagesRequest{
|
|
Model: p.model,
|
|
MaxTokens: p.maxTokens,
|
|
System: req.System,
|
|
Temperature: req.Temperature,
|
|
}
|
|
if req.MaxTokens > 0 {
|
|
body.MaxTokens = req.MaxTokens
|
|
}
|
|
if req.Schema != nil {
|
|
if !toolName.MatchString(req.Schema.Name) {
|
|
return llm.Response{}, errors.New("anthropic: generate: schema name is not a valid tool name")
|
|
}
|
|
body.Tools = []tool{{Name: req.Schema.Name, InputSchema: req.Schema.Schema}}
|
|
body.ToolChoice = &toolChoice{Type: "tool", Name: req.Schema.Name}
|
|
}
|
|
for _, m := range req.Messages {
|
|
body.Messages = append(body.Messages, message{Role: string(m.Role), Content: m.Content})
|
|
}
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
return llm.Response{}, fmt.Errorf("anthropic: generate: encode request: %w", err)
|
|
}
|
|
|
|
if req.Timeout > 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
|
|
defer cancel()
|
|
}
|
|
raw, err := p.do(ctx, http.MethodPost, "/v1/messages", payload)
|
|
if err != nil {
|
|
return llm.Response{}, fmt.Errorf("anthropic: generate: %w", err)
|
|
}
|
|
|
|
var mr messagesResponse
|
|
if err := json.Unmarshal(raw, &mr); err != nil {
|
|
return llm.Response{}, fmt.Errorf("anthropic: generate: decode response: %w", err)
|
|
}
|
|
if mr.Type != "message" {
|
|
return llm.Response{}, errors.New("anthropic: generate: response is not a message")
|
|
}
|
|
resp := llm.Response{
|
|
Usage: llm.Usage{InputTokens: mr.Usage.InputTokens, OutputTokens: mr.Usage.OutputTokens},
|
|
Model: mr.Model,
|
|
FinishReason: mr.StopReason,
|
|
}
|
|
var text strings.Builder
|
|
for _, block := range mr.Content {
|
|
switch block.Type {
|
|
case "text":
|
|
text.WriteString(block.Text)
|
|
case "tool_use":
|
|
if req.Schema != nil && block.Name == req.Schema.Name && resp.JSON == nil {
|
|
resp.JSON = block.Input
|
|
}
|
|
}
|
|
}
|
|
resp.Text = text.String()
|
|
if req.Schema == nil {
|
|
return resp, nil
|
|
}
|
|
if !isJSONObject(resp.JSON) {
|
|
resp.JSON = nil
|
|
return resp, fmt.Errorf("anthropic: generate: reply holds no input for the forced tool: %w", llm.ErrBadOutput)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// isJSONObject reports whether raw is exactly one JSON object.
|
|
func isJSONObject(raw json.RawMessage) bool {
|
|
s := bytes.TrimSpace(raw)
|
|
return bytes.HasPrefix(s, []byte("{")) && json.Valid(s)
|
|
}
|
|
|
|
type modelsResponse struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
DisplayName string `json:"display_name"`
|
|
} `json:"data"`
|
|
HasMore bool `json:"has_more"`
|
|
LastID string `json:"last_id"`
|
|
}
|
|
|
|
// modelsPageLimit is the largest page GET /v1/models serves.
|
|
const modelsPageLimit = 1000
|
|
|
|
// ListModels returns every model {base}/v1/models lists, following pages.
|
|
func (p *Provider) ListModels(ctx context.Context) ([]llm.Model, error) {
|
|
models, err := p.listModels(ctx)
|
|
return models, redact(err, p.apiKey)
|
|
}
|
|
|
|
func (p *Provider) listModels(ctx context.Context) ([]llm.Model, error) {
|
|
models := []llm.Model{}
|
|
after := ""
|
|
for {
|
|
page, err := p.modelsPage(ctx, modelsPageLimit, after)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("anthropic: list models: %w", err)
|
|
}
|
|
for _, m := range page.Data {
|
|
if m.ID == "" {
|
|
return nil, fmt.Errorf("anthropic: list models: model %d has no id", len(models))
|
|
}
|
|
name := m.DisplayName
|
|
if name == "" {
|
|
name = m.ID
|
|
}
|
|
models = append(models, llm.Model{ID: m.ID, DisplayName: name})
|
|
}
|
|
if !page.HasMore {
|
|
return models, nil
|
|
}
|
|
if page.LastID == "" || page.LastID == after {
|
|
return nil, errors.New("anthropic: list models: response has more pages but no new cursor")
|
|
}
|
|
after = page.LastID
|
|
}
|
|
}
|
|
|
|
// modelsPage fetches one page of GET {base}/v1/models.
|
|
func (p *Provider) modelsPage(ctx context.Context, limit int, after string) (modelsResponse, error) {
|
|
q := url.Values{"limit": {strconv.Itoa(limit)}}
|
|
if after != "" {
|
|
q.Set("after_id", after)
|
|
}
|
|
raw, err := p.do(ctx, http.MethodGet, "/v1/models?"+q.Encode(), nil)
|
|
if err != nil {
|
|
return modelsResponse{}, err
|
|
}
|
|
var mr modelsResponse
|
|
if err := json.Unmarshal(raw, &mr); err != nil {
|
|
return modelsResponse{}, fmt.Errorf("decode response: %w", err)
|
|
}
|
|
if mr.Data == nil {
|
|
return modelsResponse{}, errors.New("response has no data list")
|
|
}
|
|
return mr, nil
|
|
}
|
|
|
|
// Verify checks the credentials with the cheapest authenticated call: one
|
|
// page of GET {base}/v1/models holding at most one model.
|
|
func (p *Provider) Verify(ctx context.Context) error {
|
|
_, err := p.modelsPage(ctx, 1, "")
|
|
if err != nil {
|
|
err = fmt.Errorf("anthropic: verify: %w", err)
|
|
}
|
|
return redact(err, p.apiKey)
|
|
}
|
|
|
|
// do sends one request and returns the body of a 2xx response. Any other
|
|
// status becomes the matching llm typed error.
|
|
func (p *Provider) do(ctx context.Context, method, path string, payload []byte) ([]byte, error) {
|
|
var reqBody io.Reader
|
|
if payload != nil {
|
|
reqBody = bytes.NewReader(payload)
|
|
}
|
|
httpReq, err := http.NewRequestWithContext(ctx, method, p.base+path, reqBody)
|
|
if err != nil {
|
|
// The parse error names the URL, whose path may hold the API key.
|
|
return nil, errors.New("build request: request URL is invalid")
|
|
}
|
|
httpReq.Header.Set("x-api-key", p.apiKey)
|
|
httpReq.Header.Set("anthropic-version", APIVersion)
|
|
httpReq.Header.Set("Accept", "application/json")
|
|
if payload != nil {
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
httpResp, err := p.client.Do(httpReq)
|
|
if err != nil {
|
|
return nil, p.transportError(err)
|
|
}
|
|
defer httpResp.Body.Close()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 {
|
|
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxErrorBody))
|
|
if err != nil {
|
|
return nil, p.transportError(err)
|
|
}
|
|
return nil, p.statusError(httpResp, body)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBody+1))
|
|
if err != nil {
|
|
return nil, p.transportError(err)
|
|
}
|
|
if len(body) > maxResponseBody {
|
|
return nil, fmt.Errorf("response body exceeds %d bytes", maxResponseBody)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// statusOverloaded is the status the Messages API returns when it is
|
|
// temporarily overloaded.
|
|
const statusOverloaded = 529
|
|
|
|
// statusError maps a non-2xx response to an llm typed error.
|
|
func (p *Provider) statusError(resp *http.Response, body []byte) error {
|
|
switch resp.StatusCode {
|
|
case http.StatusUnauthorized, http.StatusForbidden:
|
|
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrAuth)
|
|
case http.StatusTooManyRequests, statusOverloaded:
|
|
return llm.NewRateLimitedError(retryAfter(resp.Header.Get("Retry-After"), time.Now()), p.apiKey)
|
|
case http.StatusRequestTimeout:
|
|
return llm.NewTimeoutStatusError(resp.StatusCode, p.apiKey)
|
|
default:
|
|
return llm.NewProviderError(resp.StatusCode, body, p.apiKey)
|
|
}
|
|
}
|
|
|
|
// retryAfter parses a Retry-After header, in delay-seconds or HTTP-date
|
|
// form. A missing, malformed or past value means the vendor gave no usable
|
|
// wait, reported as 0.
|
|
func retryAfter(value string, now time.Time) time.Duration {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return 0
|
|
}
|
|
if secs, err := strconv.ParseInt(value, 10, 64); err == nil || errors.Is(err, strconv.ErrRange) {
|
|
if secs < 0 {
|
|
return 0
|
|
}
|
|
// time.Duration counts nanoseconds in an int64; a larger wait is
|
|
// capped to the most whole seconds it holds instead of overflowing.
|
|
if secs > maxRetryAfterSeconds {
|
|
secs = maxRetryAfterSeconds
|
|
}
|
|
return time.Duration(secs) * time.Second
|
|
}
|
|
if at, err := http.ParseTime(value); err == nil && at.After(now) {
|
|
return at.Sub(now).Round(time.Second)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// maxRetryAfterSeconds is the largest delay-seconds value retryAfter
|
|
// returns unchanged: the most whole seconds a time.Duration holds.
|
|
const maxRetryAfterSeconds = math.MaxInt64 / int64(time.Second)
|
|
|
|
// noRedirectClient returns an HTTP client that never follows a redirect.
|
|
// A followed redirect would resend the credential headers, which net/http
|
|
// strips only for its own list of sensitive headers and only across hosts;
|
|
// instead the 3xx response is returned and becomes a provider error.
|
|
func noRedirectClient() *http.Client {
|
|
return &http.Client{
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
}
|
|
|
|
func isTimeout(err error) bool {
|
|
var ne net.Error
|
|
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
|
|
}
|
|
|
|
// transportFailure is a failed HTTP exchange. It carries the cause's text
|
|
// with the request URL removed and the API key redacted, and unwraps only to
|
|
// the cause's category: llm.ErrTimeout, context.Canceled or nothing.
|
|
type transportFailure struct {
|
|
text string
|
|
kind error
|
|
}
|
|
|
|
func (e *transportFailure) Error() string { return e.text }
|
|
|
|
// GoString keeps %#v to the redacted Error text instead of the fields.
|
|
func (e *transportFailure) GoString() string { return e.text }
|
|
|
|
func (e *transportFailure) Unwrap() error { return e.kind }
|
|
|
|
// transportError reports a failure to complete the HTTP exchange. A
|
|
// *url.Error names the request URL, and a base URL path may hold the API
|
|
// key, so only the wrapped cause is kept, rendered with the key redacted.
|
|
func (p *Provider) transportError(err error) error {
|
|
var kind error
|
|
switch {
|
|
case isTimeout(err):
|
|
kind = llm.ErrTimeout
|
|
case errors.Is(err, context.Canceled):
|
|
kind = context.Canceled
|
|
}
|
|
var ue *url.Error
|
|
if errors.As(err, &ue) {
|
|
err = ue.Err
|
|
}
|
|
text := "transport: " + err.Error()
|
|
if kind == llm.ErrTimeout {
|
|
text = fmt.Sprintf("%s: %s", llm.ErrTimeout, text)
|
|
}
|
|
return &transportFailure{text: llm.Redact(text, p.apiKey), kind: kind}
|
|
}
|
|
|
|
// redactedError stands in for one layer of an error chain that holds the
|
|
// API key. Its text is the layer's text with the key redacted, and Unwrap
|
|
// returns the layer's causes passed through redact, so no error reached by
|
|
// unwrapping has text holding the key. Is consults the original layer and
|
|
// its chain, so sentinels still match. As does too. The llm typed errors
|
|
// remove the key from their own complete text, fixed words included, so
|
|
// they always match; As refuses a match whose text, or whose %#v form,
|
|
// holds the key, so it never hands back an error that can print it.
|
|
type redactedError struct {
|
|
text string
|
|
err error
|
|
key string
|
|
}
|
|
|
|
func (e *redactedError) Error() string { return e.text }
|
|
|
|
// GoString keeps %#v from printing the fields, which hold the key.
|
|
func (e *redactedError) GoString() string { return e.text }
|
|
|
|
func (e *redactedError) Unwrap() error { return redact(errors.Unwrap(e.err), e.key) }
|
|
|
|
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
|
|
|
func (e *redactedError) As(target any) bool {
|
|
if !errors.As(e.err, target) {
|
|
return false
|
|
}
|
|
found := reflect.ValueOf(target).Elem()
|
|
matched, isErr := found.Interface().(error)
|
|
if (isErr && holdsKey(matched, e.key)) || strings.Contains(fmt.Sprintf("%#v", found.Interface()), e.key) {
|
|
found.SetZero()
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// redactedJoin is redactedError for a layer with several causes, such as
|
|
// one built by errors.Join or by fmt.Errorf with more than one %w.
|
|
type redactedJoin struct {
|
|
redactedError
|
|
}
|
|
|
|
func (e *redactedJoin) Unwrap() []error {
|
|
causes := e.err.(interface{ Unwrap() []error }).Unwrap()
|
|
safe := make([]error, len(causes))
|
|
for i, c := range causes {
|
|
safe[i] = redact(c, e.key)
|
|
}
|
|
return safe
|
|
}
|
|
|
|
// redact is the backstop every exported method applies to its error: with
|
|
// a non-empty key, it returns a stand-in whose text, and the text of every
|
|
// error it unwraps to, has the key redacted. Every non-nil err is wrapped,
|
|
// even one whose text lacks the key, because %#v of an unwrapped error
|
|
// prints its struct field names, and a key may spell one.
|
|
func redact(err error, key string) error {
|
|
if err == nil || key == "" {
|
|
return err
|
|
}
|
|
re := redactedError{text: llm.Redact(err.Error(), key), err: err, key: key}
|
|
if _, ok := err.(interface{ Unwrap() []error }); ok {
|
|
return &redactedJoin{re}
|
|
}
|
|
return &re
|
|
}
|
|
|
|
// holdsKey reports whether the text of err or of any error in its chain
|
|
// contains key.
|
|
func holdsKey(err error, key string) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if strings.Contains(err.Error(), key) {
|
|
return true
|
|
}
|
|
switch u := err.(type) {
|
|
case interface{ Unwrap() error }:
|
|
return holdsKey(u.Unwrap(), key)
|
|
case interface{ Unwrap() []error }:
|
|
for _, c := range u.Unwrap() {
|
|
if holdsKey(c, key) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|