Skip to content

Commit e43a4b7

Browse files
committed
feat(ui): Add dynamic model editor with autocomplete
Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent e5a3116 commit e43a4b7

33 files changed

Lines changed: 2504 additions & 495 deletions

core/config/meta/constants.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,22 @@ var DiffusersPipelineOptions = []FieldOption{
4949
{Value: "StableVideoDiffusionPipeline", Label: "StableVideoDiffusionPipeline"},
5050
}
5151

52+
var UsecaseOptions = []FieldOption{
53+
{Value: "chat", Label: "Chat"},
54+
{Value: "completion", Label: "Completion"},
55+
{Value: "edit", Label: "Edit"},
56+
{Value: "embeddings", Label: "Embeddings"},
57+
{Value: "rerank", Label: "Rerank"},
58+
{Value: "image", Label: "Image"},
59+
{Value: "transcript", Label: "Transcript"},
60+
{Value: "tts", Label: "TTS"},
61+
{Value: "sound_generation", Label: "Sound Generation"},
62+
{Value: "tokenize", Label: "Tokenize"},
63+
{Value: "vad", Label: "VAD"},
64+
{Value: "video", Label: "Video"},
65+
{Value: "detection", Label: "Detection"},
66+
}
67+
5268
var DiffusersSchedulerOptions = []FieldOption{
5369
{Value: "ddim", Label: "DDIM"},
5470
{Value: "ddpm", Label: "DDPM"},

core/config/meta/registry.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@ func DefaultRegistry() map[string]FieldMetaOverride {
4747
"known_usecases": {
4848
Section: "general",
4949
Label: "Known Use Cases",
50-
Description: "Capabilities this model supports (e.g. FLAG_CHAT, FLAG_COMPLETION)",
50+
Description: "Capabilities this model supports",
5151
Component: "string-list",
52+
Options: UsecaseOptions,
5253
Order: 6,
5354
},
5455

@@ -287,6 +288,15 @@ func DefaultRegistry() map[string]FieldMetaOverride {
287288
Order: 72,
288289
},
289290

291+
// --- TTS ---
292+
"tts.voice": {
293+
Section: "tts",
294+
Label: "Voice",
295+
Description: "Default voice for TTS output",
296+
Component: "input",
297+
Order: 90,
298+
},
299+
290300
// --- Diffusers ---
291301
"diffusers.pipeline_type": {
292302
Section: "diffusers",

core/http/endpoints/localai/config_meta.go

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -180,27 +180,39 @@ func PatchConfigEndpoint(cl *config.ModelConfigLoader, _ *model.ModelLoader, app
180180
return c.JSON(http.StatusBadRequest, map[string]any{"error": "invalid JSON: " + err.Error()})
181181
}
182182

183-
existingJSON, err := json.Marshal(modelConfig)
183+
// Read the raw YAML from disk rather than serializing the in-memory config.
184+
// The in-memory config has SetDefaults() applied, which would persist
185+
// runtime-only defaults (top_p, temperature, mirostat, etc.) to the file.
186+
configPath := modelConfig.GetModelConfigFile()
187+
if err := utils.VerifyPath(configPath, appConfig.SystemState.Model.ModelsPath); err != nil {
188+
return c.JSON(http.StatusForbidden, map[string]any{"error": "config path not trusted: " + err.Error()})
189+
}
190+
191+
diskYAML, err := os.ReadFile(configPath)
184192
if err != nil {
185-
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to marshal existing config"})
193+
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to read config file: " + err.Error()})
186194
}
187195

188196
var existingMap map[string]any
189-
if err := json.Unmarshal(existingJSON, &existingMap); err != nil {
190-
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to parse existing config"})
197+
if err := yaml.Unmarshal(diskYAML, &existingMap); err != nil {
198+
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to parse existing config: " + err.Error()})
199+
}
200+
if existingMap == nil {
201+
existingMap = map[string]any{}
191202
}
192203

193204
if err := mergo.Merge(&existingMap, patchMap, mergo.WithOverride); err != nil {
194205
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to merge configs: " + err.Error()})
195206
}
196207

197-
mergedJSON, err := json.Marshal(existingMap)
208+
// Marshal once and reuse for both validation and writing
209+
yamlData, err := yaml.Marshal(existingMap)
198210
if err != nil {
199-
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to marshal merged config"})
211+
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to marshal YAML"})
200212
}
201213

202214
var updatedConfig config.ModelConfig
203-
if err := json.Unmarshal(mergedJSON, &updatedConfig); err != nil {
215+
if err := yaml.Unmarshal(yamlData, &updatedConfig); err != nil {
204216
return c.JSON(http.StatusBadRequest, map[string]any{"error": "merged config is invalid: " + err.Error()})
205217
}
206218

@@ -212,16 +224,6 @@ func PatchConfigEndpoint(cl *config.ModelConfigLoader, _ *model.ModelLoader, app
212224
return c.JSON(http.StatusBadRequest, map[string]any{"error": errMsg})
213225
}
214226

215-
configPath := modelConfig.GetModelConfigFile()
216-
if err := utils.VerifyPath(configPath, appConfig.SystemState.Model.ModelsPath); err != nil {
217-
return c.JSON(http.StatusForbidden, map[string]any{"error": "config path not trusted: " + err.Error()})
218-
}
219-
220-
yamlData, err := yaml.Marshal(updatedConfig)
221-
if err != nil {
222-
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to marshal YAML"})
223-
}
224-
225227
if err := os.WriteFile(configPath, yamlData, 0644); err != nil {
226228
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to write config file"})
227229
}

core/http/endpoints/localai/config_meta_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,5 +239,54 @@ backend: llama-cpp
239239
Expect(err).NotTo(HaveOccurred())
240240
Expect(string(data)).To(ContainSubstring("vllm"))
241241
})
242+
243+
It("should not persist runtime defaults (SetDefaults values) to disk", func() {
244+
// Create a minimal pipeline config - no sampling params
245+
seedConfig := `name: gpt-realtime
246+
pipeline:
247+
vad: silero-vad
248+
transcription: whisper-base
249+
llm: llama3
250+
tts: piper
251+
`
252+
configPath := filepath.Join(tempDir, "gpt-realtime.yaml")
253+
Expect(os.WriteFile(configPath, []byte(seedConfig), 0644)).To(Succeed())
254+
Expect(configLoader.LoadModelConfigsFromPath(tempDir)).To(Succeed())
255+
256+
// PATCH with a small change to the pipeline
257+
body := bytes.NewBufferString(`{"pipeline": {"tts": "vibevoice"}}`)
258+
req := httptest.NewRequest(http.MethodPatch, "/api/models/config-json/gpt-realtime", body)
259+
req.Header.Set("Content-Type", "application/json")
260+
rec := httptest.NewRecorder()
261+
app.ServeHTTP(rec, req)
262+
263+
Expect(rec.Code).To(Equal(http.StatusOK))
264+
265+
// Read the file from disk and verify no spurious defaults leaked
266+
data, err := os.ReadFile(configPath)
267+
Expect(err).NotTo(HaveOccurred())
268+
fileContent := string(data)
269+
270+
// The patched value should be present
271+
Expect(fileContent).To(ContainSubstring("vibevoice"))
272+
273+
// Runtime-only defaults from SetDefaults() should NOT be in the file
274+
Expect(fileContent).NotTo(ContainSubstring("top_p"))
275+
Expect(fileContent).NotTo(ContainSubstring("top_k"))
276+
Expect(fileContent).NotTo(ContainSubstring("temperature"))
277+
Expect(fileContent).NotTo(ContainSubstring("mirostat"))
278+
Expect(fileContent).NotTo(ContainSubstring("mmap"))
279+
Expect(fileContent).NotTo(ContainSubstring("mmlock"))
280+
Expect(fileContent).NotTo(ContainSubstring("threads"))
281+
Expect(fileContent).NotTo(ContainSubstring("low_vram"))
282+
Expect(fileContent).NotTo(ContainSubstring("embeddings"))
283+
Expect(fileContent).NotTo(ContainSubstring("f16"))
284+
285+
// Original fields should still be present
286+
Expect(fileContent).To(ContainSubstring("gpt-realtime"))
287+
Expect(fileContent).To(ContainSubstring("silero-vad"))
288+
Expect(fileContent).To(ContainSubstring("whisper-base"))
289+
Expect(fileContent).To(ContainSubstring("llama3"))
290+
})
242291
})
243292
})

core/http/endpoints/localai/import_model.go

Lines changed: 27 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -117,48 +117,20 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applica
117117
return c.JSON(http.StatusBadRequest, response)
118118
}
119119

120-
// Check content type to determine how to parse
120+
// Detect format once and reuse for both typed and map parsing
121121
contentType := c.Request().Header.Get("Content-Type")
122-
var modelConfig config.ModelConfig
122+
trimmed := strings.TrimSpace(string(body))
123+
isJSON := strings.Contains(contentType, "application/json") ||
124+
(!strings.Contains(contentType, "yaml") && len(trimmed) > 0 && trimmed[0] == '{')
123125

124-
if strings.Contains(contentType, "application/json") {
125-
// Parse JSON
126+
var modelConfig config.ModelConfig
127+
if isJSON {
126128
if err := json.Unmarshal(body, &modelConfig); err != nil {
127-
response := ModelResponse{
128-
Success: false,
129-
Error: "Failed to parse JSON: " + err.Error(),
130-
}
131-
return c.JSON(http.StatusBadRequest, response)
132-
}
133-
} else if strings.Contains(contentType, "application/x-yaml") || strings.Contains(contentType, "text/yaml") {
134-
// Parse YAML
135-
if err := yaml.Unmarshal(body, &modelConfig); err != nil {
136-
response := ModelResponse{
137-
Success: false,
138-
Error: "Failed to parse YAML: " + err.Error(),
139-
}
140-
return c.JSON(http.StatusBadRequest, response)
129+
return c.JSON(http.StatusBadRequest, ModelResponse{Success: false, Error: "Failed to parse JSON: " + err.Error()})
141130
}
142131
} else {
143-
// Try to auto-detect format
144-
if len(body) > 0 && strings.TrimSpace(string(body))[0] == '{' {
145-
// Looks like JSON
146-
if err := json.Unmarshal(body, &modelConfig); err != nil {
147-
response := ModelResponse{
148-
Success: false,
149-
Error: "Failed to parse JSON: " + err.Error(),
150-
}
151-
return c.JSON(http.StatusBadRequest, response)
152-
}
153-
} else {
154-
// Assume YAML
155-
if err := yaml.Unmarshal(body, &modelConfig); err != nil {
156-
response := ModelResponse{
157-
Success: false,
158-
Error: "Failed to parse YAML: " + err.Error(),
159-
}
160-
return c.JSON(http.StatusBadRequest, response)
161-
}
132+
if err := yaml.Unmarshal(body, &modelConfig); err != nil {
133+
return c.JSON(http.StatusBadRequest, ModelResponse{Success: false, Error: "Failed to parse YAML: " + err.Error()})
162134
}
163135
}
164136

@@ -171,10 +143,9 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applica
171143
return c.JSON(http.StatusBadRequest, response)
172144
}
173145

174-
// Set defaults
175-
modelConfig.SetDefaults(appConfig.ToConfigLoaderOptions()...)
176-
177-
// Validate the configuration
146+
// Validate without calling SetDefaults() — runtime defaults should not
147+
// be persisted to disk. SetDefaults() is called when loading configs
148+
// for inference via LoadModelConfigsFromPath().
178149
if valid, _ := modelConfig.Validate(); !valid {
179150
response := ModelResponse{
180151
Success: false,
@@ -193,8 +164,21 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applica
193164
return c.JSON(http.StatusBadRequest, response)
194165
}
195166

196-
// Marshal to YAML for storage
197-
yamlData, err := yaml.Marshal(&modelConfig)
167+
// Write only the user-provided fields to disk by parsing the original
168+
// body into a map (not the typed struct, which includes Go zero values).
169+
var bodyMap map[string]any
170+
if isJSON {
171+
_ = json.Unmarshal(body, &bodyMap)
172+
} else {
173+
_ = yaml.Unmarshal(body, &bodyMap)
174+
}
175+
176+
var yamlData []byte
177+
if bodyMap != nil {
178+
yamlData, err = yaml.Marshal(bodyMap)
179+
} else {
180+
yamlData, err = yaml.Marshal(&modelConfig)
181+
}
198182
if err != nil {
199183
response := ModelResponse{
200184
Success: false,

0 commit comments

Comments
 (0)