Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions core/config/model_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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 (
Expand Down
148 changes: 148 additions & 0 deletions core/http/endpoints/localai/toggle_model.go
Original file line number Diff line number Diff line change
@@ -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 ToggleModelEndpoint(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 changes: 11 additions & 0 deletions core/http/middleware/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
78 changes: 73 additions & 5 deletions core/http/react-ui/src/pages/Manage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.toggle(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 {
Expand Down Expand Up @@ -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="#"
Expand All @@ -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>
Expand All @@ -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"
Expand All @@ -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)}
Expand Down
1 change: 1 addition & 0 deletions core/http/react-ui/src/utils/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
toggle: (name, action) => fetchJSON(API_CONFIG.endpoints.modelToggle(name, action), { method: 'PUT' }),
getConfigMetadata: (section) => fetchJSON(
section ? `${API_CONFIG.endpoints.configMetadata}?section=${section}`
: API_CONFIG.endpoints.configMetadata
Expand Down
1 change: 1 addition & 0 deletions core/http/react-ui/src/utils/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export const API_CONFIG = {
modelsJobStatus: (uid) => `/models/jobs/${uid}`,
modelEditGet: (name) => `/api/models/edit/${name}`,
modelEdit: (name) => `/models/edit/${name}`,
modelToggle: (name, action) => `/models/toggle/${name}/${action}`,
backendsAvailable: '/backends/available',
backendsInstalled: '/backends',
version: '/version',
Expand Down
3 changes: 3 additions & 0 deletions core/http/routes/localai.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/:name/:action", localai.ToggleModelEndpoint(cl, ml, appConfig), adminMiddleware)
Comment thread
neurocis marked this conversation as resolved.
Resolved

// Reload models endpoint
router.POST("/models/reload", localai.ReloadModelsEndpoint(cl, appConfig), adminMiddleware)
}
Expand Down
2 changes: 2 additions & 0 deletions core/http/routes/ui_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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 {
Expand Down