feat: Add toggle mechanism to enable/disable models from loading on demand (#9304)

* feat: add toggle mechanism to enable/disable models from loading on demand

Implements #9303 - Adds ability to disable models from being auto-loaded
while keeping them in the collection.

Backend changes:
- Add Disabled field to ModelConfig struct with IsDisabled() getter
- New ToggleModelEndpoint handler (PUT /models/toggle/:name/:action)
- Request middleware returns 403 when disabled model is requested
- Capabilities endpoint exposes disabled status

Frontend changes:
- Toggle switch in System > Models table Actions column
- Visual indicators: dimmed row, red Disabled badge, muted icons
- Tooltip describes toggle function on hover
- Loading state while API call is in progress

* fix: remove extra closing brace causing syntax error in request middleware

* refactor: reorder Actions column - Stop button before toggle switch

* refactor: migrate from toggle to toggle-state per PR review feedback
This commit is contained in:
Leigh Phillips authored and GitHub committed 2026-04-10 18:17:41 +02:00
1 parent d4cd6c284f
commit 062e0d0d00
8 files changed
+245 -5

No files matched your search

+6
View File
@@ -77,6 +77,7 @@ type ModelConfig struct {
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Usage string `yaml:"usage,omitempty" json:"usage,omitempty"`
Disabled *bool `yaml:"disabled,omitempty" json:"disabled,omitempty"`
Options []string `yaml:"options,omitempty" json:"options,omitempty"`
Overrides []string `yaml:"overrides,omitempty" json:"overrides,omitempty"`
@@ -548,6 +549,11 @@ func (c *ModelConfig) GetModelTemplate() string {
return c.modelTemplate
}
// IsDisabled returns true if the model is disabled
func (c *ModelConfig) IsDisabled() bool {
return c.Disabled != nil && *c.Disabled
}
type ModelConfigUsecase int
const (
+148
View File
@@ -0,0 +1,148 @@
package localai
import (
"fmt"
"net/http"
"net/url"
"os"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/utils"
"gopkg.in/yaml.v3"
)
// ToggleModelEndpoint handles enabling or disabling a model from being loaded on demand.
// When disabled, the model remains in the collection but will not be loaded when requested.
//
// @Summary Toggle model enabled/disabled status
// @Description Enable or disable a model from being loaded on demand. Disabled models remain installed but cannot be loaded.
// @Tags config
// @Param name path string true "Model name"
// @Param action path string true "Action: 'enable' or 'disable'"
// @Success 200 {object} ModelResponse
// @Failure 400 {object} ModelResponse
// @Failure 404 {object} ModelResponse
// @Failure 500 {object} ModelResponse
// @Router /api/models/{name}/{action} [put]
func ToggleStateModelEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
return func(c echo.Context) error {
modelName := c.Param("name")
if decoded, err := url.PathUnescape(modelName); err == nil {
modelName = decoded
}
if modelName == "" {
return c.JSON(http.StatusBadRequest, ModelResponse{
Success: false,
Error: "Model name is required",
})
}
action := c.Param("action")
if action != "enable" && action != "disable" {
return c.JSON(http.StatusBadRequest, ModelResponse{
Success: false,
Error: "Action must be 'enable' or 'disable'",
})
}
// Get existing model config
modelConfig, exists := cl.GetModelConfig(modelName)
if !exists {
return c.JSON(http.StatusNotFound, ModelResponse{
Success: false,
Error: "Model configuration not found",
})
}
// Get the config file path
configPath := modelConfig.GetModelConfigFile()
if configPath == "" {
return c.JSON(http.StatusNotFound, ModelResponse{
Success: false,
Error: "Model configuration file not found",
})
}
// Verify the path is trusted
if err := utils.VerifyPath(configPath, appConfig.SystemState.Model.ModelsPath); err != nil {
return c.JSON(http.StatusForbidden, ModelResponse{
Success: false,
Error: "Model configuration not trusted: " + err.Error(),
})
}
// Read the existing config file
configData, err := os.ReadFile(configPath)
if err != nil {
return c.JSON(http.StatusInternalServerError, ModelResponse{
Success: false,
Error: "Failed to read configuration file: " + err.Error(),
})
}
// Parse the YAML config as a generic map to preserve all fields
var configMap map[string]interface{}
if err := yaml.Unmarshal(configData, &configMap); err != nil {
return c.JSON(http.StatusInternalServerError, ModelResponse{
Success: false,
Error: "Failed to parse configuration file: " + err.Error(),
})
}
// Update the disabled field
disabled := action == "disable"
if disabled {
configMap["disabled"] = true
} else {
// Remove the disabled key entirely when enabling (clean YAML)
delete(configMap, "disabled")
}
// Marshal back to YAML
updatedData, err := yaml.Marshal(configMap)
if err != nil {
return c.JSON(http.StatusInternalServerError, ModelResponse{
Success: false,
Error: "Failed to serialize configuration: " + err.Error(),
})
}
// Write updated config back to file
if err := os.WriteFile(configPath, updatedData, 0644); err != nil {
return c.JSON(http.StatusInternalServerError, ModelResponse{
Success: false,
Error: "Failed to write configuration file: " + err.Error(),
})
}
// Reload model configurations from disk
if err := cl.LoadModelConfigsFromPath(appConfig.SystemState.Model.ModelsPath, appConfig.ToConfigLoaderOptions()...); err != nil {
return c.JSON(http.StatusInternalServerError, ModelResponse{
Success: false,
Error: "Failed to reload configurations: " + err.Error(),
})
}
// If disabling, also shutdown the model if it's currently running
if disabled {
if err := ml.ShutdownModel(modelName); err != nil {
// Log but don't fail - the config was saved successfully
fmt.Printf("Warning: Failed to shutdown model '%s' during disable: %v\n", modelName, err)
}
}
msg := fmt.Sprintf("Model '%s' has been %sd successfully.", modelName, action)
if disabled {
msg += " The model will not be loaded on demand until re-enabled."
}
return c.JSON(http.StatusOK, ModelResponse{
Success: true,
Message: msg,
Filename: configPath,
})
}
}
+11
View File
@@ -167,6 +167,17 @@ func (re *RequestExtractor) SetModelAndConfig(initializer func() schema.LocalAIR
}
}
// Check if the model is disabled
if cfg != nil && cfg.IsDisabled() {
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
Error: &schema.APIError{
Message: fmt.Sprintf("model %q is disabled and cannot be loaded. Enable it via the System page or API to use it.", modelName),
Code: http.StatusForbidden,
Type: "model_disabled",
},
})
}
c.Set(CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, input)
c.Set(CONTEXT_LOCALS_KEY_MODEL_CONFIG, cfg)
+73 -5
View File
@@ -24,6 +24,7 @@ export default function Manage() {
const [reinstallingBackends, setReinstallingBackends] = useState(new Set())
const [confirmDialog, setConfirmDialog] = useState(null)
const [distributedMode, setDistributedMode] = useState(false)
const [togglingModels, setTogglingModels] = useState(new Set())
const handleTabChange = (tab) => {
setActiveTab(tab)
@@ -99,6 +100,28 @@ export default function Manage() {
})
}
const handleToggleModel = async (modelId, currentlyDisabled) => {
const action = currentlyDisabled ? 'enable' : 'disable'
setTogglingModels(prev => new Set(prev).add(modelId))
try {
await modelsApi.toggleState(modelId, action)
addToast(`Model ${modelId} ${action}d`, 'success')
refetchModels()
if (!currentlyDisabled) {
// Model was just disabled, refresh loaded models since it may have been shut down
setTimeout(fetchLoadedModels, 500)
}
} catch (err) {
addToast(`Failed to ${action} model: ${err.message}`, 'error')
} finally {
setTogglingModels(prev => {
const next = new Set(prev)
next.delete(modelId)
return next
})
}
}
const handleReload = async () => {
setReloading(true)
try {
@@ -219,11 +242,11 @@ export default function Manage() {
</thead>
<tbody>
{models.map(model => (
<tr key={model.id}>
<tr key={model.id} style={{ opacity: model.disabled ? 0.55 : 1, transition: 'opacity 0.2s' }}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<i className="fas fa-brain" style={{ color: 'var(--color-accent)' }} />
<span className="badge badge-success" style={{ width: 6, height: 6, padding: 0, borderRadius: '50%', minWidth: 'auto' }} />
<i className="fas fa-brain" style={{ color: model.disabled ? 'var(--color-text-muted)' : 'var(--color-accent)' }} />
<span className={`badge ${model.disabled ? '' : 'badge-success'}`} style={{ width: 6, height: 6, padding: 0, borderRadius: '50%', minWidth: 'auto', background: model.disabled ? 'var(--color-text-muted)' : undefined }} />
<span style={{ fontWeight: 500 }}>{model.id}</span>
<a
href="#"
@@ -246,7 +269,11 @@ export default function Manage() {
</div>
</td>
<td>
{loadedModelIds.has(model.id) ? (
{model.disabled ? (
<span className="badge" style={{ background: 'var(--color-danger, #ef4444)', color: 'white' }}>
<i className="fas fa-ban" style={{ fontSize: '6px' }} /> Disabled
</span>
) : loadedModelIds.has(model.id) ? (
<span className="badge badge-success">
<i className="fas fa-circle" style={{ fontSize: '6px' }} /> Running
</span>
@@ -265,7 +292,8 @@ export default function Manage() {
</div>
</td>
<td>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }}>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'flex-end', alignItems: 'center' }}>
{/* Stop button - shown when model is loaded */}
{loadedModelIds.has(model.id) && (
<button
className="btn btn-secondary btn-sm"
@@ -275,6 +303,46 @@ export default function Manage() {
<i className="fas fa-stop" />
</button>
)}
{/* Toggle switch for enabling/disabling model loading on demand */}
<label
title={model.disabled ? 'Model is disabled — click to enable loading on demand' : 'Model is enabled — click to disable loading on demand'}
style={{
position: 'relative',
display: 'inline-block',
width: 36,
height: 20,
cursor: togglingModels.has(model.id) ? 'wait' : 'pointer',
opacity: togglingModels.has(model.id) ? 0.5 : 1,
flexShrink: 0,
}}
>
<input
type="checkbox"
checked={!model.disabled}
onChange={() => handleToggleModel(model.id, model.disabled)}
disabled={togglingModels.has(model.id)}
style={{ opacity: 0, width: 0, height: 0 }}
/>
<span style={{
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: model.disabled ? 'var(--color-bg-tertiary)' : 'var(--color-success, #22c55e)',
borderRadius: 20,
transition: 'background-color 0.2s',
}}>
<span style={{
position: 'absolute',
content: '""',
height: 14,
width: 14,
left: model.disabled ? 3 : 19,
bottom: 3,
backgroundColor: 'white',
borderRadius: '50%',
transition: 'left 0.2s',
}} />
</span>
</label>
<button
className="btn btn-danger btn-sm"
onClick={() => handleDeleteModel(model.id)}
+1
View File
@@ -97,6 +97,7 @@ export const modelsApi = {
getJobStatus: (uid) => fetchJSON(API_CONFIG.endpoints.modelsJobStatus(uid)),
getEditConfig: (name) => fetchJSON(API_CONFIG.endpoints.modelEditGet(name)),
editConfig: (name, body) => postJSON(API_CONFIG.endpoints.modelEdit(name), body),
toggleState: (name, action) => fetchJSON(API_CONFIG.endpoints.modelToggleState(name, action), { method: 'PUT' }),
getConfigMetadata: (section) => fetchJSON(
section ? `${API_CONFIG.endpoints.configMetadata}?section=${section}`
: API_CONFIG.endpoints.configMetadata
+1
View File
@@ -93,6 +93,7 @@ export const API_CONFIG = {
modelsJobStatus: (uid) => `/models/jobs/${uid}`,
modelEditGet: (name) => `/api/models/edit/${name}`,
modelEdit: (name) => `/models/edit/${name}`,
modelToggleState: (name, action) => `/models/toggle-state/${name}/${action}`,
backendsAvailable: '/backends/available',
backendsInstalled: '/backends',
version: '/version',
+3
View File
@@ -75,6 +75,9 @@ func RegisterLocalAIRoutes(router *echo.Echo,
// Custom model edit endpoint
router.POST("/models/edit/:name", localai.EditModelEndpoint(cl, ml, appConfig), adminMiddleware)
// Toggle model enable/disable endpoint
router.PUT("/models/toggle-state/:name/:action", localai.ToggleStateModelEndpoint(cl, ml, appConfig), adminMiddleware)
// Reload models endpoint
router.POST("/models/reload", localai.ReloadModelsEndpoint(cl, appConfig), adminMiddleware)
}
+2
View File
@@ -514,6 +514,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
ID string `json:"id"`
Capabilities []string `json:"capabilities"`
Backend string `json:"backend"`
Disabled bool `json:"disabled"`
}
result := make([]modelCapability, 0, len(modelConfigs)+len(modelsWithoutConfig))
@@ -522,6 +523,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
ID: cfg.Name,
Capabilities: cfg.KnownUsecaseStrings,
Backend: cfg.Backend,
Disabled: cfg.IsDisabled(),
})
}
for _, name := range modelsWithoutConfig {