pluginapi/config.go
Rama main · réplica de lectura del repositorio original.
package pluginapi
import (
"encoding/json"
"fmt"
"math"
"slices"
"strconv"
"strings"
)
// Config reads an instance configuration key by key, with the coercions a
// value needs whether it came from the dashboard form or from config.yaml:
// numbers written as strings, lists written as comma-separated text, bools
// written as yes/no. Every reader returns its default when the key is
// absent, null, or invalid, and the first problem is kept for [Config.Err],
// so a decoder reads all keys in a row and checks once:
//
// cfg, err := pluginapi.ParseConfig(Name, p.Manifest().ConfigSchema, raw)
// if err != nil {
// return err
// }
// s.model = cfg.String("model", "")
// s.action = cfg.Choice("action", "block", "block", "respond", "warn")
// s.maxTokens = cfg.Int("max_tokens", 256, 1, 1<<20)
// return cfg.Err()
type Config struct {
plugin string
values map[string]json.RawMessage
err error
}
// ParseConfig decodes raw, which may be empty or null for an empty
// configuration. A key that is not in schema is an error, so a typo in
// config.yaml is reported instead of ignored. Error messages start with the
// plugin name.
func ParseConfig(plugin string, schema []Field, raw json.RawMessage) (*Config, error) {
c := &Config{plugin: plugin, values: map[string]json.RawMessage{}}
if len(strings.TrimSpace(string(raw))) == 0 || string(raw) == "null" {
return c, nil
}
if err := json.Unmarshal(raw, &c.values); err != nil {
return nil, fmt.Errorf("%s: invalid config: %w", plugin, err)
}
for key := range c.values {
if !hasField(schema, key) {
return nil, fmt.Errorf("%s: invalid config: unknown field %q", plugin, key)
}
}
return c, nil
}
func hasField(schema []Field, key string) bool {
for _, f := range schema {
if f.Key == key {
return true
}
}
return false
}
// Err returns the first problem a reader found, or nil.
func (c *Config) Err() error { return c.err }
func (c *Config) fail(key, format string, args ...any) {
if c.err == nil {
c.err = fmt.Errorf("%s: %s %s", c.plugin, key, fmt.Sprintf(format, args...))
}
}
// Raw returns the value of key as stored, or nil when absent or null.
func (c *Config) Raw(key string) json.RawMessage {
raw, ok := c.values[key]
if !ok || string(raw) == "null" {
return nil
}
return raw
}
// String reads a string; absent or null keeps def.
func (c *Config) String(key, def string) string {
raw := c.Raw(key)
if raw == nil {
return def
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
c.fail(key, "must be a string")
return def
}
return s
}
// Choice reads a select value; absent, null, or "" keeps def, anything else
// must be one of allowed.
func (c *Config) Choice(key, def string, allowed ...string) string {
s := c.String(key, def)
if s == "" {
return def
}
if !slices.Contains(allowed, s) {
c.fail(key, "must be one of %s; got %q", strings.Join(allowed, ", "), s)
return def
}
return s
}
// Bool reads a bool, also written as true/false, yes/no, on/off, or 1/0;
// absent, null, or "" is false.
func (c *Config) Bool(key string) bool {
raw := c.Raw(key)
if raw == nil {
return false
}
var b bool
if err := json.Unmarshal(raw, &b); err == nil {
return b
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
switch strings.ToLower(strings.TrimSpace(s)) {
case "true", "yes", "on", "1":
return true
case "false", "no", "off", "0", "":
return false
}
}
c.fail(key, "must be true or false, got %s", raw)
return false
}
// number reads a JSON number or a numeric string. ok is false when the key
// is absent, null, or "".
func (c *Config) number(key string) (f float64, ok bool) {
raw := c.Raw(key)
if raw == nil {
return 0, false
}
if err := json.Unmarshal(raw, &f); err == nil {
return f, true
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
c.fail(key, "must be a number, got %s", raw)
return 0, false
}
if strings.TrimSpace(s) == "" {
return 0, false
}
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
// ParseFloat accepts "NaN" and "Inf", which JSON numbers cannot spell;
// NaN would also pass every range check.
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
c.fail(key, "must be a number, got %q", s)
return 0, false
}
return f, true
}
// Float reads a number within [lo, hi]; absent, null, or "" keeps def.
func (c *Config) Float(key string, def, lo, hi float64) float64 {
f, ok := c.number(key)
if !ok {
return def
}
if f < lo || f > hi {
c.fail(key, "must be between %v and %v, got %v", lo, hi, f)
return def
}
return f
}
// OptionalFloat reads a number within [lo, hi]; absent, null, or "" is nil.
func (c *Config) OptionalFloat(key string, lo, hi float64) *float64 {
f, ok := c.number(key)
if !ok {
return nil
}
if f < lo || f > hi {
c.fail(key, "must be between %v and %v, got %v", lo, hi, f)
return nil
}
return &f
}
// Int reads a whole number within [lo, hi]; absent, null, or "" keeps def.
func (c *Config) Int(key string, def, lo, hi int) int {
f, ok := c.number(key)
if !ok {
return def
}
if f != float64(int(f)) {
c.fail(key, "must be a whole number, got %v", f)
return def
}
n := int(f)
if n < lo || n > hi {
c.fail(key, "must be between %d and %d, got %d", lo, hi, n)
return def
}
return n
}
// BlockStatus reads an HTTP status for [Decision.Status]: empty means the
// phase default (0), anything else must be between 400 and 599.
func (c *Config) BlockStatus(key string) int {
n := c.Int(key, 0, 0, 1<<20)
if n != 0 && (n < 400 || n > 599) {
c.fail(key, "must be an HTTP status between 400 and 599, got %d", n)
return 0
}
return n
}
// List reads a list of strings: a JSON array, or one string split on commas
// and newlines. Items are trimmed and blanks dropped. Absent or null is nil;
// an empty list is an empty, non-nil slice.
func (c *Config) List(key string) []string {
raw := c.Raw(key)
if raw == nil {
return nil
}
var items []string
if err := json.Unmarshal(raw, &items); err != nil {
var text string
if err := json.Unmarshal(raw, &text); err != nil {
c.fail(key, "must be a list of strings")
return nil
}
items = strings.FieldsFunc(text, func(r rune) bool { return r == ',' || r == '\n' })
}
out := []string{}
for _, item := range items {
if item = strings.TrimSpace(item); item != "" {
out = append(out, item)
}
}
return out
}
// Lines reads a textarea as lines: one string split on newlines, or a JSON
// array of strings. Lines are kept as written. Absent or null is nil.
func (c *Config) Lines(key string) []string {
raw := c.Raw(key)
if raw == nil {
return nil
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return strings.Split(text, "\n")
}
var items []string
if err := json.Unmarshal(raw, &items); err != nil {
c.fail(key, "must be text or a list of strings")
return nil
}
return items
}
// Roles reads a checkbox list of roles (system, user, assistant, tool) into
// a set; "system" also selects [RoleDeveloper], the Responses spelling of
// system. Absent or null selects def.
func (c *Config) Roles(key string, def ...Role) map[Role]bool {
items := c.List(key)
if items == nil {
roles := make(map[Role]bool, len(def)+1)
for _, r := range def {
roles[r] = true
if r == RoleSystem {
roles[RoleDeveloper] = true
}
}
return roles
}
roles := make(map[Role]bool, len(items)+1)
for _, item := range items {
r := Role(strings.ToLower(item))
if !slices.Contains(configRoles, r) {
c.fail(key, "has unknown role %q (use system, user, assistant, tool)", item)
continue
}
roles[r] = true
if r == RoleSystem {
roles[RoleDeveloper] = true
}
}
return roles
}
var configRoles = []Role{RoleSystem, RoleUser, RoleAssistant, RoleTool}
// RoleOptions are the checkbox options for a roles field read by
// [Config.Roles].
func RoleOptions() []Option {
return []Option{
{Value: string(RoleSystem), Label: "System (and developer)"},
{Value: string(RoleUser), Label: "User"},
{Value: string(RoleAssistant), Label: "Assistant"},
{Value: string(RoleTool), Label: "Tool results"},
}
}