From 3b36f07cb8e772fab72da47ea0d8aedec022c0a6 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 4 Oct 2026 07:49:52 -0400 Subject: [PATCH 1/4] 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 --- internal/ai/llm/anthropic/anthropic.go | 534 +++++++++++ internal/ai/llm/anthropic/anthropic_test.go | 889 ++++++++++++++++++ internal/ai/llm/anthropic/testdata/README.md | 41 + .../ai/llm/anthropic/testdata/error_api.json | 1 + .../testdata/error_authentication.json | 1 + .../testdata/error_invalid_request.json | 1 + .../anthropic/testdata/error_not_found.json | 1 + .../anthropic/testdata/error_overloaded.json | 1 + .../anthropic/testdata/error_permission.json | 1 + .../anthropic/testdata/error_rate_limit.json | 1 + .../testdata/messages_max_tokens.json | 12 + .../llm/anthropic/testdata/messages_text.json | 18 + .../anthropic/testdata/messages_tool_use.json | 22 + .../llm/anthropic/testdata/models_page1.json | 13 + .../llm/anthropic/testdata/models_page2.json | 18 + 15 files changed, 1554 insertions(+) create mode 100644 internal/ai/llm/anthropic/anthropic.go create mode 100644 internal/ai/llm/anthropic/anthropic_test.go create mode 100644 internal/ai/llm/anthropic/testdata/README.md create mode 100644 internal/ai/llm/anthropic/testdata/error_api.json create mode 100644 internal/ai/llm/anthropic/testdata/error_authentication.json create mode 100644 internal/ai/llm/anthropic/testdata/error_invalid_request.json create mode 100644 internal/ai/llm/anthropic/testdata/error_not_found.json create mode 100644 internal/ai/llm/anthropic/testdata/error_overloaded.json create mode 100644 internal/ai/llm/anthropic/testdata/error_permission.json create mode 100644 internal/ai/llm/anthropic/testdata/error_rate_limit.json create mode 100644 internal/ai/llm/anthropic/testdata/messages_max_tokens.json create mode 100644 internal/ai/llm/anthropic/testdata/messages_text.json create mode 100644 internal/ai/llm/anthropic/testdata/messages_tool_use.json create mode 100644 internal/ai/llm/anthropic/testdata/models_page1.json create mode 100644 internal/ai/llm/anthropic/testdata/models_page2.json diff --git a/internal/ai/llm/anthropic/anthropic.go b/internal/ai/llm/anthropic/anthropic.go new file mode 100644 index 0000000..2891d2f --- /dev/null +++ b/internal/ai/llm/anthropic/anthropic.go @@ -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 +} diff --git a/internal/ai/llm/anthropic/anthropic_test.go b/internal/ai/llm/anthropic/anthropic_test.go new file mode 100644 index 0000000..c437b52 --- /dev/null +++ b/internal/ai/llm/anthropic/anthropic_test.go @@ -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") + } +} diff --git a/internal/ai/llm/anthropic/testdata/README.md b/internal/ai/llm/anthropic/testdata/README.md new file mode 100644 index 0000000..c69c707 --- /dev/null +++ b/internal/ai/llm/anthropic/testdata/README.md @@ -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: +- Tool use, including `tool_choice` `{type: tool}`: + +- List models: +- 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. | diff --git a/internal/ai/llm/anthropic/testdata/error_api.json b/internal/ai/llm/anthropic/testdata/error_api.json new file mode 100644 index 0000000..56710aa --- /dev/null +++ b/internal/ai/llm/anthropic/testdata/error_api.json @@ -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"} diff --git a/internal/ai/llm/anthropic/testdata/error_authentication.json b/internal/ai/llm/anthropic/testdata/error_authentication.json new file mode 100644 index 0000000..a6e1e61 --- /dev/null +++ b/internal/ai/llm/anthropic/testdata/error_authentication.json @@ -0,0 +1 @@ +{"type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fy"} diff --git a/internal/ai/llm/anthropic/testdata/error_invalid_request.json b/internal/ai/llm/anthropic/testdata/error_invalid_request.json new file mode 100644 index 0000000..977b7fc --- /dev/null +++ b/internal/ai/llm/anthropic/testdata/error_invalid_request.json @@ -0,0 +1 @@ +{"type": "error", "error": {"type": "invalid_request_error", "message": "max_tokens: Field required"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G3"} diff --git a/internal/ai/llm/anthropic/testdata/error_not_found.json b/internal/ai/llm/anthropic/testdata/error_not_found.json new file mode 100644 index 0000000..f54171f --- /dev/null +++ b/internal/ai/llm/anthropic/testdata/error_not_found.json @@ -0,0 +1 @@ +{"type": "error", "error": {"type": "not_found_error", "message": "model: claude-nonexistent"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G2"} diff --git a/internal/ai/llm/anthropic/testdata/error_overloaded.json b/internal/ai/llm/anthropic/testdata/error_overloaded.json new file mode 100644 index 0000000..013e4b3 --- /dev/null +++ b/internal/ai/llm/anthropic/testdata/error_overloaded.json @@ -0,0 +1 @@ +{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G1"} diff --git a/internal/ai/llm/anthropic/testdata/error_permission.json b/internal/ai/llm/anthropic/testdata/error_permission.json new file mode 100644 index 0000000..80941f6 --- /dev/null +++ b/internal/ai/llm/anthropic/testdata/error_permission.json @@ -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"} diff --git a/internal/ai/llm/anthropic/testdata/error_rate_limit.json b/internal/ai/llm/anthropic/testdata/error_rate_limit.json new file mode 100644 index 0000000..136220f --- /dev/null +++ b/internal/ai/llm/anthropic/testdata/error_rate_limit.json @@ -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"} diff --git a/internal/ai/llm/anthropic/testdata/messages_max_tokens.json b/internal/ai/llm/anthropic/testdata/messages_max_tokens.json new file mode 100644 index 0000000..64f86c1 --- /dev/null +++ b/internal/ai/llm/anthropic/testdata/messages_max_tokens.json @@ -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} +} diff --git a/internal/ai/llm/anthropic/testdata/messages_text.json b/internal/ai/llm/anthropic/testdata/messages_text.json new file mode 100644 index 0000000..16a947d --- /dev/null +++ b/internal/ai/llm/anthropic/testdata/messages_text.json @@ -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 + } +} diff --git a/internal/ai/llm/anthropic/testdata/messages_tool_use.json b/internal/ai/llm/anthropic/testdata/messages_tool_use.json new file mode 100644 index 0000000..5a092b3 --- /dev/null +++ b/internal/ai/llm/anthropic/testdata/messages_tool_use.json @@ -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 + } +} diff --git a/internal/ai/llm/anthropic/testdata/models_page1.json b/internal/ai/llm/anthropic/testdata/models_page1.json new file mode 100644 index 0000000..5d7c9ba --- /dev/null +++ b/internal/ai/llm/anthropic/testdata/models_page1.json @@ -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" +} diff --git a/internal/ai/llm/anthropic/testdata/models_page2.json b/internal/ai/llm/anthropic/testdata/models_page2.json new file mode 100644 index 0000000..126cf98 --- /dev/null +++ b/internal/ai/llm/anthropic/testdata/models_page2.json @@ -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" +} -- 2.55.0 From 4f26ea5ad6ede161a87b2e8ab04d66ec845b656f Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 4 Oct 2026 07:52:04 -0400 Subject: [PATCH 2/4] Keep the API key out of %#v for redacted LLM errors The OpenAI adapter's redactedError had no GoString, so %#v printed its fields, including the raw API key. Add GoString returning the redacted text, matching the Anthropic adapter; redactedJoin inherits it. Add TestRedactedErrorsFormatWithoutKey to both adapters, formatting redactedError, redactedJoin and transportFailure with %v, %+v and %#v. llm.ProviderError and llm.RateLimitedError keep no secret and are already covered by TestTypedErrorsDoNotKeepSecret. Co-Authored-By: Claude Opus 5.5 --- internal/ai/llm/anthropic/anthropic_test.go | 23 +++++++++++++++++++++ internal/ai/llm/openai/openai.go | 3 +++ internal/ai/llm/openai/openai_test.go | 23 +++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/internal/ai/llm/anthropic/anthropic_test.go b/internal/ai/llm/anthropic/anthropic_test.go index c437b52..8137121 100644 --- a/internal/ai/llm/anthropic/anthropic_test.go +++ b/internal/ai/llm/anthropic/anthropic_test.go @@ -887,3 +887,26 @@ func TestRedactKeepsChainMatches(t *testing.T) { t.Fatal("redact wrapped an error that holds no key") } } + +// TestRedactedErrorsFormatWithoutKey formats every error type that holds the +// key, or a cause holding it, with %v, %+v and %#v; %#v prints GoString. +func TestRedactedErrorsFormatWithoutKey(t *testing.T) { + const key = "sk-distinctive-anthropic-format-7c1e" + single := redact(fmt.Errorf("anthropic %s: %w", key, llm.ErrBadOutput), key) + joined := redact(errors.Join(fmt.Errorf("decode %s: %w", key, llm.ErrAuth), llm.NewProviderError(500, []byte("down "+key), key)), key) + if _, ok := single.(*redactedError); !ok { + t.Fatalf("single = %T, want *redactedError", single) + } + if _, ok := joined.(*redactedJoin); !ok { + t.Fatalf("joined = %T, want *redactedJoin", joined) + } + p := &Provider{apiKey: key} + transport := p.transportError(fmt.Errorf("dial %s: %w", key, context.DeadlineExceeded)) + for _, err := range []error{single, joined, transport} { + for _, verb := range []string{"%v", "%+v", "%#v"} { + if got := fmt.Sprintf(verb, err); strings.Contains(got, key) { + t.Fatalf("%s of %T leaks the key: %q", verb, err, got) + } + } + } +} diff --git a/internal/ai/llm/openai/openai.go b/internal/ai/llm/openai/openai.go index e6493de..33eb4c8 100644 --- a/internal/ai/llm/openai/openai.go +++ b/internal/ai/llm/openai/openai.go @@ -530,6 +530,9 @@ type redactedError struct { 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) } diff --git a/internal/ai/llm/openai/openai_test.go b/internal/ai/llm/openai/openai_test.go index 8f0a248..bbdd442 100644 --- a/internal/ai/llm/openai/openai_test.go +++ b/internal/ai/llm/openai/openai_test.go @@ -1119,3 +1119,26 @@ func TestGenerateWithAdapterNameKeyNeverLeaksKey(t *testing.T) { assertChainNoExactKey(t, err, key) }) } + +// TestRedactedErrorsFormatWithoutKey formats every error type that holds the +// key, or a cause holding it, with %v, %+v and %#v; %#v prints GoString. +func TestRedactedErrorsFormatWithoutKey(t *testing.T) { + const key = "sk-distinctive-openai-format-7c1e" + single := redact(fmt.Errorf("openai %s: %w", key, llm.ErrBadOutput), key) + joined := redact(errors.Join(fmt.Errorf("decode %s: %w", key, llm.ErrAuth), llm.NewProviderError(500, []byte("down "+key), key)), key) + if _, ok := single.(*redactedError); !ok { + t.Fatalf("single = %T, want *redactedError", single) + } + if _, ok := joined.(*redactedJoin); !ok { + t.Fatalf("joined = %T, want *redactedJoin", joined) + } + p := &Provider{apiKey: key} + transport := p.transportError(fmt.Errorf("dial %s: %w", key, context.DeadlineExceeded)) + for _, err := range []error{single, joined, transport} { + for _, verb := range []string{"%v", "%+v", "%#v"} { + if got := fmt.Sprintf(verb, err); strings.Contains(got, key) { + t.Fatalf("%s of %T leaks the key: %q", verb, err, got) + } + } + } +} -- 2.55.0 From cdf59c4eee58803f79258ce397c3c5cee023c87b Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 4 Oct 2026 07:57:37 -0400 Subject: [PATCH 3/4] Stop LLM adapters following redirects and cap Retry-After MQ-01: the Anthropic adapter used a default http.Client, so a 3xx from the configured endpoint was followed and the x-api-key header resent to the redirect target; net/http strips only its own sensitive headers. Both adapters now use a client whose CheckRedirect returns ErrUseLastResponse, so a 3xx comes back as an llm.ProviderError and nothing is requested again. The OpenAI-compatible adapter gets the same change: it forwards configured extra headers (which may carry a credential and which net/http does not treat as sensitive) across hosts, and net/http keeps Authorization on a same-host https-to-http redirect. MQ-02: a delay-seconds Retry-After too large for time.Duration overflowed into a negative wait. retryAfter now caps the value at the most whole seconds a time.Duration holds (also for values beyond int64) and the response still maps to ErrRateLimited. Tests: TestRedirectIsNotFollowed (302 to a second server, which must get no request) and TestRateLimitedHugeRetryAfterIsCapped in both packages, plus huge-number cases in TestRetryAfter. Co-Authored-By: Claude Opus 5.5 --- internal/ai/llm/anthropic/anthropic.go | 26 ++++++++- internal/ai/llm/anthropic/anthropic_test.go | 60 +++++++++++++++++++++ internal/ai/llm/openai/openai.go | 26 ++++++++- internal/ai/llm/openai/openai_test.go | 60 +++++++++++++++++++++ 4 files changed, 168 insertions(+), 4 deletions(-) diff --git a/internal/ai/llm/anthropic/anthropic.go b/internal/ai/llm/anthropic/anthropic.go index 2891d2f..3625db8 100644 --- a/internal/ai/llm/anthropic/anthropic.go +++ b/internal/ai/llm/anthropic/anthropic.go @@ -14,6 +14,7 @@ import ( "errors" "fmt" "io" + "math" "net" "net/http" "net/url" @@ -94,7 +95,7 @@ func newFromConfig(cfg Config) (*Provider, error) { apiKey: cfg.APIKey, model: cfg.Model, maxTokens: cfg.MaxTokens, - client: &http.Client{}, + client: noRedirectClient(), }, nil } @@ -394,10 +395,15 @@ func retryAfter(value string, now time.Time) time.Duration { if value == "" { return 0 } - if secs, err := strconv.Atoi(value); err == nil { + 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) { @@ -406,6 +412,22 @@ func retryAfter(value string, now time.Time) time.Duration { 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()) diff --git a/internal/ai/llm/anthropic/anthropic_test.go b/internal/ai/llm/anthropic/anthropic_test.go index 8137121..ccae5b1 100644 --- a/internal/ai/llm/anthropic/anthropic_test.go +++ b/internal/ai/llm/anthropic/anthropic_test.go @@ -647,6 +647,9 @@ func TestRetryAfter(t *testing.T) { "soon": 0, "Sat, 03 Oct 2026 12:02:00 GMT": 2 * time.Minute, "Sat, 03 Oct 2026 11:59:00 GMT": 0, + "9223372036854775807": time.Duration(maxRetryAfterSeconds) * time.Second, + "99999999999999999999": time.Duration(maxRetryAfterSeconds) * time.Second, + "-99999999999999999999": 0, } for in, want := range cases { if got := retryAfter(in, now); got != want { @@ -910,3 +913,60 @@ func TestRedactedErrorsFormatWithoutKey(t *testing.T) { } } } + +func TestRateLimitedHugeRetryAfterIsCapped(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "9223372036854775807") + w.WriteHeader(http.StatusTooManyRequests) + })) + t.Cleanup(srv.Close) + p, err := New(Config{BaseURL: srv.URL, APIKey: testKey, Model: "test-model", MaxTokens: 1024}) + if err != nil { + t.Fatal(err) + } + err = p.Verify(context.Background()) + var rl *llm.RateLimitedError + if !errors.Is(err, llm.ErrRateLimited) || !errors.As(err, &rl) || rl.RetryAfter != time.Duration(maxRetryAfterSeconds)*time.Second { + t.Fatalf("err = %v, want a RateLimitedError with RetryAfter capped to %v", err, time.Duration(maxRetryAfterSeconds)*time.Second) + } +} + +func TestRedirectIsNotFollowed(t *testing.T) { + var mu sync.Mutex + var secondCalls int + var secondHeader http.Header + second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + mu.Lock() + secondCalls++ + secondHeader = req.Header.Clone() + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(second.Close) + first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, second.URL+req.URL.Path, http.StatusFound) + })) + t.Cleanup(first.Close) + p, err := New(Config{BaseURL: first.URL, APIKey: testKey, Model: "test-model", MaxTokens: 1024}) + if err != nil { + t.Fatal(err) + } + + err = p.Verify(context.Background()) + var pe *llm.ProviderError + if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != http.StatusFound { + t.Fatalf("err = %v, want a ProviderError for HTTP 302", err) + } + mu.Lock() + defer mu.Unlock() + if secondCalls != 0 { + for name, values := range secondHeader { + for _, v := range values { + if strings.Contains(v, testKey) { + t.Errorf("redirect target saw the key in %s", name) + } + } + } + t.Fatalf("redirect target got %d requests, want 0", secondCalls) + } +} diff --git a/internal/ai/llm/openai/openai.go b/internal/ai/llm/openai/openai.go index 33eb4c8..a467e9b 100644 --- a/internal/ai/llm/openai/openai.go +++ b/internal/ai/llm/openai/openai.go @@ -15,6 +15,7 @@ import ( "errors" "fmt" "io" + "math" "net" "net/http" "net/textproto" @@ -132,7 +133,7 @@ func newFromConfig(cfg Config) (*Provider, error) { model: cfg.Model, headers: headers, mode: cfg.StructuredOutput, - client: &http.Client{}, + client: noRedirectClient(), }, nil } @@ -462,10 +463,15 @@ func retryAfter(value string, now time.Time) time.Duration { if value == "" { return 0 } - if secs, err := strconv.Atoi(value); err == nil { + 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) { @@ -474,6 +480,22 @@ func retryAfter(value string, now time.Time) time.Duration { 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()) diff --git a/internal/ai/llm/openai/openai_test.go b/internal/ai/llm/openai/openai_test.go index bbdd442..23d4c16 100644 --- a/internal/ai/llm/openai/openai_test.go +++ b/internal/ai/llm/openai/openai_test.go @@ -779,6 +779,9 @@ func TestRetryAfter(t *testing.T) { "soon": 0, "Sat, 03 Oct 2026 12:02:00 GMT": 2 * time.Minute, "Sat, 03 Oct 2026 11:59:00 GMT": 0, + "9223372036854775807": time.Duration(maxRetryAfterSeconds) * time.Second, + "99999999999999999999": time.Duration(maxRetryAfterSeconds) * time.Second, + "-99999999999999999999": 0, } for in, want := range cases { if got := retryAfter(in, now); got != want { @@ -1142,3 +1145,60 @@ func TestRedactedErrorsFormatWithoutKey(t *testing.T) { } } } + +func TestRateLimitedHugeRetryAfterIsCapped(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "9223372036854775807") + w.WriteHeader(http.StatusTooManyRequests) + })) + t.Cleanup(srv.Close) + p, err := New(Config{BaseURL: srv.URL + "/v1", APIKey: testKey, Model: "test-model", Headers: map[string]string{"X-Title": testKey}, StructuredOutput: PromptOnly}) + if err != nil { + t.Fatal(err) + } + err = p.Verify(context.Background()) + var rl *llm.RateLimitedError + if !errors.Is(err, llm.ErrRateLimited) || !errors.As(err, &rl) || rl.RetryAfter != time.Duration(maxRetryAfterSeconds)*time.Second { + t.Fatalf("err = %v, want a RateLimitedError with RetryAfter capped to %v", err, time.Duration(maxRetryAfterSeconds)*time.Second) + } +} + +func TestRedirectIsNotFollowed(t *testing.T) { + var mu sync.Mutex + var secondCalls int + var secondHeader http.Header + second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + mu.Lock() + secondCalls++ + secondHeader = req.Header.Clone() + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(second.Close) + first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, second.URL+req.URL.Path, http.StatusFound) + })) + t.Cleanup(first.Close) + p, err := New(Config{BaseURL: first.URL + "/v1", APIKey: testKey, Model: "test-model", Headers: map[string]string{"X-Title": testKey}, StructuredOutput: PromptOnly}) + if err != nil { + t.Fatal(err) + } + + err = p.Verify(context.Background()) + var pe *llm.ProviderError + if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != http.StatusFound { + t.Fatalf("err = %v, want a ProviderError for HTTP 302", err) + } + mu.Lock() + defer mu.Unlock() + if secondCalls != 0 { + for name, values := range secondHeader { + for _, v := range values { + if strings.Contains(v, testKey) { + t.Errorf("redirect target saw the key in %s", name) + } + } + } + t.Fatalf("redirect target got %d requests, want 0", secondCalls) + } +} -- 2.55.0 From d11cdbbefb655939696ad674e21dd7060f456646 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 4 Oct 2026 08:05:17 -0400 Subject: [PATCH 4/4] Keep %#v of every LLM adapter error free of the API key (MQ-03) %#v prints struct field names, so a key that spells one (for example "body" on ProviderError) leaked even though Error() was redacted. - llm.ProviderError and llm.RateLimitedError gain GoString returning their already-redacted Error text. - Both adapters' transportFailure now holds its fully redacted text and gains GoString. - The redact backstop now wraps every non-nil error when a key is set, so the returned error and every unwrapped layer print through redactedError.GoString, never a raw struct such as fmt.wrapError. - redactedError.As also refuses a match whose %#v form holds the key. - Contract test in both adapters: field-name keys (body, status, text, RetryAfter, msg, err, key, kind) on HTTP 500 "down" and 429, checking %#v of the returned error, every unwrapped layer and errors.As results. Co-Authored-By: Claude Opus 5.5 --- internal/ai/llm/anthropic/anthropic.go | 38 +++++++------ internal/ai/llm/anthropic/anthropic_test.go | 59 +++++++++++++++++++-- internal/ai/llm/errors.go | 7 +++ internal/ai/llm/openai/openai.go | 38 +++++++------ internal/ai/llm/openai/openai_test.go | 59 +++++++++++++++++++-- 5 files changed, 160 insertions(+), 41 deletions(-) diff --git a/internal/ai/llm/anthropic/anthropic.go b/internal/ai/llm/anthropic/anthropic.go index 3625db8..37b1239 100644 --- a/internal/ai/llm/anthropic/anthropic.go +++ b/internal/ai/llm/anthropic/anthropic.go @@ -437,16 +437,14 @@ func isTimeout(err error) bool { // 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 + text 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) 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 } @@ -465,7 +463,11 @@ func (p *Provider) transportError(err error) error { if errors.As(err, &ue) { err = ue.Err } - return &transportFailure{detail: llm.Redact(err.Error(), p.apiKey), kind: kind} + 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 @@ -474,8 +476,8 @@ func (p *Provider) transportError(err error) error { // 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. +// 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 @@ -496,7 +498,8 @@ func (e *redactedError) As(target any) bool { return false } found := reflect.ValueOf(target).Elem() - if matched, ok := found.Interface().(error); ok && holdsKey(matched, e.key) { + matched, isErr := found.Interface().(error) + if (isErr && holdsKey(matched, e.key)) || strings.Contains(fmt.Sprintf("%#v", found.Interface()), e.key) { found.SetZero() return false } @@ -518,12 +521,13 @@ func (e *redactedJoin) Unwrap() []error { 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. +// 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 == "" || !holdsKey(err, key) { + if err == nil || key == "" { return err } re := redactedError{text: llm.Redact(err.Error(), key), err: err, key: key} diff --git a/internal/ai/llm/anthropic/anthropic_test.go b/internal/ai/llm/anthropic/anthropic_test.go index ccae5b1..e2bab59 100644 --- a/internal/ai/llm/anthropic/anthropic_test.go +++ b/internal/ai/llm/anthropic/anthropic_test.go @@ -872,7 +872,7 @@ 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)) + joined := errors.Join(inner, pe, rl, context.Canceled, &transportFailure{text: "llm: request timed out: transport: 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} { @@ -886,8 +886,8 @@ func TestRedactKeepsChainMatches(t *testing.T) { 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") + if got := redact(clean, testKey); got.Error() != clean.Error() || !errors.Is(got, clean) { + t.Fatalf("redact(%v) = %v, want the same text and chain", clean, got) } } @@ -970,3 +970,56 @@ func TestRedirectIsNotFollowed(t *testing.T) { t.Fatalf("redirect target got %d requests, want 0", secondCalls) } } + +// assertGoStringNoKey checks that %#v of err, of every layer reached by +// unwrapping and of the typed errors reached by errors.As holds no key. +func assertGoStringNoKey(t *testing.T, err error, key string) { + t.Helper() + var walk func(error) + walk = func(e error) { + if e == nil { + return + } + if got := fmt.Sprintf("%#v", e); strings.Contains(got, key) { + t.Fatalf("%%#v of %T leaks %q: %q", e, key, got) + } + switch u := e.(type) { + case interface{ Unwrap() error }: + walk(u.Unwrap()) + case interface{ Unwrap() []error }: + for _, c := range u.Unwrap() { + walk(c) + } + } + } + walk(err) + var pe *llm.ProviderError + if errors.As(err, &pe) { + walk(pe) + } + var rl *llm.RateLimitedError + if errors.As(err, &rl) { + walk(rl) + } +} + +// TestFieldNameKeyNeverLeaksThroughGoString uses struct field names of the +// returned error types as the API key; %#v must not print any of them. +func TestFieldNameKeyNeverLeaksThroughGoString(t *testing.T) { + for _, key := range []string{"body", "status", "text", "RetryAfter", "msg", "err", "key", "kind"} { + vs := newVendorServerWithKey(t, key, reply{status: 500, body: "down"}, reply{status: 429, header: map[string]string{"Retry-After": "17"}, body: "slow"}) + p := newProviderWithKey(t, vs.URL, key) + _, err := p.Generate(context.Background(), schemaRequest()) + var pe *llm.ProviderError + if !errors.As(err, &pe) || pe.Status() != 500 { + t.Fatalf("key %q: err = %v, want a 500 ProviderError", key, err) + } + assertGoStringNoKey(t, err, key) + _, err = p.Generate(context.Background(), schemaRequest()) + var rl *llm.RateLimitedError + if !errors.As(err, &rl) || rl.RetryAfter != 17*time.Second { + t.Fatalf("key %q: err = %v, want a RateLimitedError with RetryAfter 17s", key, err) + } + assertGoStringNoKey(t, err, key) + } +} diff --git a/internal/ai/llm/errors.go b/internal/ai/llm/errors.go index 07fddef..e4f1bd2 100644 --- a/internal/ai/llm/errors.go +++ b/internal/ai/llm/errors.go @@ -58,6 +58,9 @@ func (e *RateLimitedError) Error() string { return fmt.Sprintf("%s: retry after %v", ErrRateLimited, e.RetryAfter) } +// GoString keeps %#v to the redacted Error text instead of the fields. +func (e *RateLimitedError) GoString() string { return e.Error() } + // Is reports whether target is ErrRateLimited. func (e *RateLimitedError) Is(target error) bool { return target == ErrRateLimited } @@ -128,5 +131,9 @@ func (e *ProviderError) Body() string { return e.body } func (e *ProviderError) Error() string { return e.text } +// GoString keeps %#v to the redacted Error text instead of the fields, +// whose names could spell the credential. +func (e *ProviderError) GoString() string { return e.text } + // Is reports whether target is ErrProvider. func (e *ProviderError) Is(target error) bool { return target == ErrProvider } diff --git a/internal/ai/llm/openai/openai.go b/internal/ai/llm/openai/openai.go index a467e9b..4f21be7 100644 --- a/internal/ai/llm/openai/openai.go +++ b/internal/ai/llm/openai/openai.go @@ -505,16 +505,14 @@ func isTimeout(err error) bool { // 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 + text 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) 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 } @@ -533,7 +531,11 @@ func (p *Provider) transportError(err error) error { if errors.As(err, &ue) { err = ue.Err } - return &transportFailure{detail: llm.Redact(err.Error(), p.apiKey), kind: kind} + 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 @@ -542,8 +544,8 @@ func (p *Provider) transportError(err error) error { // 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. +// 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 @@ -564,7 +566,8 @@ func (e *redactedError) As(target any) bool { return false } found := reflect.ValueOf(target).Elem() - if matched, ok := found.Interface().(error); ok && holdsKey(matched, e.key) { + matched, isErr := found.Interface().(error) + if (isErr && holdsKey(matched, e.key)) || strings.Contains(fmt.Sprintf("%#v", found.Interface()), e.key) { found.SetZero() return false } @@ -586,12 +589,13 @@ func (e *redactedJoin) Unwrap() []error { 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. +// 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 == "" || !holdsKey(err, key) { + if err == nil || key == "" { return err } re := redactedError{text: llm.Redact(err.Error(), key), err: err, key: key} diff --git a/internal/ai/llm/openai/openai_test.go b/internal/ai/llm/openai/openai_test.go index 23d4c16..c953fe1 100644 --- a/internal/ai/llm/openai/openai_test.go +++ b/internal/ai/llm/openai/openai_test.go @@ -885,8 +885,8 @@ func TestRedactKeepsChainAndHidesKey(t *testing.T) { t.Fatal("redact changed an error for the empty key") } clean := errors.New("openai: generate: no model configured") - if redact(clean, testKey) != clean { - t.Fatal("redact wrapped an error that holds no key") + if got := redact(clean, testKey); got.Error() != clean.Error() || !errors.Is(got, clean) { + t.Fatalf("redact(%v) = %v, want the same text and chain", clean, got) } // A key inside the redaction marker would survive replacement. if got := redact(errors.New("x E y"), "E").Error(); strings.Contains(got, "E") { @@ -941,7 +941,7 @@ func TestRedactMakesEveryUnwrappedLayerSafe(t *testing.T) { fmt.Errorf("openai: generate: %w", inner), fmt.Errorf("openai: generate: %w", hidden), fmt.Errorf("openai: generate: %w", joined), - fmt.Errorf("openai: %w and %w", inner, &transportFailure{detail: "slow", kind: llm.ErrTimeout}), + fmt.Errorf("openai: %w and %w", inner, &transportFailure{text: "llm: request timed out: transport: slow", kind: llm.ErrTimeout}), } for _, c := range cases { err := redact(c, testKey) @@ -967,7 +967,7 @@ func TestRedactMakesEveryUnwrappedLayerSafe(t *testing.T) { if !errors.Is(err, llm.ErrAuth) { t.Fatalf("errors.Is lost ErrAuth in %v", err) } - err = redact(fmt.Errorf("openai %s: %w", testKey, &transportFailure{detail: "slow", kind: llm.ErrTimeout}), testKey) + err = redact(fmt.Errorf("openai %s: %w", testKey, &transportFailure{text: "llm: request timed out: transport: slow", kind: llm.ErrTimeout}), testKey) if !errors.Is(err, llm.ErrTimeout) { t.Fatalf("errors.Is lost ErrTimeout in %v", err) } @@ -1202,3 +1202,54 @@ func TestRedirectIsNotFollowed(t *testing.T) { t.Fatalf("redirect target got %d requests, want 0", secondCalls) } } + +// assertGoStringNoKey checks that %#v of err, of every layer reached by +// unwrapping and of the typed errors reached by errors.As holds no key. +func assertGoStringNoKey(t *testing.T, err error, key string) { + t.Helper() + var walk func(error) + walk = func(e error) { + if e == nil { + return + } + if got := fmt.Sprintf("%#v", e); strings.Contains(got, key) { + t.Fatalf("%%#v of %T leaks %q: %q", e, key, got) + } + switch u := e.(type) { + case interface{ Unwrap() error }: + walk(u.Unwrap()) + case interface{ Unwrap() []error }: + for _, c := range u.Unwrap() { + walk(c) + } + } + } + walk(err) + var pe *llm.ProviderError + if errors.As(err, &pe) { + walk(pe) + } + var rl *llm.RateLimitedError + if errors.As(err, &rl) { + walk(rl) + } +} + +// TestFieldNameKeyNeverLeaksThroughGoString uses struct field names of the +// returned error types as the API key; %#v must not print any of them. +func TestFieldNameKeyNeverLeaksThroughGoString(t *testing.T) { + for _, key := range []string{"body", "status", "text", "RetryAfter", "msg", "err", "key", "kind"} { + _, err := keyServerWithKey(t, key, 500, "down").Generate(context.Background(), schemaRequest()) + var pe *llm.ProviderError + if !errors.As(err, &pe) || pe.Status() != 500 { + t.Fatalf("key %q: err = %v, want a 500 ProviderError", key, err) + } + assertGoStringNoKey(t, err, key) + err = keyServerWithKey(t, key, 429, "slow").Verify(context.Background()) + var rl *llm.RateLimitedError + if !errors.As(err, &rl) { + t.Fatalf("key %q: err = %v, want a RateLimitedError", key, err) + } + assertGoStringNoKey(t, err, key) + } +} -- 2.55.0