Redact the API key from preset configuration and validation errors
FromSettings errors and the configured provider's request-validation errors now pass through a presets redactedError: its text has the key removed with llm.Redact, it does not unwrap to the unredacted layers, and Is still matches the llm sentinels. A key that equals a request value (temperature -0.5) or a setting no longer appears in the error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,12 +6,14 @@
|
|||||||
// FromSettings lives here rather than in package llm because it builds the
|
// FromSettings lives here rather than in package llm because it builds the
|
||||||
// adapters, and every adapter imports llm.
|
// adapters, and every adapter imports llm.
|
||||||
//
|
//
|
||||||
// Nothing in this package logs, and no error it returns contains the API
|
// Nothing in this package logs, and no error it returns or unwraps to
|
||||||
// key or quotes a setting's value.
|
// contains the API key, even where the key equals a setting's or request's
|
||||||
|
// value or text the error spells anyway.
|
||||||
package presets
|
package presets
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -169,6 +171,14 @@ var all = map[string]Preset{
|
|||||||
// nil; a request that sets one keeps it. Neither is sent to a model the
|
// nil; a request that sets one keeps it. Neither is sent to a model the
|
||||||
// preset's OmitTemperature matches.
|
// preset's OmitTemperature matches.
|
||||||
func FromSettings(s llm.Settings, key string) (llm.Provider, error) {
|
func FromSettings(s llm.Settings, key string) (llm.Provider, error) {
|
||||||
|
cp, err := fromSettings(s, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, redact(err, key)
|
||||||
|
}
|
||||||
|
return cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromSettings(s llm.Settings, key string) (*configured, error) {
|
||||||
p, ok := Lookup(s.Provider)
|
p, ok := Lookup(s.Provider)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, llm.ErrUnknownProvider
|
return nil, llm.ErrUnknownProvider
|
||||||
@@ -205,7 +215,7 @@ func FromSettings(s llm.Settings, key string) (llm.Provider, error) {
|
|||||||
return nil, fmt.Errorf("%s: %w: %w", p.Name, llm.ErrInvalidSetting, err)
|
return nil, fmt.Errorf("%s: %w: %w", p.Name, llm.ErrInvalidSetting, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cp := &configured{Provider: inner}
|
cp := &configured{Provider: inner, key: key}
|
||||||
if !p.omitsTemperature(s.Model) {
|
if !p.omitsTemperature(s.Model) {
|
||||||
t := s.Temperature
|
t := s.Temperature
|
||||||
cp.temperature = &t
|
cp.temperature = &t
|
||||||
@@ -246,6 +256,9 @@ func check(p Preset, s llm.Settings, key string) error {
|
|||||||
// temperature quirk to every Generate.
|
// temperature quirk to every Generate.
|
||||||
type configured struct {
|
type configured struct {
|
||||||
llm.Provider
|
llm.Provider
|
||||||
|
// key is the API key, redacted from validation errors; the adapter
|
||||||
|
// redacts its own.
|
||||||
|
key string
|
||||||
// temperature is sent when a request sets none; nil means the model
|
// temperature is sent when a request sets none; nil means the model
|
||||||
// rejects temperature, so none is ever sent.
|
// rejects temperature, so none is ever sent.
|
||||||
temperature *float64
|
temperature *float64
|
||||||
@@ -256,7 +269,7 @@ type configured struct {
|
|||||||
// hides an invalid one.
|
// hides an invalid one.
|
||||||
func (c *configured) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
func (c *configured) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||||
if err := req.Validate(); err != nil {
|
if err := req.Validate(); err != nil {
|
||||||
return llm.Response{}, fmt.Errorf("presets: generate: %w", err)
|
return llm.Response{}, redact(fmt.Errorf("presets: generate: %w", err), c.key)
|
||||||
}
|
}
|
||||||
if c.temperature == nil {
|
if c.temperature == nil {
|
||||||
req.Temperature = nil
|
req.Temperature = nil
|
||||||
@@ -266,3 +279,28 @@ func (c *configured) Generate(ctx context.Context, req llm.Request) (llm.Respons
|
|||||||
}
|
}
|
||||||
return c.Provider.Generate(ctx, req)
|
return c.Provider.Generate(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// redactedError is an error with the API key removed from its text. It
|
||||||
|
// does not unwrap, because every layer below may hold the key; Is still
|
||||||
|
// matches the sentinels the original error wraps.
|
||||||
|
type redactedError struct {
|
||||||
|
text string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *redactedError) Error() string { return e.text }
|
||||||
|
|
||||||
|
// GoString keeps %#v to the redacted Error text instead of the fields.
|
||||||
|
func (e *redactedError) GoString() string { return e.text }
|
||||||
|
|
||||||
|
// Is reports whether the original error matches target.
|
||||||
|
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
||||||
|
|
||||||
|
// redact returns err with key removed from its text; a nil err or an empty
|
||||||
|
// key returns err unchanged.
|
||||||
|
func redact(err error, key string) error {
|
||||||
|
if err == nil || key == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return &redactedError{text: llm.Redact(err.Error(), key), err: err}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -399,3 +400,67 @@ func TestReadyProviderForEveryPreset(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A key that matches a value the request carries, or text the error spells
|
||||||
|
// anyway, never appears in a validation error, and nothing is sent.
|
||||||
|
func TestGenerateValidationErrorsNeverLeakKey(t *testing.T) {
|
||||||
|
rt := &recordingTransport{}
|
||||||
|
orig := http.DefaultTransport
|
||||||
|
http.DefaultTransport = rt
|
||||||
|
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||||
|
|
||||||
|
for _, key := range []string{"-0.5", "temperature", "llm", "negative"} {
|
||||||
|
p, err := FromSettings(llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m"}, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: FromSettings: %v", key, err)
|
||||||
|
}
|
||||||
|
req := plainRequest()
|
||||||
|
req.Temperature = ptr(-0.5)
|
||||||
|
_, err = p.Generate(context.Background(), req)
|
||||||
|
if err == nil || errors.Is(err, errStop) {
|
||||||
|
t.Fatalf("%s: Generate = %v, want a validation error", key, err)
|
||||||
|
}
|
||||||
|
for e := error(err); e != nil; e = errors.Unwrap(e) {
|
||||||
|
if s := e.Error() + fmt.Sprintf("%#v", e); strings.Contains(s, key) {
|
||||||
|
t.Fatalf("%s: error leaks the key: %s", key, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rt.reqs) != 0 {
|
||||||
|
t.Fatalf("sent %d requests, want 0", len(rt.reqs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A configuration error keeps its sentinel but never holds the key, even
|
||||||
|
// when the key is also a setting's value or text the error spells anyway.
|
||||||
|
func TestConfigErrorsNeverLeakKey(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
s llm.Settings
|
||||||
|
key string
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"unknown provider named by key", llm.Settings{Provider: "llm", Model: "m"}, "llm", llm.ErrUnknownProvider},
|
||||||
|
{"no base URL", llm.Settings{Provider: "openai_compatible", Model: "m"}, "base URL", llm.ErrMissingBaseURL},
|
||||||
|
{"no model", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1"}, "model", llm.ErrMissingModel},
|
||||||
|
{"key as model, no public URL", llm.Settings{Provider: "openrouter", Model: "missing"}, "missing", llm.ErrMissingPublicURL},
|
||||||
|
{"key as base URL", llm.Settings{Provider: "openai", BaseURL: "llm", Model: "m"}, "llm", llm.ErrInvalidSetting},
|
||||||
|
{"key as mode", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m", StructuredOutput: "xml"}, "xml", llm.ErrInvalidSetting},
|
||||||
|
{"key as temperature", llm.Settings{Provider: "openai", Model: "m", Temperature: -0.1}, "-0.1", llm.ErrInvalidSetting},
|
||||||
|
{"key in bad base URL", llm.Settings{Provider: "openai_compatible", BaseURL: "ftp://k3y", Model: "m"}, "k3y", llm.ErrInvalidSetting},
|
||||||
|
{"ollama with key", llm.Settings{Provider: "ollama", BaseURL: "http://o:11434", Model: "m"}, "key", llm.ErrUnexpectedKey},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
p, err := FromSettings(c.s, c.key)
|
||||||
|
if p != nil || !errors.Is(err, c.want) {
|
||||||
|
t.Fatalf("FromSettings = %v, %v; want %v", p, err, c.want)
|
||||||
|
}
|
||||||
|
for e := error(err); e != nil; e = errors.Unwrap(e) {
|
||||||
|
if s := e.Error() + fmt.Sprintf("%#v", e); strings.Contains(s, c.key) {
|
||||||
|
t.Fatalf("error leaks the key: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user