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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ See the full [Backend & Model Compatibility Table](https://localai.io/model-comp
- [Build from source](https://localai.io/basics/build/)
- [Kubernetes installation](https://localai.io/basics/getting_started/#run-localai-in-kubernetes)
- [Integrations & community projects](https://localai.io/docs/integrations/)
- [Installation video walkthrough](https://www.youtube.com/watch?v=cMVNnlqwfw4)
- [Media & blog posts](https://localai.io/basics/news/#media-blogs-social)
- [Examples](https://github.com/mudler/LocalAI-examples)

Expand Down
16 changes: 16 additions & 0 deletions core/config/meta/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ var DiffusersPipelineOptions = []FieldOption{
{Value: "StableVideoDiffusionPipeline", Label: "StableVideoDiffusionPipeline"},
}

var UsecaseOptions = []FieldOption{
{Value: "chat", Label: "Chat"},
{Value: "completion", Label: "Completion"},
{Value: "edit", Label: "Edit"},
{Value: "embeddings", Label: "Embeddings"},
{Value: "rerank", Label: "Rerank"},
{Value: "image", Label: "Image"},
{Value: "transcript", Label: "Transcript"},
{Value: "tts", Label: "TTS"},
{Value: "sound_generation", Label: "Sound Generation"},
{Value: "tokenize", Label: "Tokenize"},
{Value: "vad", Label: "VAD"},
{Value: "video", Label: "Video"},
{Value: "detection", Label: "Detection"},
}

var DiffusersSchedulerOptions = []FieldOption{
{Value: "ddim", Label: "DDIM"},
{Value: "ddpm", Label: "DDPM"},
Expand Down
12 changes: 11 additions & 1 deletion core/config/meta/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ func DefaultRegistry() map[string]FieldMetaOverride {
"known_usecases": {
Section: "general",
Label: "Known Use Cases",
Description: "Capabilities this model supports (e.g. FLAG_CHAT, FLAG_COMPLETION)",
Description: "Capabilities this model supports",
Component: "string-list",
Options: UsecaseOptions,
Order: 6,
},

Expand Down Expand Up @@ -287,6 +288,15 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Order: 72,
},

// --- TTS ---
"tts.voice": {
Section: "tts",
Label: "Voice",
Description: "Default voice for TTS output",
Component: "input",
Order: 90,
},

// --- Diffusers ---
"diffusers.pipeline_type": {
Section: "diffusers",
Expand Down
36 changes: 19 additions & 17 deletions core/http/endpoints/localai/config_meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,27 +180,39 @@ func PatchConfigEndpoint(cl *config.ModelConfigLoader, _ *model.ModelLoader, app
return c.JSON(http.StatusBadRequest, map[string]any{"error": "invalid JSON: " + err.Error()})
}

existingJSON, err := json.Marshal(modelConfig)
// Read the raw YAML from disk rather than serializing the in-memory config.
// The in-memory config has SetDefaults() applied, which would persist
// runtime-only defaults (top_p, temperature, mirostat, etc.) to the file.
configPath := modelConfig.GetModelConfigFile()
if err := utils.VerifyPath(configPath, appConfig.SystemState.Model.ModelsPath); err != nil {
return c.JSON(http.StatusForbidden, map[string]any{"error": "config path not trusted: " + err.Error()})
}

diskYAML, err := os.ReadFile(configPath)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to marshal existing config"})
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to read config file: " + err.Error()})
}

var existingMap map[string]any
if err := json.Unmarshal(existingJSON, &existingMap); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to parse existing config"})
if err := yaml.Unmarshal(diskYAML, &existingMap); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to parse existing config: " + err.Error()})
}
if existingMap == nil {
existingMap = map[string]any{}
}

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

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

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

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

configPath := modelConfig.GetModelConfigFile()
if err := utils.VerifyPath(configPath, appConfig.SystemState.Model.ModelsPath); err != nil {
return c.JSON(http.StatusForbidden, map[string]any{"error": "config path not trusted: " + err.Error()})
}

yamlData, err := yaml.Marshal(updatedConfig)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to marshal YAML"})
}

if err := os.WriteFile(configPath, yamlData, 0644); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to write config file"})
}
Expand Down
49 changes: 49 additions & 0 deletions core/http/endpoints/localai/config_meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,5 +239,54 @@ backend: llama-cpp
Expect(err).NotTo(HaveOccurred())
Expect(string(data)).To(ContainSubstring("vllm"))
})

It("should not persist runtime defaults (SetDefaults values) to disk", func() {
// Create a minimal pipeline config - no sampling params
seedConfig := `name: gpt-realtime
pipeline:
vad: silero-vad
transcription: whisper-base
llm: llama3
tts: piper
`
configPath := filepath.Join(tempDir, "gpt-realtime.yaml")
Expect(os.WriteFile(configPath, []byte(seedConfig), 0644)).To(Succeed())
Expect(configLoader.LoadModelConfigsFromPath(tempDir)).To(Succeed())

// PATCH with a small change to the pipeline
body := bytes.NewBufferString(`{"pipeline": {"tts": "vibevoice"}}`)
req := httptest.NewRequest(http.MethodPatch, "/api/models/config-json/gpt-realtime", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)

Expect(rec.Code).To(Equal(http.StatusOK))

// Read the file from disk and verify no spurious defaults leaked
data, err := os.ReadFile(configPath)
Expect(err).NotTo(HaveOccurred())
fileContent := string(data)

// The patched value should be present
Expect(fileContent).To(ContainSubstring("vibevoice"))

// Runtime-only defaults from SetDefaults() should NOT be in the file
Expect(fileContent).NotTo(ContainSubstring("top_p"))
Expect(fileContent).NotTo(ContainSubstring("top_k"))
Expect(fileContent).NotTo(ContainSubstring("temperature"))
Expect(fileContent).NotTo(ContainSubstring("mirostat"))
Expect(fileContent).NotTo(ContainSubstring("mmap"))
Expect(fileContent).NotTo(ContainSubstring("mmlock"))
Expect(fileContent).NotTo(ContainSubstring("threads"))
Expect(fileContent).NotTo(ContainSubstring("low_vram"))
Expect(fileContent).NotTo(ContainSubstring("embeddings"))
Expect(fileContent).NotTo(ContainSubstring("f16"))

// Original fields should still be present
Expect(fileContent).To(ContainSubstring("gpt-realtime"))
Expect(fileContent).To(ContainSubstring("silero-vad"))
Expect(fileContent).To(ContainSubstring("whisper-base"))
Expect(fileContent).To(ContainSubstring("llama3"))
})
})
})
70 changes: 27 additions & 43 deletions core/http/endpoints/localai/import_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,48 +119,20 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applica
return c.JSON(http.StatusBadRequest, response)
}

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

if strings.Contains(contentType, "application/json") {
// Parse JSON
var modelConfig config.ModelConfig
if isJSON {
if err := json.Unmarshal(body, &modelConfig); err != nil {
response := ModelResponse{
Success: false,
Error: "Failed to parse JSON: " + err.Error(),
}
return c.JSON(http.StatusBadRequest, response)
}
} else if strings.Contains(contentType, "application/x-yaml") || strings.Contains(contentType, "text/yaml") {
// Parse YAML
if err := yaml.Unmarshal(body, &modelConfig); err != nil {
response := ModelResponse{
Success: false,
Error: "Failed to parse YAML: " + err.Error(),
}
return c.JSON(http.StatusBadRequest, response)
return c.JSON(http.StatusBadRequest, ModelResponse{Success: false, Error: "Failed to parse JSON: " + err.Error()})
}
} else {
// Try to auto-detect format
if len(body) > 0 && strings.TrimSpace(string(body))[0] == '{' {
// Looks like JSON
if err := json.Unmarshal(body, &modelConfig); err != nil {
response := ModelResponse{
Success: false,
Error: "Failed to parse JSON: " + err.Error(),
}
return c.JSON(http.StatusBadRequest, response)
}
} else {
// Assume YAML
if err := yaml.Unmarshal(body, &modelConfig); err != nil {
response := ModelResponse{
Success: false,
Error: "Failed to parse YAML: " + err.Error(),
}
return c.JSON(http.StatusBadRequest, response)
}
if err := yaml.Unmarshal(body, &modelConfig); err != nil {
return c.JSON(http.StatusBadRequest, ModelResponse{Success: false, Error: "Failed to parse YAML: " + err.Error()})
}
}

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

// Set defaults
modelConfig.SetDefaults(appConfig.ToConfigLoaderOptions()...)

// Validate the configuration
// Validate without calling SetDefaults() — runtime defaults should not
// be persisted to disk. SetDefaults() is called when loading configs
// for inference via LoadModelConfigsFromPath().
if valid, _ := modelConfig.Validate(); !valid {
response := ModelResponse{
Success: false,
Expand All @@ -195,8 +166,21 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applica
return c.JSON(http.StatusBadRequest, response)
}

// Marshal to YAML for storage
yamlData, err := yaml.Marshal(&modelConfig)
// Write only the user-provided fields to disk by parsing the original
// body into a map (not the typed struct, which includes Go zero values).
var bodyMap map[string]any
if isJSON {
_ = json.Unmarshal(body, &bodyMap)
} else {
_ = yaml.Unmarshal(body, &bodyMap)
}

var yamlData []byte
if bodyMap != nil {
yamlData, err = yaml.Marshal(bodyMap)
} else {
yamlData, err = yaml.Marshal(&modelConfig)
}
if err != nil {
response := ModelResponse{
Success: false,
Expand Down
Loading
Loading