fix(distributed): order derived usecases deterministically

syncKnownUsecasesFromString rebuilds KnownUsecaseStrings by ranging
GetAllModelConfigUsecases, which is a map. Go randomizes that order per
call, and the field is part of the serialized config, so one unchanged
YAML hashed to a different config revision on every load.

A model that derives a single usecase hid the problem. One that derives
several, such as a chat model with an mmproj, alternated between as many
revisions as there are orderings. The router treats a revision it did
not establish as a config change, so requests failed with "stale model
config revision" until the stored value happened to match again.

Sorting the list makes the revision a function of the file alone.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
This commit is contained in:
Ettore Di Giacinto committed 2026-08-23 17:20:20 +00:00
1 parent 04735cd1f6
commit f3fabe8c5c
3 files changed
+101 -1

No files matched your search

+6
View File
@@ -1369,6 +1369,12 @@ func (c *ModelConfig) syncKnownUsecasesFromString() {
c.KnownUsecaseStrings = append(c.KnownUsecaseStrings, k)
}
}
// GetAllModelConfigUsecases returns a map, and ranging one yields a random
// order per call. KnownUsecaseStrings is part of the serialized config, so
// an unsorted list gives the same file a different config revision on every
// load. In distributed mode that reads as a config change and the router
// rejects the request with ErrStaleModelConfigRevision.
slices.Sort(c.KnownUsecaseStrings)
}
func (c *ModelConfig) UnmarshalYAML(value *yaml.Node) error {
@@ -0,0 +1,90 @@
package config_test
import (
"os"
"path/filepath"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/system"
)
// The distributed controller pins a model's replicas to its config revision and
// rejects any request carrying a different one. A revision that is not stable
// for one unchanged file on disk therefore wedges the model.
var _ = Describe("Model config revision stability", func() {
// A chat model with an mmproj derives two usecase flags, FLAG_CHAT and
// FLAG_VISION. syncKnownUsecasesFromString builds that list by ranging a
// map, so an unstable order shows up with two or more flags and stays
// hidden with one.
const multiUsecaseModel = `backend: llama-cpp
context_size: 50000
known_usecases:
- chat
mmproj: llama-cpp/mmproj/example/mmproj.gguf
name: example
options:
- use_jinja:true
- parallel:2
parameters:
model: llama-cpp/models/example/example.gguf
template:
use_tokenizer_template: true
`
var (
dir string
appConfig *config.ApplicationConfig
)
BeforeEach(func() {
dir = GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(dir, "example.yaml"), []byte(multiUsecaseModel), 0o600)).To(Succeed())
appConfig = config.NewApplicationConfig()
appConfig.SystemState = &system.SystemState{Model: system.Model{ModelsPath: dir}}
})
loadRevision := func() string {
loader := config.NewModelConfigLoader(dir)
Expect(loader.LoadModelConfigsFromPath(dir, appConfig.ToConfigLoaderOptions()...)).To(Succeed())
cfg, ok := loader.GetModelConfig("example")
Expect(ok).To(BeTrue())
revision, err := config.ModelConfigRevision(&cfg)
Expect(err).ToNot(HaveOccurred())
return revision
}
It("does not change when the same file is loaded repeatedly", func() {
baseline := loadRevision()
for i := 0; i < 20; i++ {
Expect(loadRevision()).To(Equal(baseline), "revision changed between two loads of one unchanged file")
}
})
It("orders the derived usecases deterministically", func() {
loader := config.NewModelConfigLoader(dir)
Expect(loader.LoadModelConfigsFromPath(dir, appConfig.ToConfigLoaderOptions()...)).To(Succeed())
cfg, ok := loader.GetModelConfig("example")
Expect(ok).To(BeTrue())
Expect(len(cfg.KnownUsecaseStrings)).To(BeNumerically(">=", 2), "fixture must derive several usecases to expose ordering")
Expect(cfg.KnownUsecaseStrings).To(Equal([]string{"FLAG_CHAT", "FLAG_VISION"}))
})
// The request pipeline reloads the config through LoadModelConfigFileByName,
// which applies SetDefaults a second time. That must not move the revision
// away from the one model administration publishes from the loader map.
It("survives the extra SetDefaults the request path applies", func() {
loader := config.NewModelConfigLoader(dir)
Expect(loader.LoadModelConfigsFromPath(dir, appConfig.ToConfigLoaderOptions()...)).To(Succeed())
stored, ok := loader.GetModelConfig("example")
Expect(ok).To(BeTrue())
adminRevision, err := config.ModelConfigRevision(&stored)
Expect(err).ToNot(HaveOccurred())
requestCfg, err := loader.LoadModelConfigFileByNameDefaultOptions("example", appConfig)
Expect(err).ToNot(HaveOccurred())
Expect(requestCfg.PersistedConfigRevision()).To(Equal(adminRevision))
})
})
@@ -50,7 +50,11 @@ var _ = Describe("Model config revision seen by inference requests", func() {
Expect(os.WriteFile(
filepath.Join(modelDir, "test-model.yaml"),
[]byte("name: test-model\nbackend: llama-cpp\ncontext_size: 4096\n"),
// The mmproj makes this derive several usecase flags. A single-flag
// model hides any instability in how that derived list is ordered.
[]byte("name: test-model\nbackend: llama-cpp\ncontext_size: 4096\n"+
"mmproj: llama-cpp/mmproj/test-model/mmproj.gguf\n"+
"known_usecases:\n - chat\n"),
0o600,
)).To(Succeed())