mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-15 10:37:12 -04:00
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:
1 parent
d4cd6c284f
commit
062e0d0d00
8 files changed
+245
-5
No files matched your search
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Vendored
+1
@@ -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
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user