Anthropic Messages adapter (#40)
CI / ci (push) Successful in 1m27s

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #40.
This commit is contained in:
2026-10-04 08:09:20 -04:00
committed by approver_agent
parent acd29c8221
commit 15886f2c55
18 changed files with 1909 additions and 23 deletions
+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)
}
}