Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 d11cdbbefb Keep %#v of every LLM adapter error free of the API key (MQ-03)
CI / ci (push) Successful in 1m29s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (pull_request) Successful in 1m28s
%#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 <noreply@anthropic.com>
2026-10-04 08:05:17 -04:00
dev_agentandClaude Opus 5.5 cdf59c4eee Stop LLM adapters following redirects and cap Retry-After
CI / ci (push) Successful in 1m29s
CI / ci (pull_request) Successful in 1m29s
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 <noreply@anthropic.com>
2026-10-04 07:57:37 -04:00
dev_agentandClaude Opus 5.5 4f26ea5ad6 Keep the API key out of %#v for redacted LLM errors
CI / ci (pull_request) Successful in 1m28s
CI / ci (push) Successful in 1m28s
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 <noreply@anthropic.com>
2026-10-04 07:52:04 -04:00
dev_agentandClaude Opus 5.5 3b36f07cb8 Feature 08 task 3: Anthropic Messages adapter
Add internal/ai/llm/anthropic, an llm.Provider over plain net/http:

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:49:52 -04:00
18 changed files with 1909 additions and 23 deletions
+560
View File
@@ -0,0 +1,560 @@
// Package anthropic is the llm.Provider adapter for Anthropic's Messages
// API. It calls POST {base}/v1/messages and GET {base}/v1/models over plain
// net/http.
//
// The adapter never logs, and no error it returns contains the API key:
// errors do not quote caller- or vendor-supplied text, and every exported
// method passes its error through redact as a backstop.
package anthropic
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net"
"net/http"
"net/url"
"reflect"
"regexp"
"strconv"
"strings"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// APIVersion is the Messages API version sent as the anthropic-version
// header.
const APIVersion = "2023-06-01"
// Config configures a Provider.
type Config struct {
// BaseURL is the API root the /v1 endpoint paths are appended to, e.g.
// "https://api.anthropic.com".
BaseURL string
// APIKey is sent as the x-api-key header. It must not be empty.
APIKey string
// Model is the model Generate requests. ListModels and Verify do not
// need it.
Model string
// MaxTokens is the max_tokens Generate sends when a Request sets none:
// the Messages API requires one. It must be positive.
MaxTokens int
}
// Provider is an Anthropic Messages API llm.Provider.
type Provider struct {
base string
apiKey string
model string
maxTokens int
client *http.Client
}
var _ llm.Provider = (*Provider)(nil)
// New validates cfg and returns a Provider.
func New(cfg Config) (*Provider, error) {
p, err := newFromConfig(cfg)
if err != nil {
return nil, redact(err, cfg.APIKey)
}
return p, nil
}
func newFromConfig(cfg Config) (*Provider, error) {
// No error from New quotes caller-supplied text: any of it may equal or
// contain the API key. Base URL errors never echo the URL.
u, err := url.Parse(cfg.BaseURL)
if err != nil {
return nil, errors.New("anthropic: base URL does not parse")
}
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, errors.New("anthropic: base URL must be an absolute http or https URL")
}
// A trailing "?" or "#" parses to an empty query or fragment, yet the
// endpoint path appended to it would land in the query or fragment.
if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") {
return nil, errors.New("anthropic: base URL must not carry credentials, a query or a fragment")
}
if cfg.APIKey == "" {
return nil, errors.New("anthropic: no API key configured")
}
if !validHeaderValue(cfg.APIKey) {
return nil, errors.New("anthropic: API key has a control character")
}
if cfg.MaxTokens <= 0 {
return nil, errors.New("anthropic: max tokens must be positive")
}
return &Provider{
base: strings.TrimRight(cfg.BaseURL, "/"),
apiKey: cfg.APIKey,
model: cfg.Model,
maxTokens: cfg.MaxTokens,
client: noRedirectClient(),
}, nil
}
// validHeaderValue reports whether value is an RFC 9110 field-value: no
// control characters other than horizontal tab, so no CR, LF or DEL.
func validHeaderValue(value string) bool {
for i := 0; i < len(value); i++ {
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
return false
}
}
return true
}
// toolName is the pattern the Messages API requires of a tool name.
var toolName = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
// maxResponseBody caps a successful response body; a larger one is an error.
const maxResponseBody = 16 << 20
// maxErrorBody caps how much of an error response body is read. It is far
// above llm.MaxBodyExcerpt, so redaction still sees every secret that could
// reach the excerpt.
const maxErrorBody = 64 << 10
type message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type tool struct {
Name string `json:"name"`
InputSchema json.RawMessage `json:"input_schema"`
}
type toolChoice struct {
Type string `json:"type"`
Name string `json:"name"`
}
type messagesRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system,omitempty"`
Messages []message `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
Tools []tool `json:"tools,omitempty"`
ToolChoice *toolChoice `json:"tool_choice,omitempty"`
}
type messagesResponse struct {
Type string `json:"type"`
Model string `json:"model"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
} `json:"content"`
StopReason string `json:"stop_reason"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
// Generate sends req to {base}/v1/messages. A Request with a Schema is sent
// as one tool whose input_schema is the schema, with tool_choice forcing
// it; Response.JSON is then that tool's input.
func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
resp, err := p.generate(ctx, req)
return resp, redact(err, p.apiKey)
}
func (p *Provider) generate(ctx context.Context, req llm.Request) (llm.Response, error) {
if err := req.Validate(); err != nil {
return llm.Response{}, fmt.Errorf("anthropic: generate: %w", err)
}
if p.model == "" {
return llm.Response{}, errors.New("anthropic: generate: no model configured")
}
body := messagesRequest{
Model: p.model,
MaxTokens: p.maxTokens,
System: req.System,
Temperature: req.Temperature,
}
if req.MaxTokens > 0 {
body.MaxTokens = req.MaxTokens
}
if req.Schema != nil {
if !toolName.MatchString(req.Schema.Name) {
return llm.Response{}, errors.New("anthropic: generate: schema name is not a valid tool name")
}
body.Tools = []tool{{Name: req.Schema.Name, InputSchema: req.Schema.Schema}}
body.ToolChoice = &toolChoice{Type: "tool", Name: req.Schema.Name}
}
for _, m := range req.Messages {
body.Messages = append(body.Messages, message{Role: string(m.Role), Content: m.Content})
}
payload, err := json.Marshal(body)
if err != nil {
return llm.Response{}, fmt.Errorf("anthropic: generate: encode request: %w", err)
}
if req.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
defer cancel()
}
raw, err := p.do(ctx, http.MethodPost, "/v1/messages", payload)
if err != nil {
return llm.Response{}, fmt.Errorf("anthropic: generate: %w", err)
}
var mr messagesResponse
if err := json.Unmarshal(raw, &mr); err != nil {
return llm.Response{}, fmt.Errorf("anthropic: generate: decode response: %w", err)
}
if mr.Type != "message" {
return llm.Response{}, errors.New("anthropic: generate: response is not a message")
}
resp := llm.Response{
Usage: llm.Usage{InputTokens: mr.Usage.InputTokens, OutputTokens: mr.Usage.OutputTokens},
Model: mr.Model,
FinishReason: mr.StopReason,
}
var text strings.Builder
for _, block := range mr.Content {
switch block.Type {
case "text":
text.WriteString(block.Text)
case "tool_use":
if req.Schema != nil && block.Name == req.Schema.Name && resp.JSON == nil {
resp.JSON = block.Input
}
}
}
resp.Text = text.String()
if req.Schema == nil {
return resp, nil
}
if !isJSONObject(resp.JSON) {
resp.JSON = nil
return resp, fmt.Errorf("anthropic: generate: reply holds no input for the forced tool: %w", llm.ErrBadOutput)
}
return resp, nil
}
// isJSONObject reports whether raw is exactly one JSON object.
func isJSONObject(raw json.RawMessage) bool {
s := bytes.TrimSpace(raw)
return bytes.HasPrefix(s, []byte("{")) && json.Valid(s)
}
type modelsResponse struct {
Data []struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
} `json:"data"`
HasMore bool `json:"has_more"`
LastID string `json:"last_id"`
}
// modelsPageLimit is the largest page GET /v1/models serves.
const modelsPageLimit = 1000
// ListModels returns every model {base}/v1/models lists, following pages.
func (p *Provider) ListModels(ctx context.Context) ([]llm.Model, error) {
models, err := p.listModels(ctx)
return models, redact(err, p.apiKey)
}
func (p *Provider) listModels(ctx context.Context) ([]llm.Model, error) {
models := []llm.Model{}
after := ""
for {
page, err := p.modelsPage(ctx, modelsPageLimit, after)
if err != nil {
return nil, fmt.Errorf("anthropic: list models: %w", err)
}
for _, m := range page.Data {
if m.ID == "" {
return nil, fmt.Errorf("anthropic: list models: model %d has no id", len(models))
}
name := m.DisplayName
if name == "" {
name = m.ID
}
models = append(models, llm.Model{ID: m.ID, DisplayName: name})
}
if !page.HasMore {
return models, nil
}
if page.LastID == "" || page.LastID == after {
return nil, errors.New("anthropic: list models: response has more pages but no new cursor")
}
after = page.LastID
}
}
// modelsPage fetches one page of GET {base}/v1/models.
func (p *Provider) modelsPage(ctx context.Context, limit int, after string) (modelsResponse, error) {
q := url.Values{"limit": {strconv.Itoa(limit)}}
if after != "" {
q.Set("after_id", after)
}
raw, err := p.do(ctx, http.MethodGet, "/v1/models?"+q.Encode(), nil)
if err != nil {
return modelsResponse{}, err
}
var mr modelsResponse
if err := json.Unmarshal(raw, &mr); err != nil {
return modelsResponse{}, fmt.Errorf("decode response: %w", err)
}
if mr.Data == nil {
return modelsResponse{}, errors.New("response has no data list")
}
return mr, nil
}
// Verify checks the credentials with the cheapest authenticated call: one
// page of GET {base}/v1/models holding at most one model.
func (p *Provider) Verify(ctx context.Context) error {
_, err := p.modelsPage(ctx, 1, "")
if err != nil {
err = fmt.Errorf("anthropic: verify: %w", err)
}
return redact(err, p.apiKey)
}
// do sends one request and returns the body of a 2xx response. Any other
// status becomes the matching llm typed error.
func (p *Provider) do(ctx context.Context, method, path string, payload []byte) ([]byte, error) {
var reqBody io.Reader
if payload != nil {
reqBody = bytes.NewReader(payload)
}
httpReq, err := http.NewRequestWithContext(ctx, method, p.base+path, reqBody)
if err != nil {
// The parse error names the URL, whose path may hold the API key.
return nil, errors.New("build request: request URL is invalid")
}
httpReq.Header.Set("x-api-key", p.apiKey)
httpReq.Header.Set("anthropic-version", APIVersion)
httpReq.Header.Set("Accept", "application/json")
if payload != nil {
httpReq.Header.Set("Content-Type", "application/json")
}
httpResp, err := p.client.Do(httpReq)
if err != nil {
return nil, p.transportError(err)
}
defer httpResp.Body.Close()
if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 {
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxErrorBody))
if err != nil {
return nil, p.transportError(err)
}
return nil, p.statusError(httpResp, body)
}
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBody+1))
if err != nil {
return nil, p.transportError(err)
}
if len(body) > maxResponseBody {
return nil, fmt.Errorf("response body exceeds %d bytes", maxResponseBody)
}
return body, nil
}
// statusOverloaded is the status the Messages API returns when it is
// temporarily overloaded.
const statusOverloaded = 529
// statusError maps a non-2xx response to an llm typed error.
func (p *Provider) statusError(resp *http.Response, body []byte) error {
switch resp.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrAuth)
case http.StatusTooManyRequests, statusOverloaded:
return llm.NewRateLimitedError(retryAfter(resp.Header.Get("Retry-After"), time.Now()), p.apiKey)
case http.StatusRequestTimeout:
return 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.ParseInt(value, 10, 64); err == nil || errors.Is(err, strconv.ErrRange) {
if secs < 0 {
return 0
}
// time.Duration counts nanoseconds in an int64; a larger wait is
// capped to the most whole seconds it holds instead of overflowing.
if secs > maxRetryAfterSeconds {
secs = maxRetryAfterSeconds
}
return time.Duration(secs) * time.Second
}
if at, err := http.ParseTime(value); err == nil && at.After(now) {
return at.Sub(now).Round(time.Second)
}
return 0
}
// maxRetryAfterSeconds is the largest delay-seconds value retryAfter
// returns unchanged: the most whole seconds a time.Duration holds.
const maxRetryAfterSeconds = math.MaxInt64 / int64(time.Second)
// noRedirectClient returns an HTTP client that never follows a redirect.
// A followed redirect would resend the credential headers, which net/http
// strips only for its own list of sensitive headers and only across hosts;
// instead the 3xx response is returned and becomes a provider error.
func noRedirectClient() *http.Client {
return &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
func isTimeout(err error) bool {
var ne net.Error
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
}
// transportFailure is a failed HTTP exchange. It carries the cause's text
// with the request URL removed and the API key redacted, and unwraps only to
// the cause's category: llm.ErrTimeout, context.Canceled or nothing.
type transportFailure struct {
text string
kind error
}
func (e *transportFailure) Error() string { return e.text }
// GoString keeps %#v to the redacted Error text instead of the fields.
func (e *transportFailure) GoString() string { return e.text }
func (e *transportFailure) Unwrap() error { return e.kind }
// transportError reports a failure to complete the HTTP exchange. A
// *url.Error names the request URL, and a base URL path may hold the API
// key, so only the wrapped cause is kept, rendered with the key redacted.
func (p *Provider) transportError(err error) error {
var kind error
switch {
case isTimeout(err):
kind = llm.ErrTimeout
case errors.Is(err, context.Canceled):
kind = context.Canceled
}
var ue *url.Error
if errors.As(err, &ue) {
err = ue.Err
}
text := "transport: " + err.Error()
if kind == llm.ErrTimeout {
text = fmt.Sprintf("%s: %s", llm.ErrTimeout, text)
}
return &transportFailure{text: llm.Redact(text, p.apiKey), kind: kind}
}
// redactedError stands in for one layer of an error chain that holds the
// API key. Its text is the layer's text with the key redacted, and Unwrap
// returns the layer's causes passed through redact, so no error reached by
// unwrapping has text holding the key. Is consults the original layer and
// its chain, so sentinels still match. As does too. The llm typed errors
// remove the key from their own complete text, fixed words included, so
// they always match; As refuses a match whose text, or whose %#v form,
// holds the key, so it never hands back an error that can print it.
type redactedError struct {
text string
err error
key string
}
func (e *redactedError) Error() string { return e.text }
// GoString keeps %#v from printing the fields, which hold the key.
func (e *redactedError) GoString() string { return e.text }
func (e *redactedError) Unwrap() error { return redact(errors.Unwrap(e.err), e.key) }
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
func (e *redactedError) As(target any) bool {
if !errors.As(e.err, target) {
return false
}
found := reflect.ValueOf(target).Elem()
matched, isErr := found.Interface().(error)
if (isErr && holdsKey(matched, e.key)) || strings.Contains(fmt.Sprintf("%#v", found.Interface()), e.key) {
found.SetZero()
return false
}
return true
}
// redactedJoin is redactedError for a layer with several causes, such as
// one built by errors.Join or by fmt.Errorf with more than one %w.
type redactedJoin struct {
redactedError
}
func (e *redactedJoin) Unwrap() []error {
causes := e.err.(interface{ Unwrap() []error }).Unwrap()
safe := make([]error, len(causes))
for i, c := range causes {
safe[i] = redact(c, e.key)
}
return safe
}
// redact is the backstop every exported method applies to its error: with
// a non-empty key, it returns a stand-in whose text, and the text of every
// error it unwraps to, has the key redacted. Every non-nil err is wrapped,
// even one whose text lacks the key, because %#v of an unwrapped error
// prints its struct field names, and a key may spell one.
func redact(err error, key string) error {
if err == nil || key == "" {
return err
}
re := redactedError{text: llm.Redact(err.Error(), key), err: err, key: key}
if _, ok := err.(interface{ Unwrap() []error }); ok {
return &redactedJoin{re}
}
return &re
}
// holdsKey reports whether the text of err or of any error in its chain
// contains key.
func holdsKey(err error, key string) bool {
if err == nil {
return false
}
if strings.Contains(err.Error(), key) {
return true
}
switch u := err.(type) {
case interface{ Unwrap() error }:
return holdsKey(u.Unwrap(), key)
case interface{ Unwrap() []error }:
for _, c := range u.Unwrap() {
if holdsKey(c, key) {
return true
}
}
}
return false
}
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
# Anthropic adapter fixtures
These are the Messages API response bodies the contract tests in
`anthropic_test.go` replay. They are **not live captures**. Product AI is
tested with fakes, and live vendor calls are opt-in and never use
credentials, so no request was sent to Anthropic to make them. Each fixture
is a sanitized adaptation of the response shapes in Anthropic's public API
documentation:
- Messages: <https://docs.anthropic.com/en/api/messages>
- Tool use, including `tool_choice` `{type: tool}`:
<https://docs.anthropic.com/en/docs/build-with-claude/tool-use>
- List models: <https://docs.anthropic.com/en/api/models-list>
- Errors: <https://docs.anthropic.com/en/api/errors>
Sanitizing:
- IDs, request IDs, timestamps and token counts are illustrative values in
the documented format, not values from a real account.
- Message content and tool input are replaced with MealQuest dinner text.
- No real credential appears anywhere. `{{KEY}}` is a placeholder the tests
replace with a fake key, to prove the adapter redacts a key echoed back in
a vendor body.
Below, *not in the public example* marks fields or values that were added
for a test case and do not come from the documented example.
| Fixture | Shape | Not in the public example |
| --- | --- | --- |
| `messages_tool_use.json` | Message with one `tool_use` block, `stop_reason: tool_use`, `usage` with cache fields. | The tool name and input. |
| `messages_text.json` | Message with `text` blocks, `stop_reason: end_turn`. | A second text block, to test joining. |
| `messages_max_tokens.json` | Message with a `text` block, `stop_reason: max_tokens`. | — |
| `models_page1.json` | Models list page: `data`, `has_more: true`, `first_id`, `last_id`. | — |
| `models_page2.json` | Last models list page, `has_more: false`. | The second model has no `display_name`, to test the fallback to `id`. |
| `error_authentication.json` | Error, `authentication_error` (401). | The `{{KEY}}` text; added to test redaction. |
| `error_permission.json` | Error, `permission_error` (403). | — |
| `error_rate_limit.json` | Error, `rate_limit_error` (429). | The `{{KEY}}` text; added to test redaction. |
| `error_overloaded.json` | Error, `overloaded_error` (529). | — |
| `error_not_found.json` | Error, `not_found_error` (404). | — |
| `error_invalid_request.json` | Error, `invalid_request_error` (400). | — |
| `error_api.json` | Error, `api_error` (500). | The `x-api-key: {{KEY}}` text; added to test redaction. |
+1
View File
@@ -0,0 +1 @@
{"type": "error", "error": {"type": "api_error", "message": "An unexpected error has occurred internal to Anthropic's systems. x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G4"}
@@ -0,0 +1 @@
{"type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fy"}
@@ -0,0 +1 @@
{"type": "error", "error": {"type": "invalid_request_error", "message": "max_tokens: Field required"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G3"}
@@ -0,0 +1 @@
{"type": "error", "error": {"type": "not_found_error", "message": "model: claude-nonexistent"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G2"}
@@ -0,0 +1 @@
{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G1"}
@@ -0,0 +1 @@
{"type": "error", "error": {"type": "permission_error", "message": "Your API key does not have permission to use the specified resource."}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fz"}
@@ -0,0 +1 @@
{"type": "error", "error": {"type": "rate_limit_error", "message": "Number of request tokens has exceeded your per-minute rate limit for key {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G0"}
@@ -0,0 +1,12 @@
{
"id": "msg_01XFDUDYJgAACzvnptvVoYEL",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5",
"content": [
{"type": "text", "text": "Here are some Side quests for Our kitchen"}
],
"stop_reason": "max_tokens",
"stop_sequence": null,
"usage": {"input_tokens": 472, "output_tokens": 400}
}
+18
View File
@@ -0,0 +1,18 @@
{
"id": "msg_013Zva2CMHLNnXjNJJKqJ2EF",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5",
"content": [
{"type": "text", "text": "Try a crock pot chili: "},
{"type": "text", "text": "it is a Quick dinner."}
],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 2095,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 503
}
}
@@ -0,0 +1,22 @@
{
"id": "msg_01Aq9w938a90dw8q",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5",
"content": [
{
"type": "tool_use",
"id": "toolu_01A09q90qw90lq917835lq9",
"name": "side_quests",
"input": {"dinners": [{"name": "Sheet-pan gnocchi", "why": "Like your Regular tray bake, but quicker."}]}
}
],
"stop_reason": "tool_use",
"stop_sequence": null,
"usage": {
"input_tokens": 472,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 61
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"data": [
{
"type": "model",
"id": "claude-sonnet-5",
"display_name": "Claude Sonnet 5",
"created_at": "2026-02-24T00:00:00Z"
}
],
"has_more": true,
"first_id": "claude-sonnet-5",
"last_id": "claude-sonnet-5"
}
+18
View File
@@ -0,0 +1,18 @@
{
"data": [
{
"type": "model",
"id": "claude-haiku-4-5-20251001",
"display_name": "Claude Haiku 4.5",
"created_at": "2025-10-01T00:00:00Z"
},
{
"type": "model",
"id": "claude-opus-5-5",
"created_at": "2026-05-01T00:00:00Z"
}
],
"has_more": false,
"first_id": "claude-haiku-4-5-20251001",
"last_id": "claude-opus-5-5"
}
+7
View File
@@ -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 }
+48 -19
View File
@@ -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())
@@ -483,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 }
@@ -511,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
@@ -520,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
@@ -530,6 +554,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) }
@@ -539,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
}
@@ -561,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}
+138 -4
View File
@@ -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 {
@@ -882,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") {
@@ -938,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)
@@ -964,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)
}
@@ -1119,3 +1122,134 @@ 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)
}
}
}
}
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)
}
}
// 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)
}
}