Never quote request or reply text in errors and redact the key as a backstop

Request.Validate no longer quotes the role or schema name, and the
bad-output error no longer quotes finish_reason: any of that text may
equal the API key. New, Generate, ListModels and Verify now pass every
error through redact, which removes the key (and so "Bearer "+key) from
the error text, wrapped errors included, while Unwrap keeps the chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 07:24:37 -04:00
co-authored by Claude Opus 5.5
parent f333440256
commit 2afd6a9338
3 changed files with 162 additions and 8 deletions
+99
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -785,3 +786,101 @@ func TestRetryAfter(t *testing.T) {
}
}
}
// headerNameKey is an API key that is also a word callers and vendors put in
// text: an error that quotes such text prints the key.
const headerNameKey = "Authorization"
// assertNoKey fails if err's text holds key or "Bearer "+key in any case.
func assertNoKey(t *testing.T, err error, key string) {
t.Helper()
for _, secret := range []string{key, "Bearer " + key} {
if strings.Contains(err.Error(), secret) || strings.Contains(strings.ToLower(err.Error()), strings.ToLower(secret)) {
t.Fatalf("error leaks %q: %v", secret, err)
}
}
}
// keyServer replies to every request with status and body.
func keyServer(t *testing.T, status int, body string) *Provider {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
io.WriteString(w, body)
}))
t.Cleanup(srv.Close)
p, err := New(Config{BaseURL: srv.URL + "/v1", APIKey: headerNameKey, Model: "test-model", StructuredOutput: PromptOnly})
if err != nil {
t.Fatal(err)
}
return p
}
func TestGenerateInvalidRoleNeverLeaksKey(t *testing.T) {
p := keyServer(t, 200, `{}`)
_, err := p.Generate(context.Background(), llm.Request{Messages: []llm.Message{{Role: llm.Role(headerNameKey), Content: "hi"}}})
if err == nil {
t.Fatal("Generate accepted an unknown role")
}
assertNoKey(t, err, headerNameKey)
}
func TestGenerateInvalidSchemaNeverLeaksKey(t *testing.T) {
p := keyServer(t, 200, `{}`)
req := schemaRequest()
req.Schema = &llm.JSONSchema{Name: headerNameKey, Schema: json.RawMessage(`[]`)}
_, err := p.Generate(context.Background(), req)
if err == nil {
t.Fatal("Generate accepted a schema that is not a JSON object")
}
assertNoKey(t, err, headerNameKey)
}
func TestGenerateBadOutputNeverLeaksKey(t *testing.T) {
p := keyServer(t, 200, `{"model":"m","choices":[{"message":{"content":"no JSON here"},"finish_reason":"`+headerNameKey+`"}]}`)
resp, err := p.Generate(context.Background(), schemaRequest())
if !errors.Is(err, llm.ErrBadOutput) {
t.Fatalf("err = %v, want ErrBadOutput", err)
}
assertNoKey(t, err, headerNameKey)
if resp.FinishReason != headerNameKey {
t.Fatalf("FinishReason = %q, want it kept on the response", resp.FinishReason)
}
}
func TestProviderErrorBodyNeverLeaksKey(t *testing.T) {
p := keyServer(t, 500, `{"error":{"message":"rejected `+headerNameKey+`: Bearer `+headerNameKey+`"}}`)
_, genErr := p.Generate(context.Background(), schemaRequest())
_, listErr := p.ListModels(context.Background())
verifyErr := p.Verify(context.Background())
for _, err := range []error{genErr, listErr, verifyErr} {
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)
}
assertNoKey(t, err, headerNameKey)
}
}
// TestRedactKeepsChainAndHidesKey checks the backstop every exported method
// applies: the key and its Bearer value leave the text, the chain stays.
func TestRedactKeepsChainAndHidesKey(t *testing.T) {
inner := fmt.Errorf("decode: body %q: %w", "Bearer "+testKey, llm.ErrBadOutput)
err := redact(inner, testKey)
assertNoSecret(t, err)
if !errors.Is(err, llm.ErrBadOutput) || errors.Unwrap(err) != inner {
t.Fatalf("redact broke the chain of %v", err)
}
if redact(inner, "") != inner {
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")
}
// A key inside the redaction marker would survive replacement.
if got := redact(errors.New("x E y"), "E").Error(); strings.Contains(got, "E") {
t.Fatalf("redact kept the key: %q", got)
}
}