Skip to content

Commit 112d18c

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

33 files changed

+2504
-495
lines changed

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
@@ -119,48 +119,20 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applica
119119
return c.JSON(http.StatusBadRequest, response)
120120
}
121121

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

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

@@ -173,10 +145,9 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applica
173145
return c.JSON(http.StatusBadRequest, response)
174146
}
175147

176-
// Set defaults
177-
modelConfig.SetDefaults(appConfig.ToConfigLoaderOptions()...)
178-
179-
// Validate the configuration
148+
// Validate without calling SetDefaults() — runtime defaults should not
149+
// be persisted to disk. SetDefaults() is called when loading configs
150+
// for inference via LoadModelConfigsFromPath().
180151
if valid, _ := modelConfig.Validate(); !valid {
181152
response := ModelResponse{
182153
Success: false,
@@ -195,8 +166,21 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applica
195166
return c.JSON(http.StatusBadRequest, response)
196167
}
197168

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

0 commit comments

Comments
 (0)