Skip to content

Commit 062e0d0

Browse files
authored
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
1 parent d4cd6c2 commit 062e0d0

8 files changed

Lines changed: 245 additions & 5 deletions

File tree

core/config/model_config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ type ModelConfig struct {
7777

7878
Description string `yaml:"description,omitempty" json:"description,omitempty"`
7979
Usage string `yaml:"usage,omitempty" json:"usage,omitempty"`
80+
Disabled *bool `yaml:"disabled,omitempty" json:"disabled,omitempty"`
8081

8182
Options []string `yaml:"options,omitempty" json:"options,omitempty"`
8283
Overrides []string `yaml:"overrides,omitempty" json:"overrides,omitempty"`
@@ -548,6 +549,11 @@ func (c *ModelConfig) GetModelTemplate() string {
548549
return c.modelTemplate
549550
}
550551

552+
// IsDisabled returns true if the model is disabled
553+
func (c *ModelConfig) IsDisabled() bool {
554+
return c.Disabled != nil && *c.Disabled
555+
}
556+
551557
type ModelConfigUsecase int
552558

553559
const (
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package localai
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/url"
7+
"os"
8+
9+
"github.com/labstack/echo/v4"
10+
"github.com/mudler/LocalAI/core/config"
11+
"github.com/mudler/LocalAI/pkg/model"
12+
"github.com/mudler/LocalAI/pkg/utils"
13+
14+
"gopkg.in/yaml.v3"
15+
)
16+
17+
// ToggleModelEndpoint handles enabling or disabling a model from being loaded on demand.
18+
// When disabled, the model remains in the collection but will not be loaded when requested.
19+
//
20+
// @Summary Toggle model enabled/disabled status
21+
// @Description Enable or disable a model from being loaded on demand. Disabled models remain installed but cannot be loaded.
22+
// @Tags config
23+
// @Param name path string true "Model name"
24+
// @Param action path string true "Action: 'enable' or 'disable'"
25+
// @Success 200 {object} ModelResponse
26+
// @Failure 400 {object} ModelResponse
27+
// @Failure 404 {object} ModelResponse
28+
// @Failure 500 {object} ModelResponse
29+
// @Router /api/models/{name}/{action} [put]
30+
func ToggleStateModelEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
31+
return func(c echo.Context) error {
32+
modelName := c.Param("name")
33+
if decoded, err := url.PathUnescape(modelName); err == nil {
34+
modelName = decoded
35+
}
36+
if modelName == "" {
37+
return c.JSON(http.StatusBadRequest, ModelResponse{
38+
Success: false,
39+
Error: "Model name is required",
40+
})
41+
}
42+
43+
action := c.Param("action")
44+
if action != "enable" && action != "disable" {
45+
return c.JSON(http.StatusBadRequest, ModelResponse{
46+
Success: false,
47+
Error: "Action must be 'enable' or 'disable'",
48+
})
49+
}
50+
51+
// Get existing model config
52+
modelConfig, exists := cl.GetModelConfig(modelName)
53+
if !exists {
54+
return c.JSON(http.StatusNotFound, ModelResponse{
55+
Success: false,
56+
Error: "Model configuration not found",
57+
})
58+
}
59+
60+
// Get the config file path
61+
configPath := modelConfig.GetModelConfigFile()
62+
if configPath == "" {
63+
return c.JSON(http.StatusNotFound, ModelResponse{
64+
Success: false,
65+
Error: "Model configuration file not found",
66+
})
67+
}
68+
69+
// Verify the path is trusted
70+
if err := utils.VerifyPath(configPath, appConfig.SystemState.Model.ModelsPath); err != nil {
71+
return c.JSON(http.StatusForbidden, ModelResponse{
72+
Success: false,
73+
Error: "Model configuration not trusted: " + err.Error(),
74+
})
75+
}
76+
77+
// Read the existing config file
78+
configData, err := os.ReadFile(configPath)
79+
if err != nil {
80+
return c.JSON(http.StatusInternalServerError, ModelResponse{
81+
Success: false,
82+
Error: "Failed to read configuration file: " + err.Error(),
83+
})
84+
}
85+
86+
// Parse the YAML config as a generic map to preserve all fields
87+
var configMap map[string]interface{}
88+
if err := yaml.Unmarshal(configData, &configMap); err != nil {
89+
return c.JSON(http.StatusInternalServerError, ModelResponse{
90+
Success: false,
91+
Error: "Failed to parse configuration file: " + err.Error(),
92+
})
93+
}
94+
95+
// Update the disabled field
96+
disabled := action == "disable"
97+
if disabled {
98+
configMap["disabled"] = true
99+
} else {
100+
// Remove the disabled key entirely when enabling (clean YAML)
101+
delete(configMap, "disabled")
102+
}
103+
104+
// Marshal back to YAML
105+
updatedData, err := yaml.Marshal(configMap)
106+
if err != nil {
107+
return c.JSON(http.StatusInternalServerError, ModelResponse{
108+
Success: false,
109+
Error: "Failed to serialize configuration: " + err.Error(),
110+
})
111+
}
112+
113+
// Write updated config back to file
114+
if err := os.WriteFile(configPath, updatedData, 0644); err != nil {
115+
return c.JSON(http.StatusInternalServerError, ModelResponse{
116+
Success: false,
117+
Error: "Failed to write configuration file: " + err.Error(),
118+
})
119+
}
120+
121+
// Reload model configurations from disk
122+
if err := cl.LoadModelConfigsFromPath(appConfig.SystemState.Model.ModelsPath, appConfig.ToConfigLoaderOptions()...); err != nil {
123+
return c.JSON(http.StatusInternalServerError, ModelResponse{
124+
Success: false,
125+
Error: "Failed to reload configurations: " + err.Error(),
126+
})
127+
}
128+
129+
// If disabling, also shutdown the model if it's currently running
130+
if disabled {
131+
if err := ml.ShutdownModel(modelName); err != nil {
132+
// Log but don't fail - the config was saved successfully
133+
fmt.Printf("Warning: Failed to shutdown model '%s' during disable: %v\n", modelName, err)
134+
}
135+
}
136+
137+
msg := fmt.Sprintf("Model '%s' has been %sd successfully.", modelName, action)
138+
if disabled {
139+
msg += " The model will not be loaded on demand until re-enabled."
140+
}
141+
142+
return c.JSON(http.StatusOK, ModelResponse{
143+
Success: true,
144+
Message: msg,
145+
Filename: configPath,
146+
})
147+
}
148+
}

core/http/middleware/request.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,17 @@ func (re *RequestExtractor) SetModelAndConfig(initializer func() schema.LocalAIR
167167
}
168168
}
169169

170+
// Check if the model is disabled
171+
if cfg != nil && cfg.IsDisabled() {
172+
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
173+
Error: &schema.APIError{
174+
Message: fmt.Sprintf("model %q is disabled and cannot be loaded. Enable it via the System page or API to use it.", modelName),
175+
Code: http.StatusForbidden,
176+
Type: "model_disabled",
177+
},
178+
})
179+
}
180+
170181
c.Set(CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, input)
171182
c.Set(CONTEXT_LOCALS_KEY_MODEL_CONFIG, cfg)
172183

core/http/react-ui/src/pages/Manage.jsx

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export default function Manage() {
2424
const [reinstallingBackends, setReinstallingBackends] = useState(new Set())
2525
const [confirmDialog, setConfirmDialog] = useState(null)
2626
const [distributedMode, setDistributedMode] = useState(false)
27+
const [togglingModels, setTogglingModels] = useState(new Set())
2728

2829
const handleTabChange = (tab) => {
2930
setActiveTab(tab)
@@ -99,6 +100,28 @@ export default function Manage() {
99100
})
100101
}
101102

103+
const handleToggleModel = async (modelId, currentlyDisabled) => {
104+
const action = currentlyDisabled ? 'enable' : 'disable'
105+
setTogglingModels(prev => new Set(prev).add(modelId))
106+
try {
107+
await modelsApi.toggleState(modelId, action)
108+
addToast(`Model ${modelId} ${action}d`, 'success')
109+
refetchModels()
110+
if (!currentlyDisabled) {
111+
// Model was just disabled, refresh loaded models since it may have been shut down
112+
setTimeout(fetchLoadedModels, 500)
113+
}
114+
} catch (err) {
115+
addToast(`Failed to ${action} model: ${err.message}`, 'error')
116+
} finally {
117+
setTogglingModels(prev => {
118+
const next = new Set(prev)
119+
next.delete(modelId)
120+
return next
121+
})
122+
}
123+
}
124+
102125
const handleReload = async () => {
103126
setReloading(true)
104127
try {
@@ -219,11 +242,11 @@ export default function Manage() {
219242
</thead>
220243
<tbody>
221244
{models.map(model => (
222-
<tr key={model.id}>
245+
<tr key={model.id} style={{ opacity: model.disabled ? 0.55 : 1, transition: 'opacity 0.2s' }}>
223246
<td>
224247
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
225-
<i className="fas fa-brain" style={{ color: 'var(--color-accent)' }} />
226-
<span className="badge badge-success" style={{ width: 6, height: 6, padding: 0, borderRadius: '50%', minWidth: 'auto' }} />
248+
<i className="fas fa-brain" style={{ color: model.disabled ? 'var(--color-text-muted)' : 'var(--color-accent)' }} />
249+
<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 }} />
227250
<span style={{ fontWeight: 500 }}>{model.id}</span>
228251
<a
229252
href="#"
@@ -246,7 +269,11 @@ export default function Manage() {
246269
</div>
247270
</td>
248271
<td>
249-
{loadedModelIds.has(model.id) ? (
272+
{model.disabled ? (
273+
<span className="badge" style={{ background: 'var(--color-danger, #ef4444)', color: 'white' }}>
274+
<i className="fas fa-ban" style={{ fontSize: '6px' }} /> Disabled
275+
</span>
276+
) : loadedModelIds.has(model.id) ? (
250277
<span className="badge badge-success">
251278
<i className="fas fa-circle" style={{ fontSize: '6px' }} /> Running
252279
</span>
@@ -265,7 +292,8 @@ export default function Manage() {
265292
</div>
266293
</td>
267294
<td>
268-
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }}>
295+
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'flex-end', alignItems: 'center' }}>
296+
{/* Stop button - shown when model is loaded */}
269297
{loadedModelIds.has(model.id) && (
270298
<button
271299
className="btn btn-secondary btn-sm"
@@ -275,6 +303,46 @@ export default function Manage() {
275303
<i className="fas fa-stop" />
276304
</button>
277305
)}
306+
{/* Toggle switch for enabling/disabling model loading on demand */}
307+
<label
308+
title={model.disabled ? 'Model is disabled — click to enable loading on demand' : 'Model is enabled — click to disable loading on demand'}
309+
style={{
310+
position: 'relative',
311+
display: 'inline-block',
312+
width: 36,
313+
height: 20,
314+
cursor: togglingModels.has(model.id) ? 'wait' : 'pointer',
315+
opacity: togglingModels.has(model.id) ? 0.5 : 1,
316+
flexShrink: 0,
317+
}}
318+
>
319+
<input
320+
type="checkbox"
321+
checked={!model.disabled}
322+
onChange={() => handleToggleModel(model.id, model.disabled)}
323+
disabled={togglingModels.has(model.id)}
324+
style={{ opacity: 0, width: 0, height: 0 }}
325+
/>
326+
<span style={{
327+
position: 'absolute',
328+
top: 0, left: 0, right: 0, bottom: 0,
329+
backgroundColor: model.disabled ? 'var(--color-bg-tertiary)' : 'var(--color-success, #22c55e)',
330+
borderRadius: 20,
331+
transition: 'background-color 0.2s',
332+
}}>
333+
<span style={{
334+
position: 'absolute',
335+
content: '""',
336+
height: 14,
337+
width: 14,
338+
left: model.disabled ? 3 : 19,
339+
bottom: 3,
340+
backgroundColor: 'white',
341+
borderRadius: '50%',
342+
transition: 'left 0.2s',
343+
}} />
344+
</span>
345+
</label>
278346
<button
279347
className="btn btn-danger btn-sm"
280348
onClick={() => handleDeleteModel(model.id)}

core/http/react-ui/src/utils/api.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export const modelsApi = {
9797
getJobStatus: (uid) => fetchJSON(API_CONFIG.endpoints.modelsJobStatus(uid)),
9898
getEditConfig: (name) => fetchJSON(API_CONFIG.endpoints.modelEditGet(name)),
9999
editConfig: (name, body) => postJSON(API_CONFIG.endpoints.modelEdit(name), body),
100+
toggleState: (name, action) => fetchJSON(API_CONFIG.endpoints.modelToggleState(name, action), { method: 'PUT' }),
100101
getConfigMetadata: (section) => fetchJSON(
101102
section ? `${API_CONFIG.endpoints.configMetadata}?section=${section}`
102103
: API_CONFIG.endpoints.configMetadata

core/http/react-ui/src/utils/config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export const API_CONFIG = {
9393
modelsJobStatus: (uid) => `/models/jobs/${uid}`,
9494
modelEditGet: (name) => `/api/models/edit/${name}`,
9595
modelEdit: (name) => `/models/edit/${name}`,
96+
modelToggleState: (name, action) => `/models/toggle-state/${name}/${action}`,
9697
backendsAvailable: '/backends/available',
9798
backendsInstalled: '/backends',
9899
version: '/version',

core/http/routes/localai.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ func RegisterLocalAIRoutes(router *echo.Echo,
7575
// Custom model edit endpoint
7676
router.POST("/models/edit/:name", localai.EditModelEndpoint(cl, ml, appConfig), adminMiddleware)
7777

78+
// Toggle model enable/disable endpoint
79+
router.PUT("/models/toggle-state/:name/:action", localai.ToggleStateModelEndpoint(cl, ml, appConfig), adminMiddleware)
80+
7881
// Reload models endpoint
7982
router.POST("/models/reload", localai.ReloadModelsEndpoint(cl, appConfig), adminMiddleware)
8083
}

core/http/routes/ui_api.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
514514
ID string `json:"id"`
515515
Capabilities []string `json:"capabilities"`
516516
Backend string `json:"backend"`
517+
Disabled bool `json:"disabled"`
517518
}
518519

519520
result := make([]modelCapability, 0, len(modelConfigs)+len(modelsWithoutConfig))
@@ -522,6 +523,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
522523
ID: cfg.Name,
523524
Capabilities: cfg.KnownUsecaseStrings,
524525
Backend: cfg.Backend,
526+
Disabled: cfg.IsDisabled(),
525527
})
526528
}
527529
for _, name := range modelsWithoutConfig {

0 commit comments

Comments
 (0)