mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-30 03:55:58 -04:00
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>
270 lines
9.2 KiB
Go
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",
|
|
})
|
|
}
|
|
}
|