Files
xarmian 442dcbdc6a feat(decision): instance-admin settings for the decision provider (TASK-3121) (#1426)
console/admin/settings gains a Decision provider section with a provider select,
an enable toggle, a write-only API key stored encrypted, a model pin, and the
privacy line. It is backed by GET/PUT /api/v1/admin/decision-provider.

- The admin setting is a third source to decision.ResolveNamed:
  config file < admin setting < environment, per field. Resolve is now
  implemented on ResolveNamed, so the page's "set by environment" comes from
  the same pass that builds the provider.
- Changing the setting rebuilds the provider and swaps the runner in place.
  The tick and the write doors' enqueue resolver both follow the swap, so no
  restart is needed. A swap cancels any pass in flight, so a disabled provider
  is not called again; the resolver is installed before the runner is
  published; and no tick can start after Stop().
- The key is encrypted through the PAD_ENCRYPTION_KEY path
  (store.SetSecretPlatformSetting). It is never returned by any read, masked
  or otherwise, and it is not writable through /admin/settings. The store
  refuses a secret write without an encryption key (`pad server` never
  reaches that state), and it refuses to READ a secret row that is not
  ciphertext.
- A PUT to a field the environment sets is refused with 409
  set_by_environment. On Pad Cloud the environment is the only source and PUT
  answers 403 managed_by_operator.
- `pad server info` still resolves only the file and the environment on the
  CLI host. It now says so, in an additive `sources` JSON member and in its
  human line.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-22 07:34:33 -04:00

173 lines
5.6 KiB
Go

package decision
import (
"fmt"
"os"
"strings"
)
// Provider names.
const (
// ProviderTypesafe selects the typesafe.ai Jev backend.
ProviderTypesafe = "typesafe"
// ProviderNone explicitly disables decisions. It exists so an
// environment variable can DISABLE a provider an instance-admin setting
// enabled: [Resolve] lets non-empty values win, and an empty string
// therefore means "this source has no opinion" rather than "off". See
// [Resolve] for why that distinction is load-bearing.
ProviderNone = "none"
)
// DefaultModel is the pinned Jev model.
//
// It is a pinned VERSION rather than "jev-latest" deliberately: the provider
// documents that jev-latest moves without notice, and a decision model that
// changes underneath stored answers makes those answers uncomparable across
// time. Callers who want the moving target must set it explicitly.
const DefaultModel = "jev-1.13.0"
// Config is the resolved decision-provider configuration.
//
// It is the SINGLE reader of provider settings. Three sources feed it, each
// a Config passed to [Resolve] / [ResolveNamed]: pad's config file, the
// instance-admin setting (TASK-3121), and the environment — in that order,
// later winning. See TASK-3116.
type Config struct {
// Provider is the backend name: "typesafe", "none", or "" when this
// source has no opinion.
Provider string
// APIKey authenticates to the backend. Never logged, never printed, and
// never included in an error message.
APIKey string
// Model pins the backend model. Empty falls back to [DefaultModel].
Model string
}
// Enabled reports whether this config selects a real provider.
func (c Config) Enabled() bool {
p := strings.TrimSpace(strings.ToLower(c.Provider))
return p != "" && p != ProviderNone
}
// EnvConfig reads the decision-provider settings from the environment.
//
// The variables follow the PAD_MAILEROO_API_KEY pattern in
// internal/config: PAD_DECISION_PROVIDER, PAD_TYPESAFE_API_KEY,
// PAD_DECISION_MODEL.
func EnvConfig() Config {
return Config{
Provider: strings.TrimSpace(os.Getenv("PAD_DECISION_PROVIDER")),
APIKey: strings.TrimSpace(os.Getenv("PAD_TYPESAFE_API_KEY")),
Model: strings.TrimSpace(os.Getenv("PAD_DECISION_MODEL")),
}
}
// Resolve merges configuration sources, LATER sources winning per field.
// Only non-empty fields override, so a source that says nothing about a
// field leaves the earlier value standing.
//
// Callers put the environment LAST, because the environment overrides the
// instance-admin setting (ruled day 73 on PLAN-3114). That precedence is why
// [ProviderNone] exists: since an empty string means "no opinion", an
// environment that wants to TURN OFF a provider an admin enabled has to say
// so with a value, and "none" is that value.
func Resolve(sources ...Config) Config {
named := make([]NamedConfig, len(sources))
for i, c := range sources {
named[i] = NamedConfig{Config: c}
}
out, _ := ResolveNamed(named...)
return out
}
// Source names for [NamedConfig], as reported by [Origins].
const (
SourceFile = "file"
SourceAdmin = "admin"
SourceEnv = "env"
)
// NamedConfig is a [Config] source labelled for [ResolveNamed].
type NamedConfig struct {
Name string
Config
}
// Origins reports, per field, the Name of the source whose value won, or ""
// when no source set that field.
type Origins struct {
Provider string
APIKey string
Model string
}
// ResolveNamed is [Resolve] with attribution: it applies the same
// later-wins, non-empty-overrides rule and also reports which source each
// field came from. [Resolve] is implemented on it, so the two cannot drift —
// the admin page's "set by environment" is read from the same pass that
// builds the provider (TASK-3121).
func ResolveNamed(sources ...NamedConfig) (Config, Origins) {
var out Config
var o Origins
for _, s := range sources {
if v := strings.TrimSpace(s.Provider); v != "" {
out.Provider, o.Provider = v, s.Name
}
if v := strings.TrimSpace(s.APIKey); v != "" {
out.APIKey, o.APIKey = v, s.Name
}
if v := strings.TrimSpace(s.Model); v != "" {
out.Model, o.Model = v, s.Name
}
}
return out, o
}
// New builds the configured provider.
//
// A config that selects no provider returns (nil, nil) — the unconfigured
// case is not an error, and callers branch on a nil Provider. See the package
// doc.
//
// A config that names a provider but cannot satisfy it — an unknown name, or
// a missing API key — IS an error, because it describes an instance whose
// operator asked for decisions and will not get them. Failing quietly there
// would present a misconfiguration as the supported off state.
func New(cfg Config) (Provider, error) {
if !cfg.Enabled() {
return nil, nil
}
name := strings.ToLower(strings.TrimSpace(cfg.Provider))
switch name {
case ProviderTypesafe:
if cfg.APIKey == "" {
return nil, fmt.Errorf("decision: provider %q is configured but PAD_TYPESAFE_API_KEY is empty", name)
}
model := cfg.Model
if model == "" {
model = DefaultModel
}
return newTypesafe(cfg.APIKey, model), nil
default:
return nil, fmt.Errorf("decision: unknown provider %q (known: %s, %s)", cfg.Provider, ProviderTypesafe, ProviderNone)
}
}
// Describe reports the configured provider for operator-facing output such as
// `pad server info`. It returns ("none", "") when nothing is configured, and
// it NEVER returns the API key.
func Describe(cfg Config) (name, model string) {
if !cfg.Enabled() {
return ProviderNone, ""
}
name = strings.ToLower(strings.TrimSpace(cfg.Provider))
model = cfg.Model
if model == "" && name == ProviderTypesafe {
model = DefaultModel
}
return name, model
}