mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-20 13:17:56 -04:00
* fix(vram): persist remote probe metadata The startup warmer repeated remote size and GGUF metadata probes after every restart because both caches lived only in memory. Store successful HTTP probes for 24 hours so frequent restarts reuse the prior results. Bound the cache, reject invalid records, and purge it when gallery data changes. Local model files continue to bypass persistence. Assisted-by: Codex:gpt-5 * fix(vram): check temporary file cleanup The lint gate rejects the unchecked cleanup call in the persistent cache writer. Assisted-by: Codex:gpt-5.6 [golangci-lint] * fix(vram): make persistent cache optional Remote metadata probes can transfer enough data that operators need control over disk reuse and startup warming. Gallery autoload now gates both behaviors, and the runtime setting applies changes immediately. Assisted-by: Codex:gpt-5 * fix(ui): expose gallery startup pre-warm The existing gallery autoload setting also gates the startup metadata warmer. Name both effects in Settings so operators can find the requested boot control. Assisted-by: Codex:gpt-5 --------- Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
339 lines
13 KiB
Go
339 lines
13 KiB
Go
package localai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/mudler/LocalAI/core/application"
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/http/endpoints/openresponses"
|
|
"github.com/mudler/LocalAI/core/p2p"
|
|
"github.com/mudler/LocalAI/core/schema"
|
|
"github.com/mudler/LocalAI/pkg/vram"
|
|
"github.com/mudler/LocalAI/pkg/vrambudget"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
// GetSettingsEndpoint returns current settings with precedence (env > file > defaults)
|
|
func GetSettingsEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
appConfig := app.ApplicationConfig()
|
|
settings := appConfig.ToRuntimeSettings()
|
|
return c.JSON(http.StatusOK, settings)
|
|
}
|
|
}
|
|
|
|
// UpdateSettingsEndpoint updates settings, saves to file, and applies immediately
|
|
func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
appConfig := app.ApplicationConfig()
|
|
startupConfig := app.StartupConfig()
|
|
|
|
if startupConfig == nil {
|
|
startupConfig = appConfig
|
|
}
|
|
|
|
body, err := io.ReadAll(c.Request().Body)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Failed to read request body: " + err.Error(),
|
|
})
|
|
}
|
|
|
|
var settings config.RuntimeSettings
|
|
if err := json.Unmarshal(body, &settings); err != nil {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Failed to parse JSON: " + err.Error(),
|
|
})
|
|
}
|
|
|
|
// Validate timeouts if provided
|
|
if settings.WatchdogIdleTimeout != nil {
|
|
if _, err := time.ParseDuration(*settings.WatchdogIdleTimeout); err != nil {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Invalid watchdog_idle_timeout format: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
if settings.WatchdogBusyTimeout != nil {
|
|
if _, err := time.ParseDuration(*settings.WatchdogBusyTimeout); err != nil {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Invalid watchdog_busy_timeout format: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
if settings.WatchdogInterval != nil {
|
|
if _, err := time.ParseDuration(*settings.WatchdogInterval); err != nil {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Invalid watchdog_interval format: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
if settings.LRUEvictionRetryInterval != nil {
|
|
if _, err := time.ParseDuration(*settings.LRUEvictionRetryInterval); err != nil {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Invalid lru_eviction_retry_interval format: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
if settings.OpenResponsesStoreTTL != nil {
|
|
if *settings.OpenResponsesStoreTTL != "0" && *settings.OpenResponsesStoreTTL != "" {
|
|
if _, err := time.ParseDuration(*settings.OpenResponsesStoreTTL); err != nil {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Invalid open_responses_store_ttl format: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reject a malformed VRAM budget up front so a bad value never reaches
|
|
// the config (ApplyRuntimeSettings is fail-open and would silently skip
|
|
// installing the cap). An empty string is allowed: it clears the cap.
|
|
if settings.VRAMBudget != nil && *settings.VRAMBudget != "" {
|
|
if _, err := vrambudget.Parse(*settings.VRAMBudget); err != nil {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Invalid vram_budget format: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Generate P2P token before saving so the real token is persisted (not "0")
|
|
if settings.P2PToken != nil && *settings.P2PToken == "0" {
|
|
token := p2p.GenerateToken(60, 60)
|
|
settings.P2PToken = &token
|
|
}
|
|
|
|
// Save to file
|
|
if appConfig.DynamicConfigsDir == "" {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "DynamicConfigsDir is not set",
|
|
})
|
|
}
|
|
|
|
// Read whatever is already persisted: it is both the source of truth
|
|
// for branding asset filenames (below) and the base we merge this
|
|
// request onto before writing. A read failure must not let a Save
|
|
// silently discard the existing settings — surface it instead.
|
|
persisted, err := appConfig.ReadPersistedSettings()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Failed to read existing settings: " + err.Error(),
|
|
})
|
|
}
|
|
|
|
// Branding asset filenames are owned exclusively by
|
|
// /api/branding/asset/{kind} (upload/delete). The Settings page also
|
|
// round-trips them via GET /api/settings, but its local state is stale
|
|
// once an asset has been uploaded — clicking Save would otherwise
|
|
// clobber the uploaded basename with the empty string the UI loaded
|
|
// at page open. Replace whatever the body sent for these three fields
|
|
// with the values currently on disk so /api/settings can never
|
|
// regress them.
|
|
settings.LogoFile = persisted.LogoFile
|
|
settings.LogoHorizontalFile = persisted.LogoHorizontalFile
|
|
settings.FaviconFile = persisted.FaviconFile
|
|
|
|
// The UI reads ApiKeys from GET /api/settings, which already returns the
|
|
// merged env+runtime list. When the user clicks Save, the same merged
|
|
// list comes back in the POST body. Strip the env-supplied keys from
|
|
// the incoming list before we persist or re-merge, otherwise each save
|
|
// duplicates the env keys on top of the previous merge (#9071).
|
|
if settings.ApiKeys != nil {
|
|
envKeys := startupConfig.ApiKeys
|
|
envSet := make(map[string]struct{}, len(envKeys))
|
|
for _, k := range envKeys {
|
|
envSet[k] = struct{}{}
|
|
}
|
|
runtimeOnly := make([]string, 0, len(*settings.ApiKeys))
|
|
for _, k := range *settings.ApiKeys {
|
|
if _, fromEnv := envSet[k]; fromEnv {
|
|
continue
|
|
}
|
|
runtimeOnly = append(runtimeOnly, k)
|
|
}
|
|
settings.ApiKeys = &runtimeOnly
|
|
}
|
|
|
|
// Persist as a partial update: overlay only the fields this request set
|
|
// onto the settings already on disk. Focused admin pages POST just the
|
|
// keys they own (the Middleware proxy tab sends only mitm_listen; the
|
|
// detector table only pii_default_detectors), so writing the request
|
|
// body verbatim would null every unrelated setting (the no-omitempty
|
|
// api_keys / pii_default_detectors fields even round-trip as JSON
|
|
// null). The full Settings page still round-trips every field, so its
|
|
// Save is unchanged.
|
|
toPersist := persisted
|
|
toPersist.MergeNonNil(settings)
|
|
if err := appConfig.WritePersistedSettings(toPersist); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Failed to write settings file: " + err.Error(),
|
|
})
|
|
}
|
|
|
|
// Apply settings using centralized method
|
|
watchdogChanged := appConfig.ApplyRuntimeSettings(&settings)
|
|
if settings.VRAMPersistentCache != nil || settings.AutoloadGalleries != nil {
|
|
if appConfig.VRAMPersistentCache && appConfig.AutoloadGalleries {
|
|
vram.ConfigurePersistentCache(filepath.Join(appConfig.SystemState.Model.ModelsPath, "..", "cache", "vram"), 24*time.Hour)
|
|
} else {
|
|
vram.DisablePersistentCache()
|
|
}
|
|
}
|
|
|
|
// Handle API keys specially (merge with startup keys)
|
|
if settings.ApiKeys != nil {
|
|
appConfig.ApiKeys = config.MergeAPIKeys(startupConfig.ApiKeys, *settings.ApiKeys)
|
|
}
|
|
|
|
// Update backend logging dynamically
|
|
if settings.EnableBackendLogging != nil {
|
|
app.ModelLoader().SetBackendLoggingEnabled(*settings.EnableBackendLogging)
|
|
xlog.Info("Updated backend logging setting", "enableBackendLogging", *settings.EnableBackendLogging)
|
|
}
|
|
|
|
// Update watchdog dynamically for settings that don't require restart
|
|
if settings.ForceEvictionWhenBusy != nil {
|
|
currentWD := app.ModelLoader().GetWatchDog()
|
|
if currentWD != nil {
|
|
currentWD.SetForceEvictionWhenBusy(*settings.ForceEvictionWhenBusy)
|
|
xlog.Info("Updated watchdog force eviction when busy setting", "forceEvictionWhenBusy", *settings.ForceEvictionWhenBusy)
|
|
}
|
|
}
|
|
|
|
// Update ModelLoader LRU eviction retry settings dynamically
|
|
maxRetries := appConfig.LRUEvictionMaxRetries
|
|
retryInterval := appConfig.LRUEvictionRetryInterval
|
|
if settings.LRUEvictionMaxRetries != nil {
|
|
maxRetries = *settings.LRUEvictionMaxRetries
|
|
}
|
|
if settings.LRUEvictionRetryInterval != nil {
|
|
if dur, err := time.ParseDuration(*settings.LRUEvictionRetryInterval); err == nil {
|
|
retryInterval = dur
|
|
}
|
|
}
|
|
if settings.LRUEvictionMaxRetries != nil || settings.LRUEvictionRetryInterval != nil {
|
|
app.ModelLoader().SetLRUEvictionRetrySettings(maxRetries, retryInterval)
|
|
xlog.Info("Updated LRU eviction retry settings", "maxRetries", maxRetries, "retryInterval", retryInterval)
|
|
}
|
|
|
|
// Update Open Responses store TTL dynamically
|
|
if settings.OpenResponsesStoreTTL != nil {
|
|
ttl := time.Duration(0)
|
|
if *settings.OpenResponsesStoreTTL != "0" && *settings.OpenResponsesStoreTTL != "" {
|
|
if dur, err := time.ParseDuration(*settings.OpenResponsesStoreTTL); err == nil {
|
|
ttl = dur
|
|
} else {
|
|
xlog.Warn("Invalid Open Responses store TTL format", "ttl", *settings.OpenResponsesStoreTTL, "error", err)
|
|
}
|
|
}
|
|
// Import the store package
|
|
store := openresponses.GetGlobalStore()
|
|
store.SetTTL(ttl)
|
|
xlog.Info("Updated Open Responses store TTL", "ttl", ttl)
|
|
}
|
|
|
|
// Check if agent job retention changed
|
|
agentJobChanged := settings.AgentJobRetentionDays != nil
|
|
|
|
// Restart watchdog if settings changed.
|
|
//
|
|
// The live start/stop decision derives from the post-apply config
|
|
// (WatchdogShouldRun) rather than the raw watchdog_enabled request
|
|
// field: the React master toggle only ever writes the idle/busy flags,
|
|
// so keying off watchdog_enabled left the live watchdog stopped on a
|
|
// cold enable until the next restart (#9125). WatchdogShouldRun mirrors
|
|
// the gating in startWatchdog, so a cold enable starts it immediately
|
|
// and a full disable (both checks off, no LRU / memory reclaimer) stops
|
|
// it.
|
|
if watchdogChanged {
|
|
if !appConfig.WatchdogShouldRun() {
|
|
if err := app.StopWatchdog(); err != nil {
|
|
xlog.Error("Failed to stop watchdog", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Settings saved but failed to stop watchdog: " + err.Error(),
|
|
})
|
|
}
|
|
} else {
|
|
if err := app.RestartWatchdog(); err != nil {
|
|
xlog.Error("Failed to restart watchdog", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Settings saved but failed to restart watchdog: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restart agent job service if retention days changed
|
|
if agentJobChanged {
|
|
if err := app.RestartAgentJobService(); err != nil {
|
|
xlog.Error("Failed to restart agent job service", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Settings saved but failed to restart agent job service: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Rebuild the MITM listener when its address OR the instance-wide
|
|
// default detectors change. The per-host detector map is resolved once
|
|
// at listener start (startMITMLocked → ResolvePIIPolicy), so a
|
|
// default-detector change is otherwise invisible to cloud-proxy traffic
|
|
// until the next restart — an admin toggling a default detector would
|
|
// see no redaction. RestartMITM is a no-op when the listener is
|
|
// disabled (empty address).
|
|
if settings.MITMListen != nil || settings.PIIDefaultDetectors != nil {
|
|
if err := app.RestartMITM(); err != nil {
|
|
xlog.Error("Failed to restart MITM proxy", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Settings saved but failed to restart MITM proxy: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Restart P2P if P2P settings changed
|
|
p2pChanged := settings.P2PToken != nil || settings.P2PNetworkID != nil || settings.Federated != nil
|
|
if p2pChanged {
|
|
if settings.P2PToken != nil && *settings.P2PToken == "" {
|
|
if err := app.StopP2P(); err != nil {
|
|
xlog.Error("Failed to stop P2P", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Settings saved but failed to stop P2P: " + err.Error(),
|
|
})
|
|
}
|
|
} else {
|
|
if err := app.RestartP2P(); err != nil {
|
|
xlog.Error("Failed to restart P2P", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Settings saved but failed to restart P2P: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, schema.SettingsResponse{
|
|
Success: true,
|
|
Message: "Settings updated successfully",
|
|
})
|
|
}
|
|
}
|