Files
LocalAI/core/config/runtime_settings_startup.go
LocalAI [bot] c1a891662c refactor(settings): single declarative registry for runtime settings (fixes the #10845 bug class) (#10864)
* 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>
2026-07-16 22:39:59 +02:00

97 lines
4.2 KiB
Go

package config
import (
"encoding/json"
"github.com/mudler/LocalAI/pkg/vrambudget"
"github.com/mudler/LocalAI/pkg/xsysinfo"
)
// DefaultGalleriesJSON / DefaultBackendGalleriesJSON are the gallery lists
// an option-less `local-ai run` gets. cmd/local-ai feeds them to kong as
// the "${galleries}" / "${backends}" vars, and DefaultRuntimeBaseline uses
// them to tell "kong default" apart from "user-configured" at settings-load
// time - they must stay the single source for both.
const DefaultGalleriesJSON = `[{"name":"localai", "url":"github:mudler/LocalAI/gallery/index.yaml@master"}]`
const DefaultBackendGalleriesJSON = `[{"name":"localai", "url":"github:mudler/LocalAI/backend/index.yaml@master"}]`
func mustGalleries(jsonList string) []Gallery {
var g []Gallery
if err := json.Unmarshal([]byte(jsonList), &g); err != nil {
// Both inputs are compile-time constants above; failing loudly at
// init beats silently mis-detecting every gallery as env-set.
panic("invalid built-in gallery JSON: " + err.Error())
}
return g
}
// DefaultRuntimeBaseline is the ApplicationConfig an option-less `local-ai
// run` produces: NewApplicationConfig plus the flag defaults kong injects
// even when the user passes nothing (see the default:"..." tags in
// core/cli/run.go). ApplyRuntimeSettingsAtStartup compares the live config
// against it to decide whether env/CLI claimed a field (env > file).
func DefaultRuntimeBaseline() *ApplicationConfig {
o := NewApplicationConfig()
// WithDebug(log level) is always applied by run.go; the default level
// is not debug, so an option-less run boots with Debug=false even
// though the NewApplicationConfig literal says true.
o.Debug = false
o.Galleries = mustGalleries(DefaultGalleriesJSON)
o.BackendGalleries = mustGalleries(DefaultBackendGalleriesJSON)
o.AutoloadGalleries = true
o.AutoloadBackendGalleries = true
// core/cli/run.go injects WithMemoryReclaimer(enabled, threshold)
// unconditionally, so the kong threshold default (0.95) reaches the
// config even when the reclaimer flag is off - this overlay must match
// that contract or a UI-persisted threshold looks env-set and is
// skipped at boot.
o.MemoryReclaimerThreshold = 0.95
return o
}
// ApplyRuntimeSettingsAtStartup merges persisted settings into o with
// env-over-file precedence: a field is taken from the file only when the
// live value still equals the option-less baseline (env/CLI did not touch
// it) or when the file is that field's only source (fileAuthoritative).
// Used at boot (application.New) and by the runtime_settings.json file
// watcher, so a manual file edit behaves exactly like a boot-time load.
//
// Known limitation (accepted): an env var explicitly set to its default
// value is indistinguishable from "not set", so the file wins there; and a
// field previously changed via the API looks env-set to the watcher, so a
// manual file edit of that field lands on the next restart.
func (o *ApplicationConfig) ApplyRuntimeSettingsAtStartup(settings *RuntimeSettings) {
if settings == nil {
return
}
baseline := DefaultRuntimeBaseline()
for _, f := range runtimeSettingsFields {
if f.snapshotOnly || !f.isSet(settings) {
continue
}
if !f.fileAuthoritative && f.envSet(o, baseline) {
continue
}
f.apply(o, settings)
}
// Startup invariant, mirroring ApplyRuntimeSettings and the gating in
// startWatchdog: enabled idle/busy checks or the memory reclaimer imply
// the watchdog master flag. Never forced off here - an explicit
// watchdog_enabled=false row already applied above when unclaimed.
if o.WatchDogIdle || o.WatchDogBusy || o.MemoryReclaimerEnabled {
o.WatchDog = true
}
// VRAM budget post-processing, mirroring ApplyRuntimeSettings: at boot
// only run.go's env path installs the process-wide cap, so a
// file-persisted budget applied by the loop above would set
// o.VRAMBudget without ever capping allocations. When env set the
// budget the loop skipped the file value and this re-installs the env
// value - idempotent. Fail-open on a malformed persisted value so it
// cannot wedge startup.
if settings.VRAMBudget != nil {
if b, err := vrambudget.Parse(o.VRAMBudget); err == nil {
xsysinfo.SetDefaultVRAMBudget(b)
}
}
}