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>
This commit is contained in:
2026-10-04 07:52:04 -04:00
co-authored by Claude Opus 5.5
parent 3b36f07cb8
commit 4f26ea5ad6
3 changed files with 49 additions and 0 deletions
@@ -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)
}
}
}
}
+3
View File
@@ -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) }
+23
View File
@@ -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)
}
}
}
}