mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 18:09:05 -04:00
* feat(settings): add declarative runtime-settings field registry One fieldSpec row per RuntimeSettings field, with a reflection completeness spec so a field added without a registry row is a red test instead of a silently-dropped setting (the #10845 bug class). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-fable-5 * refactor(settings): drive ToRuntimeSettings/ApplyRuntimeSettings from the field registry Behavior-preserving: ~350 hand-written per-field lines become two loops over runtimeSettingsFields, gated by a To->Apply->To round-trip spec. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-fable-5 * feat(settings): baseline-driven startup merge for persisted runtime settings ApplyRuntimeSettingsAtStartup compares the live config against DefaultRuntimeBaseline (option-less-run defaults incl. kong-injected flag defaults) instead of per-field == 0 guards. Fixes persisted lru_eviction_max_retries, tracing_max_items, agent_job_retention_days, memory_reclaimer_threshold, galleries and autoload flags being silently ignored at boot. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-fable-5 * fix(settings): registry-driven startup merge, applied before consumers loadRuntimeSettingsFromFile becomes a thin wrapper over ApplyRuntimeSettingsAtStartup and runs at the top of New(), before model configs capture app-level defaults. WithThreads stops eagerly resolving 0 so a persisted thread count survives restart while LOCALAI_THREADS still wins (#10845); the physical-core fallback moves after the merge. Also: run.go now injects the memory-reclaimer threshold unconditionally so the option-less boot matches DefaultRuntimeBaseline and a UI-saved threshold survives restart. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-fable-5 * refactor(settings): file watcher delegates to the registry merge; shared API-key merge Manual edits to runtime_settings.json now behave like a boot-time load (env still wins) instead of the inverted diverged-from-startup guard that ignored most manual edits. MergeAPIKeys dedups env keys in one place for the endpoint and the watcher. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-fable-5 * docs(settings): document unified runtime-settings precedence Document the single env/CLI > runtime_settings.json > defaults rule, applied identically at boot, on POST /api/settings, and on manual file edits, plus the two known limitations (default-valued env vars are indistinguishable from unset; API-changed fields hot-apply on the next restart only). Also add a completion debug log when the watcher applies runtime_settings.json. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-fable-5 * test(settings): reset the global VRAM cap leaked by the round-trip spec The round-trip spec applies vram_budget=12GiB, whose post-loop hook installs a process-global default cap; without a reset every spec ordered after it runs under that phantom budget. Also drop a stale enumeration in the ApplyRuntimeSettings doc comment. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-fable-5 --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
99 lines
3.5 KiB
Go
99 lines
3.5 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
)
|
|
|
|
// runtimeSettingsFile is the on-disk filename inside DynamicConfigsDir.
|
|
const runtimeSettingsFile = "runtime_settings.json"
|
|
|
|
// ReadPersistedSettings loads runtime_settings.json from DynamicConfigsDir.
|
|
// A missing file is not an error — the zero RuntimeSettings is returned.
|
|
// This lets callers update only the field they own (e.g. one branding
|
|
// asset filename) without clobbering unrelated settings already on disk.
|
|
func (o *ApplicationConfig) ReadPersistedSettings() (RuntimeSettings, error) {
|
|
var settings RuntimeSettings
|
|
if o.DynamicConfigsDir == "" {
|
|
return settings, errors.New("DynamicConfigsDir is not set")
|
|
}
|
|
path := filepath.Join(o.DynamicConfigsDir, runtimeSettingsFile)
|
|
data, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return settings, nil
|
|
}
|
|
if err != nil {
|
|
return settings, err
|
|
}
|
|
if err := json.Unmarshal(data, &settings); err != nil {
|
|
return settings, err
|
|
}
|
|
return settings, nil
|
|
}
|
|
|
|
// MergeNonNil overlays every set (non-nil) field of overlay onto the
|
|
// receiver, leaving the receiver's value untouched wherever overlay left a
|
|
// field unset. Every RuntimeSettings field is a pointer precisely so "set"
|
|
// can be told apart from "absent" (see the type doc), which makes this a
|
|
// faithful partial update: a caller that submits only the field it owns
|
|
// changes exactly that field and never clobbers unrelated settings.
|
|
//
|
|
// This is the read-modify-write contract the persistence helpers exist for.
|
|
// UpdateSettingsEndpoint reads the on-disk settings, merges the request body
|
|
// on top, and writes the result — so a focused admin page that POSTs only its
|
|
// own field (the Middleware page sends only mitm_listen; the detector table
|
|
// only pii_default_detectors) no longer nulls every other setting.
|
|
//
|
|
// Reflection keeps the merge total over the struct: a field added to
|
|
// RuntimeSettings later is merged automatically, so the persistence path can
|
|
// never silently drop a new setting the way a hand-maintained field list
|
|
// would. Non-pointer fields (none today) are skipped — they cannot express
|
|
// "absent", so the receiver wins.
|
|
func (s *RuntimeSettings) MergeNonNil(overlay RuntimeSettings) {
|
|
dst := reflect.ValueOf(s).Elem()
|
|
src := reflect.ValueOf(overlay)
|
|
for i := 0; i < src.NumField(); i++ {
|
|
f := src.Field(i)
|
|
if f.Kind() == reflect.Pointer && !f.IsNil() {
|
|
dst.Field(i).Set(f)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MergeAPIKeys returns the env/CLI-provided keys followed by the
|
|
// runtime-managed (settings) keys, dropping runtime entries that duplicate
|
|
// an env key so repeated saves cannot stack the env keys (#9071). Env keys
|
|
// always survive: the settings file owns only the runtime tail.
|
|
func MergeAPIKeys(envKeys, runtimeKeys []string) []string {
|
|
merged := append([]string(nil), envKeys...)
|
|
seen := make(map[string]struct{}, len(envKeys))
|
|
for _, k := range envKeys {
|
|
seen[k] = struct{}{}
|
|
}
|
|
for _, k := range runtimeKeys {
|
|
if _, dup := seen[k]; dup {
|
|
continue
|
|
}
|
|
merged = append(merged, k)
|
|
}
|
|
return merged
|
|
}
|
|
|
|
// WritePersistedSettings serialises the given RuntimeSettings to
|
|
// runtime_settings.json with restricted permissions (it may carry API
|
|
// keys and P2P tokens).
|
|
func (o *ApplicationConfig) WritePersistedSettings(settings RuntimeSettings) error {
|
|
if o.DynamicConfigsDir == "" {
|
|
return errors.New("DynamicConfigsDir is not set")
|
|
}
|
|
path := filepath.Join(o.DynamicConfigsDir, runtimeSettingsFile)
|
|
data, err := json.MarshalIndent(settings, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, data, 0o600)
|
|
}
|