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:
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user