Skip to content

Commit 7c5d616

Browse files
authored
fix(ui): rename model config files on save to prevent duplicates (#9388)
Editing a model's YAML and changing the `name:` field previously wrote the new body to the original `<oldName>.yaml`. On reload the config loader indexed that file under the new name while the old key lingered in memory, producing two entries in the system UI that shared a single underlying file — deleting either removed both. Detect the rename in EditModelEndpoint and rename the on-disk `<name>.yaml` and `._gallery_<name>.yaml` to match, drop the stale in-memory key before the reload, and redirect the editor URL in the React UI so it tracks the new name. Reject conflicts (409) and names containing path separators (400). Fixes #9294
1 parent 5837b14 commit 7c5d616

File tree

4 files changed

+247
-11
lines changed

4 files changed

+247
-11
lines changed

core/gallery/models.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,12 @@ func galleryFileName(name string) string {
335335
return "._gallery_" + name + ".yaml"
336336
}
337337

338+
// GalleryFileName returns the on-disk filename of the gallery metadata file
339+
// for a given installed model name (e.g. "._gallery_<name>.yaml").
340+
func GalleryFileName(name string) string {
341+
return galleryFileName(name)
342+
}
343+
338344
func GetLocalModelConfiguration(basePath string, name string) (*ModelConfig, error) {
339345
name = strings.ReplaceAll(name, string(os.PathSeparator), "__")
340346
galleryFile := filepath.Join(basePath, galleryFileName(name))

core/http/endpoints/localai/edit_model.go

Lines changed: 94 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
package localai
22

33
import (
4+
"errors"
45
"fmt"
56
"io"
67
"net/http"
78
"net/url"
89
"os"
10+
"path/filepath"
11+
"strings"
912

1013
"github.com/labstack/echo/v4"
1114
"github.com/mudler/LocalAI/core/config"
15+
"github.com/mudler/LocalAI/core/gallery"
1216
httpUtils "github.com/mudler/LocalAI/core/http/middleware"
1317
"github.com/mudler/LocalAI/internal"
1418
"github.com/mudler/LocalAI/pkg/model"
@@ -156,25 +160,98 @@ func EditModelEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appC
156160

157161
// Load the existing configuration
158162
configPath := modelConfig.GetModelConfigFile()
159-
if err := utils.VerifyPath(configPath, appConfig.SystemState.Model.ModelsPath); err != nil {
163+
modelsPath := appConfig.SystemState.Model.ModelsPath
164+
if err := utils.VerifyPath(configPath, modelsPath); err != nil {
160165
response := ModelResponse{
161166
Success: false,
162167
Error: "Model configuration not trusted: " + err.Error(),
163168
}
164169
return c.JSON(http.StatusNotFound, response)
165170
}
166171

167-
// Write new content to file
168-
if err := os.WriteFile(configPath, body, 0644); err != nil {
169-
response := ModelResponse{
170-
Success: false,
171-
Error: "Failed to write configuration file: " + err.Error(),
172+
// Detect a rename: the URL param (old name) differs from the name field
173+
// in the posted YAML. When that happens we must rename the on-disk file
174+
// so that <name>.yaml stays in sync with the internal name field —
175+
// otherwise a subsequent config reload indexes the file under the new
176+
// name while the old key lingers in memory, producing duplicates in the UI.
177+
renamed := req.Name != modelName
178+
if renamed {
179+
if strings.ContainsRune(req.Name, os.PathSeparator) || strings.Contains(req.Name, "/") || strings.Contains(req.Name, "\\") {
180+
response := ModelResponse{
181+
Success: false,
182+
Error: "Model name must not contain path separators",
183+
}
184+
return c.JSON(http.StatusBadRequest, response)
185+
}
186+
if _, exists := cl.GetModelConfig(req.Name); exists {
187+
response := ModelResponse{
188+
Success: false,
189+
Error: fmt.Sprintf("A model named %q already exists", req.Name),
190+
}
191+
return c.JSON(http.StatusConflict, response)
192+
}
193+
newConfigPath := filepath.Join(modelsPath, req.Name+".yaml")
194+
if err := utils.VerifyPath(newConfigPath, modelsPath); err != nil {
195+
response := ModelResponse{
196+
Success: false,
197+
Error: "New model configuration path not trusted: " + err.Error(),
198+
}
199+
return c.JSON(http.StatusBadRequest, response)
200+
}
201+
if _, err := os.Stat(newConfigPath); err == nil {
202+
response := ModelResponse{
203+
Success: false,
204+
Error: fmt.Sprintf("A configuration file for %q already exists on disk", req.Name),
205+
}
206+
return c.JSON(http.StatusConflict, response)
207+
} else if !errors.Is(err, os.ErrNotExist) {
208+
response := ModelResponse{
209+
Success: false,
210+
Error: "Failed to check for existing configuration: " + err.Error(),
211+
}
212+
return c.JSON(http.StatusInternalServerError, response)
213+
}
214+
215+
if err := os.WriteFile(newConfigPath, body, 0644); err != nil {
216+
response := ModelResponse{
217+
Success: false,
218+
Error: "Failed to write configuration file: " + err.Error(),
219+
}
220+
return c.JSON(http.StatusInternalServerError, response)
221+
}
222+
if configPath != newConfigPath {
223+
if err := os.Remove(configPath); err != nil && !errors.Is(err, os.ErrNotExist) {
224+
fmt.Printf("Warning: Failed to remove old configuration file %q: %v\n", configPath, err)
225+
}
226+
}
227+
228+
// Rename the gallery metadata file if one exists, so the delete
229+
// flow (which looks up ._gallery_<name>.yaml) can still find it.
230+
oldGalleryPath := filepath.Join(modelsPath, gallery.GalleryFileName(modelName))
231+
newGalleryPath := filepath.Join(modelsPath, gallery.GalleryFileName(req.Name))
232+
if _, err := os.Stat(oldGalleryPath); err == nil {
233+
if err := os.Rename(oldGalleryPath, newGalleryPath); err != nil {
234+
fmt.Printf("Warning: Failed to rename gallery metadata from %q to %q: %v\n", oldGalleryPath, newGalleryPath, err)
235+
}
236+
}
237+
238+
// Drop the stale in-memory entry before the reload so we don't
239+
// surface both names to callers between the reload scan steps.
240+
cl.RemoveModelConfig(modelName)
241+
configPath = newConfigPath
242+
} else {
243+
// Write new content to file
244+
if err := os.WriteFile(configPath, body, 0644); err != nil {
245+
response := ModelResponse{
246+
Success: false,
247+
Error: "Failed to write configuration file: " + err.Error(),
248+
}
249+
return c.JSON(http.StatusInternalServerError, response)
172250
}
173-
return c.JSON(http.StatusInternalServerError, response)
174251
}
175252

176253
// Reload configurations
177-
if err := cl.LoadModelConfigsFromPath(appConfig.SystemState.Model.ModelsPath, appConfig.ToConfigLoaderOptions()...); err != nil {
254+
if err := cl.LoadModelConfigsFromPath(modelsPath, appConfig.ToConfigLoaderOptions()...); err != nil {
178255
response := ModelResponse{
179256
Success: false,
180257
Error: "Failed to reload configurations: " + err.Error(),
@@ -183,15 +260,17 @@ func EditModelEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appC
183260
}
184261

185262
// Shutdown the running model to apply new configuration (e.g., context_size)
186-
// The model will be reloaded on the next inference request
263+
// The model will be reloaded on the next inference request.
264+
// Shutdown uses the old name because that's what the running instance
265+
// (if any) was started with.
187266
if err := ml.ShutdownModel(modelName); err != nil {
188267
// Log the error but don't fail the request - the config was saved successfully
189268
// The model can still be manually reloaded or restarted
190269
fmt.Printf("Warning: Failed to shutdown model '%s': %v\n", modelName, err)
191270
}
192271

193272
// Preload the model
194-
if err := cl.Preload(appConfig.SystemState.Model.ModelsPath); err != nil {
273+
if err := cl.Preload(modelsPath); err != nil {
195274
response := ModelResponse{
196275
Success: false,
197276
Error: "Failed to preload model: " + err.Error(),
@@ -200,9 +279,13 @@ func EditModelEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appC
200279
}
201280

202281
// Return success response
282+
msg := fmt.Sprintf("Model '%s' updated successfully. Model has been reloaded with new configuration.", req.Name)
283+
if renamed {
284+
msg = fmt.Sprintf("Model '%s' renamed to '%s' and updated successfully.", modelName, req.Name)
285+
}
203286
response := ModelResponse{
204287
Success: true,
205-
Message: fmt.Sprintf("Model '%s' updated successfully. Model has been reloaded with new configuration.", modelName),
288+
Message: msg,
206289
Filename: configPath,
207290
Config: req,
208291
}

core/http/endpoints/localai/edit_model_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import (
1111

1212
"github.com/labstack/echo/v4"
1313
"github.com/mudler/LocalAI/core/config"
14+
"github.com/mudler/LocalAI/core/gallery"
1415
. "github.com/mudler/LocalAI/core/http/endpoints/localai"
16+
"github.com/mudler/LocalAI/pkg/model"
1517
"github.com/mudler/LocalAI/pkg/system"
1618
. "github.com/onsi/ginkgo/v2"
1719
. "github.com/onsi/gomega"
@@ -80,5 +82,142 @@ var _ = Describe("Edit Model test", func() {
8082
Expect(string(body)).To(ContainSubstring(`"name":"foo"`))
8183
Expect(rec.Code).To(Equal(http.StatusOK))
8284
})
85+
86+
It("renames the config file on disk when the YAML name changes", func() {
87+
systemState, err := system.GetSystemState(
88+
system.WithModelPath(tempDir),
89+
)
90+
Expect(err).ToNot(HaveOccurred())
91+
applicationConfig := config.NewApplicationConfig(
92+
config.WithSystemState(systemState),
93+
)
94+
modelConfigLoader := config.NewModelConfigLoader(systemState.Model.ModelsPath)
95+
modelLoader := model.NewModelLoader(systemState)
96+
97+
oldYAML := "name: oldname\nbackend: llama\nmodel: foo\n"
98+
oldPath := filepath.Join(tempDir, "oldname.yaml")
99+
Expect(os.WriteFile(oldPath, []byte(oldYAML), 0644)).To(Succeed())
100+
// Drop a gallery metadata file so we can check it is renamed too.
101+
galleryOldPath := filepath.Join(tempDir, gallery.GalleryFileName("oldname"))
102+
Expect(os.WriteFile(galleryOldPath, []byte("name: oldname\n"), 0644)).To(Succeed())
103+
104+
Expect(modelConfigLoader.LoadModelConfigsFromPath(tempDir)).To(Succeed())
105+
_, exists := modelConfigLoader.GetModelConfig("oldname")
106+
Expect(exists).To(BeTrue())
107+
108+
app := echo.New()
109+
app.POST("/models/edit/:name", EditModelEndpoint(modelConfigLoader, modelLoader, applicationConfig))
110+
111+
newYAML := "name: newname\nbackend: llama\nmodel: foo\n"
112+
req := httptest.NewRequest("POST", "/models/edit/oldname", bytes.NewBufferString(newYAML))
113+
req.Header.Set("Content-Type", "application/x-yaml")
114+
rec := httptest.NewRecorder()
115+
app.ServeHTTP(rec, req)
116+
117+
body, err := io.ReadAll(rec.Body)
118+
Expect(err).ToNot(HaveOccurred(), string(body))
119+
Expect(rec.Code).To(Equal(http.StatusOK), string(body))
120+
121+
// Old file is gone, new file exists.
122+
_, err = os.Stat(oldPath)
123+
Expect(os.IsNotExist(err)).To(BeTrue(), "old config file should be removed")
124+
newPath := filepath.Join(tempDir, "newname.yaml")
125+
_, err = os.Stat(newPath)
126+
Expect(err).ToNot(HaveOccurred(), "new config file should exist")
127+
128+
// Gallery metadata followed the rename.
129+
_, err = os.Stat(galleryOldPath)
130+
Expect(os.IsNotExist(err)).To(BeTrue(), "old gallery metadata should be removed")
131+
_, err = os.Stat(filepath.Join(tempDir, gallery.GalleryFileName("newname")))
132+
Expect(err).ToNot(HaveOccurred(), "new gallery metadata should exist")
133+
134+
// In-memory config loader holds exactly one entry, keyed by the new name.
135+
_, exists = modelConfigLoader.GetModelConfig("oldname")
136+
Expect(exists).To(BeFalse(), "old name must not remain in config loader")
137+
_, exists = modelConfigLoader.GetModelConfig("newname")
138+
Expect(exists).To(BeTrue(), "new name must be present in config loader")
139+
Expect(modelConfigLoader.GetAllModelsConfigs()).To(HaveLen(1))
140+
})
141+
142+
It("rejects a rename when the new name already exists", func() {
143+
systemState, err := system.GetSystemState(
144+
system.WithModelPath(tempDir),
145+
)
146+
Expect(err).ToNot(HaveOccurred())
147+
applicationConfig := config.NewApplicationConfig(
148+
config.WithSystemState(systemState),
149+
)
150+
modelConfigLoader := config.NewModelConfigLoader(systemState.Model.ModelsPath)
151+
modelLoader := model.NewModelLoader(systemState)
152+
153+
Expect(os.WriteFile(
154+
filepath.Join(tempDir, "oldname.yaml"),
155+
[]byte("name: oldname\nbackend: llama\nmodel: foo\n"),
156+
0644,
157+
)).To(Succeed())
158+
Expect(os.WriteFile(
159+
filepath.Join(tempDir, "newname.yaml"),
160+
[]byte("name: newname\nbackend: llama\nmodel: bar\n"),
161+
0644,
162+
)).To(Succeed())
163+
Expect(modelConfigLoader.LoadModelConfigsFromPath(tempDir)).To(Succeed())
164+
165+
app := echo.New()
166+
app.POST("/models/edit/:name", EditModelEndpoint(modelConfigLoader, modelLoader, applicationConfig))
167+
168+
req := httptest.NewRequest(
169+
"POST",
170+
"/models/edit/oldname",
171+
bytes.NewBufferString("name: newname\nbackend: llama\nmodel: foo\n"),
172+
)
173+
req.Header.Set("Content-Type", "application/x-yaml")
174+
rec := httptest.NewRecorder()
175+
app.ServeHTTP(rec, req)
176+
177+
Expect(rec.Code).To(Equal(http.StatusConflict))
178+
179+
// Neither file should have been rewritten.
180+
oldBody, err := os.ReadFile(filepath.Join(tempDir, "oldname.yaml"))
181+
Expect(err).ToNot(HaveOccurred())
182+
Expect(string(oldBody)).To(ContainSubstring("name: oldname"))
183+
newBody, err := os.ReadFile(filepath.Join(tempDir, "newname.yaml"))
184+
Expect(err).ToNot(HaveOccurred())
185+
Expect(string(newBody)).To(ContainSubstring("model: bar"))
186+
})
187+
188+
It("rejects a rename whose new name contains a path separator", func() {
189+
systemState, err := system.GetSystemState(
190+
system.WithModelPath(tempDir),
191+
)
192+
Expect(err).ToNot(HaveOccurred())
193+
applicationConfig := config.NewApplicationConfig(
194+
config.WithSystemState(systemState),
195+
)
196+
modelConfigLoader := config.NewModelConfigLoader(systemState.Model.ModelsPath)
197+
modelLoader := model.NewModelLoader(systemState)
198+
199+
Expect(os.WriteFile(
200+
filepath.Join(tempDir, "oldname.yaml"),
201+
[]byte("name: oldname\nbackend: llama\nmodel: foo\n"),
202+
0644,
203+
)).To(Succeed())
204+
Expect(modelConfigLoader.LoadModelConfigsFromPath(tempDir)).To(Succeed())
205+
206+
app := echo.New()
207+
app.POST("/models/edit/:name", EditModelEndpoint(modelConfigLoader, modelLoader, applicationConfig))
208+
209+
req := httptest.NewRequest(
210+
"POST",
211+
"/models/edit/oldname",
212+
bytes.NewBufferString("name: evil/name\nbackend: llama\nmodel: foo\n"),
213+
)
214+
req.Header.Set("Content-Type", "application/x-yaml")
215+
rec := httptest.NewRecorder()
216+
app.ServeHTTP(rec, req)
217+
218+
Expect(rec.Code).To(Equal(http.StatusBadRequest))
219+
_, err = os.Stat(filepath.Join(tempDir, "oldname.yaml"))
220+
Expect(err).ToNot(HaveOccurred(), "original file must not be removed")
221+
})
83222
})
84223
})

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,15 +307,23 @@ export default function ModelEditor() {
307307
}
308308
// Refresh interactive state from saved YAML
309309
setSavedYamlText(yamlText)
310+
let parsedName = null
310311
try {
311312
const parsed = YAML.parse(yamlText)
313+
parsedName = parsed?.name ?? null
312314
const flat = flattenConfig(parsed || {})
313315
setValues(flat)
314316
setInitialValues(structuredClone(flat))
315317
setActiveFieldPaths(new Set(Object.keys(flat)))
316318
} catch { /* ignore parse failure */ }
317319
setTabSwitchWarning(false)
318320
addToast('Config saved', 'success')
321+
// When the model was renamed via the YAML `name:` field, the current
322+
// editor URL points at a name that no longer exists on the backend.
323+
// Redirect so refreshes and subsequent saves hit the new name.
324+
if (parsedName && parsedName !== name) {
325+
navigate(`/app/model-editor/${encodeURIComponent(parsedName)}`, { replace: true })
326+
}
319327
}
320328
} catch (err) {
321329
addToast(`Save failed: ${err.message}`, 'error')

0 commit comments

Comments
 (0)