Files
LocalAI/core/http/endpoints/localai/settings.go
Sai Asish Y 6480715a16 fix(settings): strip env-supplied ApiKeys from the request before persisting (#9438)
GET /api/settings returns settings.ApiKeys as the merged env+runtime list
via ApplicationConfig.ToRuntimeSettings(). The WebUI displays that list and
round-trips it back on POST /api/settings unchanged.

UpdateSettingsEndpoint was then doing:

    appConfig.ApiKeys = append(envKeys, runtimeKeys...)

where runtimeKeys already contained envKeys (because the UI got them from
the merged GET). Every save therefore duplicated the env keys on top of
the previous merge, and also wrote the duplicates to runtime_settings.json
so the duplication survived restarts and compounded with each save. This
is the user-visible behaviour in #9071: the Web UI shows the keys
twice / three times after consecutive saves.

Before we marshal the settings to disk or call ApplyRuntimeSettings, drop
any incoming key that already appears in startupConfig.ApiKeys. The file
on disk now stores only the genuinely runtime-added keys; the subsequent
append(envKeys, runtimeKeys...) produces one copy of each env key, as
intended. Behaviour is unchanged for users who never had env keys set.

Fixes #9071

Co-authored-by: SAY-5 <SAY-5@users.noreply.github.com>
2026-04-20 10:36:54 +02:00

270 lines
9.2 KiB
Go

package localai
import (
"encoding/json"
"io"
"net/http"
"os"
"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/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(),
})
}
}
}
// 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",
})
}
// 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
}
settingsFile := filepath.Join(appConfig.DynamicConfigsDir, "runtime_settings.json")
settingsJSON, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
Success: false,
Error: "Failed to marshal settings: " + err.Error(),
})
}
if err := os.WriteFile(settingsFile, settingsJSON, 0600); 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)
// Handle API keys specially (merge with startup keys)
if settings.ApiKeys != nil {
envKeys := startupConfig.ApiKeys
runtimeKeys := *settings.ApiKeys
appConfig.ApiKeys = append(envKeys, runtimeKeys...)
}
// 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
if watchdogChanged {
if settings.WatchdogEnabled != nil && !*settings.WatchdogEnabled {
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(),
})
}
}
// 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",
})
}
}