Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 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 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.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 {
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),
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
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}`,
modelToggleState: (name, action) => `/models/toggle-state/${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-state/:name/:action", localai.ToggleStateModelEndpoint(cl, ml, appConfig), adminMiddleware)

// 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