Ctrl K
ComunidadGuías…

pluginapi/config.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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"},
	}
}