Feature 08 task 3: Anthropic Messages adapter

Add internal/ai/llm/anthropic, an llm.Provider over plain net/http:

- POST {base}/v1/messages and GET {base}/v1/models with x-api-key and
  anthropic-version 2023-06-01. An empty key, a non-positive
  Config.MaxTokens and a base URL that is not absolute http(s), or that
  carries userinfo, a query or a fragment, are construction errors.
- System text goes in the top-level system field. A Request with a
  Schema becomes one tool whose input_schema is the schema, forced by
  tool_choice; Response.JSON is that tool's input. Otherwise the text
  blocks are Response.Text. Usage and stop_reason are parsed.
- ListModels follows has_more/after_id pages; Verify fetches one page
  with limit=1.
- 401/403 map to ErrAuth, 429 and 529 to ErrRateLimited with
  Retry-After, 408 and deadlines to ErrTimeout, other statuses to
  ErrProvider with a redacted excerpt.
- Errors carry the OpenAI adapter's redaction chain, so no Error(),
  Unwrap layer, errors.As target or %#v output holds the key.

Contract tests replay sanitized fixtures adapted from Anthropic's public
Messages API docs over httptest, covering tool-forced JSON, text, each
error mapping and key redaction with the keys x-api-key, anthropic,
HTTP, rate, a one-letter key and a URL-escaped key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 07:49:52 -04:00
co-authored by Claude Opus 5.5
parent acd29c8221
commit 3b36f07cb8
15 changed files with 1554 additions and 0 deletions
+534
View File
@@ -0,0 +1,534 @@
// 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"
"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: &http.Client{},
}, 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 fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrTimeout)
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.Atoi(value); err == nil {
if secs < 0 {
return 0
}
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
}
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 {
detail string
kind error
}
func (e *transportFailure) Error() string {
if e.kind == llm.ErrTimeout {
return fmt.Sprintf("%s: transport: %s", llm.ErrTimeout, e.detail)
}
return "transport: " + e.detail
}
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
}
return &transportFailure{detail: llm.Redact(err.Error(), 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 only a match from outside llm whose text
// holds the key, so it never hands back an unredacted error.
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()
if matched, ok := found.Interface().(error); ok && holdsKey(matched, 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: when
// the text of err or of any error in its chain holds a non-empty key, it
// returns a stand-in whose text, and the text of every error it unwraps
// to, has the key redacted. Any other err is returned unchanged.
func redact(err error, key string) error {
if err == nil || key == "" || !holdsKey(err, 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
}
+889
View File
@@ -0,0 +1,889 @@
package anthropic
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
const testKey = "sk-ant-test-9f8e7d6c5b4a39281706f5e4d3c2b1a0"
// recorded is one request the test server received.
type recorded struct {
method string
path string
query url.Values
header http.Header
body []byte
}
// reply is a response the test server replays.
type reply struct {
status int
header map[string]string
fixture string // testdata file; {{KEY}} is replaced with the key
body string // used when fixture is empty
delay time.Duration
}
// vendorServer replays its replies in order, repeating the last, and
// records every request.
type vendorServer struct {
*httptest.Server
mu sync.Mutex
got []recorded
}
func newVendorServer(t *testing.T, replies ...reply) *vendorServer {
return newVendorServerWithKey(t, testKey, replies...)
}
func newVendorServerWithKey(t *testing.T, key string, replies ...reply) *vendorServer {
t.Helper()
payloads := make([][]byte, len(replies))
for i, r := range replies {
payloads[i] = []byte(r.body)
if r.fixture != "" {
raw, err := os.ReadFile(filepath.Join("testdata", r.fixture))
if err != nil {
t.Fatal(err)
}
payloads[i] = bytes.ReplaceAll(raw, []byte("{{KEY}}"), []byte(key))
}
}
vs := &vendorServer{}
vs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
vs.mu.Lock()
n := len(vs.got)
vs.got = append(vs.got, recorded{req.Method, req.URL.Path, req.URL.Query(), req.Header.Clone(), body})
vs.mu.Unlock()
i := min(n, len(replies)-1)
r := replies[i]
if r.delay > 0 {
select {
case <-time.After(r.delay):
case <-req.Context().Done():
return
}
}
for k, v := range r.header {
w.Header().Set(k, v)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(r.status)
w.Write(payloads[i])
}))
t.Cleanup(vs.Close)
return vs
}
func (vs *vendorServer) requests() []recorded {
vs.mu.Lock()
defer vs.mu.Unlock()
return append([]recorded(nil), vs.got...)
}
func (vs *vendorServer) only(t *testing.T) recorded {
t.Helper()
got := vs.requests()
if len(got) != 1 {
t.Fatalf("server got %d requests, want 1", len(got))
}
return got[0]
}
func newProvider(t *testing.T, base string) *Provider {
return newProviderWithKey(t, base, testKey)
}
func newProviderWithKey(t *testing.T, base, key string) *Provider {
t.Helper()
p, err := New(Config{BaseURL: base, APIKey: key, Model: "test-model", MaxTokens: 1024})
if err != nil {
t.Fatal(err)
}
return p
}
var dinnersSchema = json.RawMessage(`{"type":"object","properties":{"dinners":{"type":"array"}},"required":["dinners"]}`)
func schemaRequest() llm.Request {
temp := 0.0
return llm.Request{
System: "You plan dinners for Our kitchen.",
Messages: []llm.Message{
{Role: llm.RoleUser, Content: "Suggest a Side quest."},
{Role: llm.RoleAssistant, Content: "Something quick?"},
{Role: llm.RoleUser, Content: "Yes, a Quick dinner."},
},
Temperature: &temp,
MaxTokens: 400,
Schema: &llm.JSONSchema{Name: "side_quests", Schema: dinnersSchema},
}
}
func textRequest() llm.Request {
return llm.Request{Messages: []llm.Message{{Role: llm.RoleUser, Content: "What should we cook tonight?"}}}
}
// sentBody decodes a request body into a generic map.
func sentBody(t *testing.T, r recorded) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(r.body, &m); err != nil {
t.Fatalf("request body %q: %v", r.body, err)
}
return m
}
// canonical re-encodes JSON so two encodings of one value compare equal.
func canonical(t *testing.T, raw []byte) string {
t.Helper()
var v any
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatalf("JSON %q: %v", raw, err)
}
out, _ := json.Marshal(v)
return string(out)
}
func assertHeaders(t *testing.T, r recorded, key string) {
t.Helper()
if got := r.header.Values("X-Api-Key"); len(got) != 1 || got[0] != key {
t.Errorf("x-api-key = %q", got)
}
if got := r.header.Values("Anthropic-Version"); len(got) != 1 || got[0] != "2023-06-01" {
t.Errorf("anthropic-version = %q", got)
}
if got := r.header.Get("Authorization"); got != "" {
t.Errorf("Authorization = %q, want none", got)
}
}
func TestGenerateToolForcedJSON(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, fixture: "messages_tool_use.json"})
p := newProvider(t, vs.URL+"/")
resp, err := p.Generate(context.Background(), schemaRequest())
if err != nil {
t.Fatal(err)
}
if got, want := canonical(t, resp.JSON), `{"dinners":[{"name":"Sheet-pan gnocchi","why":"Like your Regular tray bake, but quicker."}]}`; got != want {
t.Errorf("Response.JSON = %s, want %s", got, want)
}
if resp.Text != "" {
t.Errorf("Text = %q, want none", resp.Text)
}
if resp.Usage != (llm.Usage{InputTokens: 472, OutputTokens: 61}) || resp.Model != "claude-sonnet-5" || resp.FinishReason != "tool_use" {
t.Errorf("Usage, Model, FinishReason = %+v, %q, %q", resp.Usage, resp.Model, resp.FinishReason)
}
got := vs.only(t)
if got.method != http.MethodPost || got.path != "/v1/messages" || len(got.query) != 0 {
t.Errorf("request = %s %s?%s", got.method, got.path, got.query.Encode())
}
assertHeaders(t, got, testKey)
if h := got.header.Get("Content-Type"); h != "application/json" {
t.Errorf("Content-Type = %q", h)
}
body := sentBody(t, got)
if body["model"] != "test-model" || body["temperature"] != 0.0 || body["max_tokens"] != 400.0 {
t.Errorf("model, temperature, max_tokens = %v, %v, %v", body["model"], body["temperature"], body["max_tokens"])
}
if body["system"] != "You plan dinners for Our kitchen." {
t.Errorf("system = %v", body["system"])
}
msgs := body["messages"].([]any)
wantMsgs := []string{"user:Suggest a Side quest.", "assistant:Something quick?", "user:Yes, a Quick dinner."}
if len(msgs) != len(wantMsgs) {
t.Fatalf("messages = %v", msgs)
}
for i, m := range msgs {
mm := m.(map[string]any)
if got := fmt.Sprintf("%v:%v", mm["role"], mm["content"]); got != wantMsgs[i] {
t.Errorf("message %d = %s, want %s", i, got, wantMsgs[i])
}
}
tools := body["tools"].([]any)
if len(tools) != 1 {
t.Fatalf("tools = %v", tools)
}
tool := tools[0].(map[string]any)
schema, _ := json.Marshal(tool["input_schema"])
if tool["name"] != "side_quests" || string(schema) != canonical(t, dinnersSchema) {
t.Errorf("tool = %v", tool)
}
choice := body["tool_choice"].(map[string]any)
if len(choice) != 2 || choice["type"] != "tool" || choice["name"] != "side_quests" {
t.Errorf("tool_choice = %v", choice)
}
}
func TestGenerateText(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, fixture: "messages_text.json"})
p := newProvider(t, vs.URL)
resp, err := p.Generate(context.Background(), textRequest())
if err != nil {
t.Fatal(err)
}
if resp.Text != "Try a crock pot chili: it is a Quick dinner." || resp.JSON != nil {
t.Errorf("Text, JSON = %q, %s", resp.Text, resp.JSON)
}
if resp.Usage != (llm.Usage{InputTokens: 2095, OutputTokens: 503}) || resp.Model != "claude-sonnet-5" || resp.FinishReason != "end_turn" {
t.Errorf("Usage, Model, FinishReason = %+v, %q, %q", resp.Usage, resp.Model, resp.FinishReason)
}
got := vs.only(t)
if got.path != "/v1/messages" {
t.Errorf("path = %s", got.path)
}
assertHeaders(t, got, testKey)
body := sentBody(t, got)
if body["max_tokens"] != 1024.0 {
t.Errorf("max_tokens = %v, want the configured 1024", body["max_tokens"])
}
for _, field := range []string{"system", "temperature", "tools", "tool_choice"} {
if v, ok := body[field]; ok {
t.Errorf("%s = %v, want it omitted", field, v)
}
}
}
func TestGenerateTextKeepsStopReason(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, fixture: "messages_max_tokens.json"})
resp, err := newProvider(t, vs.URL).Generate(context.Background(), textRequest())
if err != nil {
t.Fatal(err)
}
if resp.FinishReason != "max_tokens" || resp.Usage.OutputTokens != 400 {
t.Errorf("FinishReason, Usage = %q, %+v", resp.FinishReason, resp.Usage)
}
}
func TestGenerateBadOutput(t *testing.T) {
cases := map[string]string{
"text instead of tool": `{"type":"message","model":"m","content":[{"type":"text","text":"Sure!"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":2}}`,
"other tool": `{"type":"message","model":"m","content":[{"type":"tool_use","id":"t","name":"other","input":{"a":1}}],"stop_reason":"tool_use","usage":{}}`,
"input not an object": `{"type":"message","model":"m","content":[{"type":"tool_use","id":"t","name":"side_quests","input":[1]}],"stop_reason":"tool_use","usage":{}}`,
"no content": `{"type":"message","model":"m","content":[],"stop_reason":"max_tokens","usage":{}}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, body: body})
resp, err := newProvider(t, vs.URL).Generate(context.Background(), schemaRequest())
if !errors.Is(err, llm.ErrBadOutput) {
t.Fatalf("err = %v, want ErrBadOutput", err)
}
if resp.JSON != nil || resp.Model != "m" {
t.Fatalf("Response = %+v, want no JSON and the model kept", resp)
}
})
}
vs := newVendorServer(t, reply{status: 200, fixture: "messages_max_tokens.json"})
resp, err := newProvider(t, vs.URL).Generate(context.Background(), schemaRequest())
if !errors.Is(err, llm.ErrBadOutput) || resp.FinishReason != "max_tokens" {
t.Fatalf("truncated: resp, err = %+v, %v", resp, err)
}
}
func TestGenerateRejectsNonMessage(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, fixture: "error_overloaded.json"})
_, err := newProvider(t, vs.URL).Generate(context.Background(), textRequest())
if err == nil || errors.Is(err, llm.ErrBadOutput) {
t.Fatalf("err = %v, want an untyped malformed-response error", err)
}
}
func TestGenerateRejectsInvalidRequests(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, fixture: "messages_text.json"})
p := newProvider(t, vs.URL)
if _, err := p.Generate(context.Background(), llm.Request{}); err == nil {
t.Error("Generate accepted a request with no messages")
}
for _, name := range []string{"side quests", "", strings.Repeat("a", 65), "plan.v2"} {
req := schemaRequest()
req.Schema.Name = name
if _, err := p.Generate(context.Background(), req); err == nil {
t.Errorf("Generate accepted tool name %q", name)
}
}
noModel, err := New(Config{BaseURL: vs.URL, APIKey: testKey, MaxTokens: 1})
if err != nil {
t.Fatal(err)
}
if _, err := noModel.Generate(context.Background(), textRequest()); err == nil {
t.Error("Generate succeeded with no model configured")
}
if n := len(vs.requests()); n != 0 {
t.Errorf("invalid requests reached the vendor %d times", n)
}
}
func TestListModelsFollowsPages(t *testing.T) {
vs := newVendorServer(t,
reply{status: 200, fixture: "models_page1.json"},
reply{status: 200, fixture: "models_page2.json"},
)
models, err := newProvider(t, vs.URL).ListModels(context.Background())
if err != nil {
t.Fatal(err)
}
want := []llm.Model{
{ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5"},
{ID: "claude-haiku-4-5-20251001", DisplayName: "Claude Haiku 4.5"},
{ID: "claude-opus-5-5", DisplayName: "claude-opus-5-5"},
}
if len(models) != len(want) {
t.Fatalf("models = %+v", models)
}
for i := range want {
if models[i] != want[i] {
t.Errorf("model %d = %+v, want %+v", i, models[i], want[i])
}
}
got := vs.requests()
if len(got) != 2 {
t.Fatalf("server got %d requests, want 2", len(got))
}
for i, wantAfter := range []string{"", "claude-sonnet-5"} {
r := got[i]
if r.method != http.MethodGet || r.path != "/v1/models" || r.query.Get("limit") != "1000" || r.query.Get("after_id") != wantAfter {
t.Errorf("request %d = %s %s?%s", i, r.method, r.path, r.query.Encode())
}
assertHeaders(t, r, testKey)
if len(r.body) != 0 || r.header.Get("Content-Type") != "" {
t.Errorf("request %d sent a body", i)
}
}
}
func TestListModelsRejectsBadPages(t *testing.T) {
cases := map[string]string{
"no data": `{"has_more":false}`,
"no id": `{"data":[{"type":"model","display_name":"x"}],"has_more":false}`,
"no cursor": `{"data":[{"id":"a"}],"has_more":true}`,
"stuck cursor": `{"data":[{"id":"a"}],"has_more":true,"last_id":"a"}`,
"invalid": `{"data":`,
}
for name, body := range cases {
vs := newVendorServer(t, reply{status: 200, body: body})
if models, err := newProvider(t, vs.URL).ListModels(context.Background()); err == nil {
t.Errorf("%s: ListModels() = %+v, nil error", name, models)
}
}
}
func TestListModelsEmpty(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, body: `{"data":[],"has_more":false}`})
models, err := newProvider(t, vs.URL).ListModels(context.Background())
if err != nil || models == nil || len(models) != 0 {
t.Fatalf("ListModels() = %#v, %v", models, err)
}
}
func TestVerify(t *testing.T) {
ok := newVendorServer(t, reply{status: 200, fixture: "models_page1.json"})
if err := newProvider(t, ok.URL).Verify(context.Background()); err != nil {
t.Fatalf("Verify() = %v", err)
}
got := ok.only(t)
if got.method != http.MethodGet || got.path != "/v1/models" || got.query.Get("limit") != "1" || got.query.Has("after_id") {
t.Errorf("Verify called %s %s?%s", got.method, got.path, got.query.Encode())
}
assertHeaders(t, got, testKey)
for status, want := range map[int]error{401: llm.ErrAuth, 403: llm.ErrAuth, 529: llm.ErrRateLimited, 500: llm.ErrProvider} {
bad := newVendorServer(t, reply{status: status, fixture: "error_api.json"})
err := newProvider(t, bad.URL).Verify(context.Background())
if !errors.Is(err, want) {
t.Errorf("HTTP %d: Verify() = %v, want %v", status, err, want)
}
assertNoSecret(t, err)
}
malformed := newVendorServer(t, reply{status: 200, body: `{}`})
if err := newProvider(t, malformed.URL).Verify(context.Background()); err == nil {
t.Error("Verify accepted a body with no data list")
}
}
// assertNoSecret fails if err's text holds the key or a prefix of it.
func assertNoSecret(t *testing.T, err error) {
t.Helper()
for _, secret := range []string{testKey, testKey[:16]} {
if strings.Contains(strings.ToLower(err.Error()), strings.ToLower(secret)) {
t.Fatalf("error leaks %q: %v", secret, err)
}
}
}
func TestErrorMapping(t *testing.T) {
rateLimited := func(want time.Duration) func(*testing.T, error) {
return func(t *testing.T, err error) {
var rl *llm.RateLimitedError
if !errors.As(err, &rl) || rl.RetryAfter != want {
t.Fatalf("RetryAfter from %v, want %v", err, want)
}
}
}
providerError := func(status int, excerpt string) func(*testing.T, error) {
return func(t *testing.T, err error) {
var pe *llm.ProviderError
if !errors.As(err, &pe) || pe.Status() != status || !strings.Contains(pe.Body(), excerpt) {
t.Fatalf("ProviderError from %v, want HTTP %d with %q", err, status, excerpt)
}
if !strings.Contains(err.Error(), pe.Body()) {
t.Fatalf("Error() = %q, want it to carry the excerpt", err)
}
}
}
cases := []struct {
name string
reply reply
want error
check func(*testing.T, error)
}{
{name: "401", reply: reply{status: 401, fixture: "error_authentication.json"}, want: llm.ErrAuth},
{name: "403", reply: reply{status: 403, fixture: "error_permission.json"}, want: llm.ErrAuth},
{name: "429 seconds", reply: reply{status: 429, header: map[string]string{"Retry-After": "17"}, fixture: "error_rate_limit.json"}, want: llm.ErrRateLimited, check: rateLimited(17 * time.Second)},
{name: "429 no Retry-After", reply: reply{status: 429, fixture: "error_rate_limit.json"}, want: llm.ErrRateLimited, check: rateLimited(0)},
{name: "429 HTTP-date", reply: reply{status: 429, header: map[string]string{"Retry-After": time.Now().Add(90 * time.Second).UTC().Format(http.TimeFormat)}, fixture: "error_rate_limit.json"}, want: llm.ErrRateLimited,
check: func(t *testing.T, err error) {
var rl *llm.RateLimitedError
if !errors.As(err, &rl) || rl.RetryAfter < 85*time.Second || rl.RetryAfter > 91*time.Second {
t.Fatalf("RetryAfter from %v", err)
}
}},
{name: "529 seconds", reply: reply{status: 529, header: map[string]string{"Retry-After": "4"}, fixture: "error_overloaded.json"}, want: llm.ErrRateLimited, check: rateLimited(4 * time.Second)},
{name: "529 no Retry-After", reply: reply{status: 529, fixture: "error_overloaded.json"}, want: llm.ErrRateLimited, check: rateLimited(0)},
{name: "408", reply: reply{status: 408}, want: llm.ErrTimeout},
{name: "400", reply: reply{status: 400, fixture: "error_invalid_request.json"}, want: llm.ErrProvider, check: providerError(400, "max_tokens: Field required")},
{name: "404", reply: reply{status: 404, fixture: "error_not_found.json"}, want: llm.ErrProvider, check: providerError(404, "not_found_error")},
{name: "500 echoing the key", reply: reply{status: 500, fixture: "error_api.json"}, want: llm.ErrProvider, check: providerError(500, "x-api-key: [REDACTED]")},
{name: "503", reply: reply{status: 503, body: "upstream connect error"}, want: llm.ErrProvider, check: providerError(503, "upstream connect error")},
}
all := []error{llm.ErrAuth, llm.ErrRateLimited, llm.ErrTimeout, llm.ErrBadOutput, llm.ErrProvider}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
vs := newVendorServer(t, c.reply)
p := newProvider(t, vs.URL)
_, genErr := p.Generate(context.Background(), schemaRequest())
_, listErr := p.ListModels(context.Background())
for _, err := range []error{genErr, listErr} {
for _, sentinel := range all {
if errors.Is(err, sentinel) != (sentinel == c.want) {
t.Fatalf("errors.Is(%v, %v) = %v", err, sentinel, sentinel != c.want)
}
}
assertNoSecret(t, err)
assertChainNoExactKey(t, err, testKey)
if c.check != nil {
c.check(t, err)
}
}
})
}
}
func TestTimeouts(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, fixture: "messages_tool_use.json", delay: 5 * time.Second})
p := newProvider(t, vs.URL)
req := schemaRequest()
req.Timeout = 50 * time.Millisecond
start := time.Now()
_, err := p.Generate(context.Background(), req)
if !errors.Is(err, llm.ErrTimeout) {
t.Fatalf("Request.Timeout: err = %v, want ErrTimeout", err)
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Fatalf("Request.Timeout not honoured: took %v", elapsed)
}
assertNoSecret(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if _, err := p.ListModels(ctx); !errors.Is(err, llm.ErrTimeout) {
t.Fatalf("context deadline: err = %v, want ErrTimeout", err)
}
ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if err := p.Verify(ctx); !errors.Is(err, llm.ErrTimeout) {
t.Fatalf("Verify context deadline: err = %v, want ErrTimeout", err)
}
ctx, cancel = context.WithCancel(context.Background())
cancel()
if _, err := p.Generate(ctx, schemaRequest()); !errors.Is(err, context.Canceled) || errors.Is(err, llm.ErrTimeout) {
t.Fatalf("cancelled context: err = %v, want context.Canceled only", err)
}
}
// TestTransportErrorWithKeyInBaseURLPath fails the exchange against a base
// URL whose path holds the key, which New accepts; net/http names the full
// request URL in its transport errors.
func TestTransportErrorWithKeyInBaseURLPath(t *testing.T) {
refused := newVendorServer(t, reply{status: 200})
refusedBase := refused.URL
refused.Close()
dropped := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Error(err)
return
}
conn.Close()
}))
t.Cleanup(dropped.Close)
for name, base := range map[string]string{
"connection refused": refusedBase + "/" + testKey,
"connection dropped": dropped.URL + "/" + testKey,
} {
p := newProvider(t, base)
_, genErr := p.Generate(context.Background(), schemaRequest())
_, listErr := p.ListModels(context.Background())
verifyErr := p.Verify(context.Background())
for _, err := range []error{genErr, listErr, verifyErr} {
if err == nil {
t.Fatalf("%s: nil error", name)
}
assertNoSecret(t, err)
assertChainNoExactKey(t, err, testKey)
var ue *url.Error
if errors.As(err, &ue) {
t.Fatalf("%s: error chain exposes the request URL: %v", name, ue)
}
if errors.Is(err, llm.ErrTimeout) || errors.Is(err, llm.ErrProvider) {
t.Fatalf("%s: err = %v, want an untyped transport failure", name, err)
}
}
}
// The request URL escapes a key with spaces, so redacting the key
// itself would miss that copy.
const spacedKey = "sk ant key"
p := newProviderWithKey(t, refusedBase+"/"+spacedKey, spacedKey)
_, genErr := p.Generate(context.Background(), textRequest())
_, listErr := p.ListModels(context.Background())
verifyErr := p.Verify(context.Background())
for _, err := range []error{genErr, listErr, verifyErr} {
if err == nil {
t.Fatal("escaped key: nil error")
}
assertChainNoExactKey(t, err, spacedKey)
assertChainNoExactKey(t, err, url.PathEscape(spacedKey))
}
}
func TestNewRejectsBadConfig(t *testing.T) {
cases := map[string]Config{
"empty base": {},
"relative base": {BaseURL: "api.anthropic.com"},
"ftp base": {BaseURL: "ftp://example.com"},
"credentials in URL": {BaseURL: "https://user:" + testKey + "@example.com"},
"key in query": {BaseURL: "https://example.com/?key=" + testKey},
"key in fragment": {BaseURL: "https://example.com/#" + testKey},
"key, relative base": {BaseURL: "example.com?key=" + testKey},
"key, ftp base": {BaseURL: "ftp://example.com/?key=" + testKey},
"key, unparseable": {BaseURL: "https://example.com/%zz?key=" + testKey},
"empty query": {BaseURL: "https://example.com?"},
"empty fragment": {BaseURL: "https://example.com#"},
"empty both": {BaseURL: "https://example.com/?#"},
"empty key": {BaseURL: "https://example.com", APIKey: "-"},
"key with newline": {BaseURL: "https://example.com", APIKey: testKey + "\n"},
"zero max tokens": {BaseURL: "https://example.com", MaxTokens: -1},
"negative max": {BaseURL: "https://example.com", MaxTokens: -5},
}
for name, cfg := range cases {
switch cfg.APIKey {
case "":
cfg.APIKey = testKey
case "-":
cfg.APIKey = ""
}
switch cfg.MaxTokens {
case 0:
cfg.MaxTokens = 1024
case -1:
cfg.MaxTokens = 0
}
_, err := New(cfg)
if err == nil {
t.Errorf("%s: New() = nil error", name)
continue
}
assertNoSecret(t, err)
assertChainNoExactKey(t, err, testKey)
}
if _, err := New(Config{BaseURL: "http://anthropic:8080/proxy/", APIKey: testKey, MaxTokens: 1}); err != nil {
t.Errorf("New() rejected a valid config: %v", err)
}
}
func TestRetryAfter(t *testing.T) {
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
cases := map[string]time.Duration{
"": 0,
"30": 30 * time.Second,
" 5 ": 5 * time.Second,
"-3": 0,
"soon": 0,
"Sat, 03 Oct 2026 12:02:00 GMT": 2 * time.Minute,
"Sat, 03 Oct 2026 11:59:00 GMT": 0,
}
for in, want := range cases {
if got := retryAfter(in, now); got != want {
t.Errorf("retryAfter(%q) = %v, want %v", in, got, want)
}
}
}
// assertChainNoExactKey fails if key appears, case-sensitively, in the text
// of err, of any error reached from it by unwrapping, or of a typed error
// errors.As finds in it, including a ProviderError's Body.
func assertChainNoExactKey(t *testing.T, err error, key string) {
t.Helper()
var walk func(error)
walk = func(e error) {
if e == nil {
return
}
if strings.Contains(e.Error(), key) {
t.Fatalf("layer leaks %q: %q", key, e.Error())
}
switch u := e.(type) {
case interface{ Unwrap() error }:
walk(u.Unwrap())
case interface{ Unwrap() []error }:
for _, c := range u.Unwrap() {
walk(c)
}
}
}
walk(err)
for layer := err; layer != nil; layer = errors.Unwrap(layer) {
if strings.Contains(layer.Error(), key) {
t.Fatalf("errors.Unwrap layer leaks %q: %q", key, layer.Error())
}
}
var pe *llm.ProviderError
if errors.As(err, &pe) && (strings.Contains(pe.Body(), key) || strings.Contains(pe.Error(), key)) {
t.Fatalf("ProviderError leaks %q: Error %q, Body %q", key, pe.Error(), pe.Body())
}
var rl *llm.RateLimitedError
if errors.As(err, &rl) && strings.Contains(rl.Error(), key) {
t.Fatalf("RateLimitedError leaks %q: %q", key, rl.Error())
}
var syntax *json.SyntaxError
if errors.As(err, &syntax) && strings.Contains(syntax.Error(), key) {
t.Fatalf("errors.As reached a *json.SyntaxError leaking %q: %q", key, syntax.Error())
}
if got := fmt.Sprintf("%v %+v %#v", err, err, err); strings.Contains(got, key) {
t.Fatalf("formatting leaks %q: %q", key, got)
}
}
// TestHeaderNameKeyNeverLeaks uses the header name "x-api-key" as the API
// key, a word an error naming the header would print, and drives every
// construction and request error path with it.
func TestHeaderNameKeyNeverLeaks(t *testing.T) {
const key = "x-api-key"
construction := map[string]Config{
"unparseable base": {BaseURL: "https://example.com/%zz/" + key, APIKey: key, MaxTokens: 1},
"relative base": {BaseURL: key, APIKey: key, MaxTokens: 1},
"query in base": {BaseURL: "https://example.com/?" + key, APIKey: key, MaxTokens: 1},
"userinfo in base": {BaseURL: "https://" + key + "@example.com", APIKey: key, MaxTokens: 1},
"no max tokens": {BaseURL: "https://example.com", APIKey: key},
"key with newline": {BaseURL: "https://example.com", APIKey: key + "\r\n", MaxTokens: 1},
}
for name, cfg := range construction {
_, err := New(cfg)
if err == nil {
t.Fatalf("%s: New() = nil error", name)
}
assertChainNoExactKey(t, err, key)
}
body := `{"type":"error","error":{"type":"x","message":"bad x-api-key header: x-api-key"}}`
statuses := []int{400, 401, 403, 404, 408, 429, 500, 529}
for _, status := range statuses {
vs := newVendorServerWithKey(t, key, reply{status: status, body: body, header: map[string]string{"Retry-After": "3"}})
p := newProviderWithKey(t, vs.URL, key)
_, genErr := p.Generate(context.Background(), schemaRequest())
_, listErr := p.ListModels(context.Background())
verifyErr := p.Verify(context.Background())
for _, err := range []error{genErr, listErr, verifyErr} {
if err == nil {
t.Fatalf("HTTP %d: nil error", status)
}
assertChainNoExactKey(t, err, key)
}
}
ok := newVendorServerWithKey(t, key, reply{status: 200, fixture: "messages_text.json"})
p := newProviderWithKey(t, ok.URL, key)
requests := map[string]llm.Request{
"unknown role": {Messages: []llm.Message{{Role: llm.Role(key), Content: "hi"}}},
"bad tool name": {Messages: textRequest().Messages, Schema: &llm.JSONSchema{Name: key + "!", Schema: dinnersSchema}},
"schema array": {Messages: textRequest().Messages, Schema: &llm.JSONSchema{Name: key, Schema: json.RawMessage(`[]`)}},
"bad output": {Messages: textRequest().Messages, Schema: &llm.JSONSchema{Name: key, Schema: dinnersSchema}},
}
for name, req := range requests {
_, err := p.Generate(context.Background(), req)
if err == nil {
t.Fatalf("%s: nil error", name)
}
assertChainNoExactKey(t, err, key)
}
noModel, err := New(Config{BaseURL: ok.URL, APIKey: key, MaxTokens: 1})
if err != nil {
t.Fatal(err)
}
_, err = noModel.Generate(context.Background(), textRequest())
if err == nil {
t.Fatal("Generate succeeded with no model")
}
assertChainNoExactKey(t, err, key)
refused := newVendorServer(t, reply{status: 200})
refusedBase := refused.URL
refused.Close()
_, err = newProviderWithKey(t, refusedBase+"/"+key, key).Generate(context.Background(), textRequest())
if err == nil {
t.Fatal("Generate against a closed server succeeded")
}
assertChainNoExactKey(t, err, key)
}
// TestAdapterNameKeyOn401 uses the adapter's own error prefix "anthropic" as
// the API key.
func TestAdapterNameKeyOn401(t *testing.T) {
const key = "anthropic"
vs := newVendorServerWithKey(t, key, reply{status: 401, fixture: "error_authentication.json"})
p := newProviderWithKey(t, vs.URL, key)
_, genErr := p.Generate(context.Background(), schemaRequest())
_, listErr := p.ListModels(context.Background())
verifyErr := p.Verify(context.Background())
for _, err := range []error{genErr, listErr, verifyErr} {
if !errors.Is(err, llm.ErrAuth) {
t.Fatalf("err = %v, want ErrAuth", err)
}
assertChainNoExactKey(t, err, key)
}
}
// TestFixedWordKeyOn500 uses the fixed word "HTTP" of ProviderError's text
// as the API key: errors.As must still reach the ProviderError, whose Body
// and Error must drop it.
func TestFixedWordKeyOn500(t *testing.T) {
const key = "HTTP"
vs := newVendorServerWithKey(t, key, reply{status: 500, body: `{"type":"error","error":{"type":"api_error","message":"HTTP backend down"}}`})
_, err := newProviderWithKey(t, vs.URL, key).Generate(context.Background(), schemaRequest())
var pe *llm.ProviderError
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != 500 {
t.Fatalf("err = %v, want a 500 ProviderError", err)
}
if strings.Contains(pe.Error(), key) || strings.Contains(pe.Body(), key) {
t.Fatalf("ProviderError leaks the key: Error %q, Body %q", pe.Error(), pe.Body())
}
if !strings.Contains(pe.Body(), "backend down") {
t.Fatalf("Body() = %q, want the redacted excerpt", pe.Body())
}
assertChainNoExactKey(t, err, key)
}
// TestRateLimitedFixedWordKey uses the fixed word "rate" of
// RateLimitedError's text as the API key, on both rate-limit statuses.
func TestRateLimitedFixedWordKey(t *testing.T) {
const key = "rate"
for _, status := range []int{429, 529} {
vs := newVendorServerWithKey(t, key, reply{status: status, header: map[string]string{"Retry-After": "17"}, fixture: "error_rate_limit.json"})
_, err := newProviderWithKey(t, vs.URL, key).Generate(context.Background(), schemaRequest())
var rl *llm.RateLimitedError
if !errors.Is(err, llm.ErrRateLimited) || !errors.As(err, &rl) || rl.RetryAfter != 17*time.Second {
t.Fatalf("HTTP %d: err = %v, want a RateLimitedError with RetryAfter 17s", status, err)
}
assertChainNoExactKey(t, err, key)
}
}
// TestResponseBodyWithKeyNeverLeaks has the vendor echo the key in error
// and success bodies, including ones that fail to decode, and checks
// Error(), every errors.Unwrap layer and every errors.As target.
func TestResponseBodyWithKeyNeverLeaks(t *testing.T) {
bodies := []reply{
{status: 500, fixture: "error_api.json"},
{status: 400, body: `{"type":"error","error":{"type":"invalid_request_error","message":"key ` + testKey + ` rejected"}}`},
{status: 401, fixture: "error_authentication.json"},
{status: 429, fixture: "error_rate_limit.json"},
{status: 200, body: `{"type": ` + testKey + `}`},
{status: 200, body: testKey},
{status: 200, body: `{"type":"message","content":[{"type":"text","text":"` + testKey + `"}],"stop_reason":"` + testKey + `"}`},
{status: 200, body: `{"data": [` + testKey + `]}`},
}
for i, r := range bodies {
vs := newVendorServer(t, r)
p := newProvider(t, vs.URL)
_, genErr := p.Generate(context.Background(), schemaRequest())
_, listErr := p.ListModels(context.Background())
verifyErr := p.Verify(context.Background())
for _, err := range []error{genErr, listErr, verifyErr} {
if err == nil {
t.Fatalf("body %d: nil error", i)
}
assertNoSecret(t, err)
assertChainNoExactKey(t, err, testKey)
}
}
// A short key that decode errors quote from a malformed body.
const key = "Z"
vs := newVendorServerWithKey(t, key, reply{status: 200, body: `{"type": Z}`})
_, err := newProviderWithKey(t, vs.URL, key).Generate(context.Background(), textRequest())
var syntax *json.SyntaxError
if err == nil || errors.As(err, &syntax) {
t.Fatalf("err = %v, want no reachable *json.SyntaxError quoting the key", err)
}
assertChainNoExactKey(t, err, key)
}
// TestRedactKeepsChainMatches checks that the redaction backstop keeps
// errors.Is and errors.As working through a chain that holds the key.
func TestRedactKeepsChainMatches(t *testing.T) {
pe := llm.NewProviderError(500, []byte(`{"error":"down"}`), testKey)
rl := llm.NewRateLimitedError(time.Second, testKey)
inner := fmt.Errorf("decode %s: %w", testKey, llm.ErrBadOutput)
joined := errors.Join(inner, pe, rl, context.Canceled, &transportFailure{detail: "slow", kind: llm.ErrTimeout}, fmt.Errorf("HTTP 401: %w", llm.ErrAuth))
err := redact(fmt.Errorf("anthropic %s: %w", testKey, joined), testKey)
assertChainNoExactKey(t, err, testKey)
for _, target := range []error{llm.ErrBadOutput, llm.ErrProvider, llm.ErrRateLimited, llm.ErrTimeout, llm.ErrAuth, context.Canceled} {
if !errors.Is(err, target) {
t.Fatalf("errors.Is(%v, %v) = false", err, target)
}
}
var gotPE *llm.ProviderError
var gotRL *llm.RateLimitedError
if !errors.As(err, &gotPE) || gotPE != pe || !errors.As(err, &gotRL) || gotRL != rl {
t.Fatalf("errors.As lost a typed error in %v", err)
}
clean := errors.New("anthropic: generate: no model configured")
if redact(clean, testKey) != clean {
t.Fatal("redact wrapped an error that holds no key")
}
}
+41
View File
@@ -0,0 +1,41 @@
# Anthropic adapter fixtures
These are the Messages API response bodies the contract tests in
`anthropic_test.go` replay. They are **not live captures**. Product AI is
tested with fakes, and live vendor calls are opt-in and never use
credentials, so no request was sent to Anthropic to make them. Each fixture
is a sanitized adaptation of the response shapes in Anthropic's public API
documentation:
- Messages: <https://docs.anthropic.com/en/api/messages>
- Tool use, including `tool_choice` `{type: tool}`:
<https://docs.anthropic.com/en/docs/build-with-claude/tool-use>
- List models: <https://docs.anthropic.com/en/api/models-list>
- Errors: <https://docs.anthropic.com/en/api/errors>
Sanitizing:
- IDs, request IDs, timestamps and token counts are illustrative values in
the documented format, not values from a real account.
- Message content and tool input are replaced with MealQuest dinner text.
- No real credential appears anywhere. `{{KEY}}` is a placeholder the tests
replace with a fake key, to prove the adapter redacts a key echoed back in
a vendor body.
Below, *not in the public example* marks fields or values that were added
for a test case and do not come from the documented example.
| Fixture | Shape | Not in the public example |
| --- | --- | --- |
| `messages_tool_use.json` | Message with one `tool_use` block, `stop_reason: tool_use`, `usage` with cache fields. | The tool name and input. |
| `messages_text.json` | Message with `text` blocks, `stop_reason: end_turn`. | A second text block, to test joining. |
| `messages_max_tokens.json` | Message with a `text` block, `stop_reason: max_tokens`. | — |
| `models_page1.json` | Models list page: `data`, `has_more: true`, `first_id`, `last_id`. | — |
| `models_page2.json` | Last models list page, `has_more: false`. | The second model has no `display_name`, to test the fallback to `id`. |
| `error_authentication.json` | Error, `authentication_error` (401). | The `{{KEY}}` text; added to test redaction. |
| `error_permission.json` | Error, `permission_error` (403). | — |
| `error_rate_limit.json` | Error, `rate_limit_error` (429). | The `{{KEY}}` text; added to test redaction. |
| `error_overloaded.json` | Error, `overloaded_error` (529). | — |
| `error_not_found.json` | Error, `not_found_error` (404). | — |
| `error_invalid_request.json` | Error, `invalid_request_error` (400). | — |
| `error_api.json` | Error, `api_error` (500). | The `x-api-key: {{KEY}}` text; added to test redaction. |
+1
View File
@@ -0,0 +1 @@
{"type": "error", "error": {"type": "api_error", "message": "An unexpected error has occurred internal to Anthropic's systems. x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G4"}
@@ -0,0 +1 @@
{"type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fy"}
@@ -0,0 +1 @@
{"type": "error", "error": {"type": "invalid_request_error", "message": "max_tokens: Field required"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G3"}
@@ -0,0 +1 @@
{"type": "error", "error": {"type": "not_found_error", "message": "model: claude-nonexistent"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G2"}
@@ -0,0 +1 @@
{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G1"}
@@ -0,0 +1 @@
{"type": "error", "error": {"type": "permission_error", "message": "Your API key does not have permission to use the specified resource."}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fz"}
@@ -0,0 +1 @@
{"type": "error", "error": {"type": "rate_limit_error", "message": "Number of request tokens has exceeded your per-minute rate limit for key {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G0"}
@@ -0,0 +1,12 @@
{
"id": "msg_01XFDUDYJgAACzvnptvVoYEL",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5",
"content": [
{"type": "text", "text": "Here are some Side quests for Our kitchen"}
],
"stop_reason": "max_tokens",
"stop_sequence": null,
"usage": {"input_tokens": 472, "output_tokens": 400}
}
+18
View File
@@ -0,0 +1,18 @@
{
"id": "msg_013Zva2CMHLNnXjNJJKqJ2EF",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5",
"content": [
{"type": "text", "text": "Try a crock pot chili: "},
{"type": "text", "text": "it is a Quick dinner."}
],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 2095,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 503
}
}
@@ -0,0 +1,22 @@
{
"id": "msg_01Aq9w938a90dw8q",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5",
"content": [
{
"type": "tool_use",
"id": "toolu_01A09q90qw90lq917835lq9",
"name": "side_quests",
"input": {"dinners": [{"name": "Sheet-pan gnocchi", "why": "Like your Regular tray bake, but quicker."}]}
}
],
"stop_reason": "tool_use",
"stop_sequence": null,
"usage": {
"input_tokens": 472,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 61
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"data": [
{
"type": "model",
"id": "claude-sonnet-5",
"display_name": "Claude Sonnet 5",
"created_at": "2026-02-24T00:00:00Z"
}
],
"has_more": true,
"first_id": "claude-sonnet-5",
"last_id": "claude-sonnet-5"
}
+18
View File
@@ -0,0 +1,18 @@
{
"data": [
{
"type": "model",
"id": "claude-haiku-4-5-20251001",
"display_name": "Claude Haiku 4.5",
"created_at": "2025-10-01T00:00:00Z"
},
{
"type": "model",
"id": "claude-opus-5-5",
"created_at": "2026-05-01T00:00:00Z"
}
],
"has_more": false,
"first_id": "claude-haiku-4-5-20251001",
"last_id": "claude-opus-5-5"
}