Skip to content

Commit 2fe10c3

Browse files
localai-botmudler
andauthored
fix(model-artifacts): persist companion artifacts so remote workers get the base_model option (#11075)
fix(model-artifacts): persist companion artifacts, not just the primary A managed model can declare companion artifacts (LongCat-Video-Avatar-1.5 pulls its tokenizer, text encoder and VAE from the separate LongCat-Video base repo via a target: companion artifact). preloadOne resolves the whole set in memory, but the binding written back to disk carried only the primary: persistArtifactBinding marshalled []Spec{result.Spec} and replaced the entire artifacts: list with it, silently dropping every companion. In a single process the loss is invisible because the in-memory config keeps the companion. It bites on the next controller restart: the config reloads from the mangled file with the primary alone, so withCompanionArtifactOptions finds no resolved companion and synthesizes no base_model option. The remote longcat-video backend then never receives base_model, falls back to BASE_MODEL_ID and downloads the repo itself ("Downloading required files for meituan-longcat/LongCat-Video"), failing the load with "base_model must point to a LongCat-Video checkpoint". This is why an explicit base_model:<path> added to the config options works where the managed companion does not: an explicit option lives in options:, which is never rewritten, while the managed companion lives in artifacts:, which the binding overwrote. Persist the full resolved set (primary + every companion), and widen bindingNeedsPersistence to compare the whole artifact list so a companion resolving for the first time still triggers a write. The single-node path is unaffected: there the in-memory config already carried the companion, and the staging/ModelPath resolution for a remote worker (nested per-model staged root, #10949) is unchanged and already correct once the option is generated. Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent aae69b1 commit 2fe10c3

4 files changed

Lines changed: 108 additions & 17 deletions

File tree

core/config/model_artifact_binding.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@ import (
1010
"github.com/mudler/LocalAI/pkg/modelartifacts"
1111
)
1212

13-
func persistArtifactBinding(fileName, modelName string, result modelartifacts.Result) error {
13+
// persistArtifactBinding writes the resolved artifact set back into a model's
14+
// config document. It replaces the whole `artifacts:` list, so the caller must
15+
// pass EVERY artifact the model declares — the primary and all companions — not
16+
// just the one that triggered the write. Persisting only the primary silently
17+
// dropped companions from disk, and on the next controller restart the reloaded
18+
// config had no companion at all: withCompanionArtifactOptions then synthesized
19+
// no companion option and a remote backend fell back to fetching the companion
20+
// repo itself, failing the load (the distributed longcat-video base_model bug).
21+
func persistArtifactBinding(fileName, modelName string, artifacts []modelartifacts.Spec) error {
1422
data, err := os.ReadFile(fileName)
1523
if err != nil {
1624
return err
@@ -24,7 +32,7 @@ func persistArtifactBinding(fileName, modelName string, result modelartifacts.Re
2432
return err
2533
}
2634
artifactValue := &yaml.Node{}
27-
encoded, err := yaml.Marshal([]modelartifacts.Spec{result.Spec})
35+
encoded, err := yaml.Marshal(artifacts)
2836
if err != nil {
2937
return err
3038
}

core/config/model_artifact_binding_test.go

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,16 @@ var _ = Describe("artifact binding persistence", func() {
2525
sibling_only: true
2626
parameters: {model: sibling.gguf}
2727
`), 0644)).To(Succeed())
28-
result := modelartifacts.Result{
29-
RelativePath: ".artifacts/huggingface/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/snapshot",
30-
Spec: modelartifacts.Spec{
31-
Name: "model", Target: "model",
32-
Source: modelartifacts.Source{Type: "huggingface", Repo: "owner/repo", Revision: "main"},
33-
Resolved: &modelartifacts.Resolved{
34-
Endpoint: "https://huggingface.co",
35-
Revision: "0123456789abcdef0123456789abcdef01234567",
36-
CacheKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
37-
},
28+
primary := modelartifacts.Spec{
29+
Name: "model", Target: "model",
30+
Source: modelartifacts.Source{Type: "huggingface", Repo: "owner/repo", Revision: "main"},
31+
Resolved: &modelartifacts.Resolved{
32+
Endpoint: "https://huggingface.co",
33+
Revision: "0123456789abcdef0123456789abcdef01234567",
34+
CacheKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
3835
},
3936
}
40-
Expect(persistArtifactBinding(fileName, "managed", result)).To(Succeed())
37+
Expect(persistArtifactBinding(fileName, "managed", []modelartifacts.Spec{primary})).To(Succeed())
4138
updated, err := os.ReadFile(fileName)
4239
Expect(err).NotTo(HaveOccurred())
4340
Expect(string(updated)).To(ContainSubstring("name: sibling"))
@@ -47,4 +44,48 @@ var _ = Describe("artifact binding persistence", func() {
4744
Expect(string(updated)).To(ContainSubstring("cache_key: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
4845
Expect(string(updated)).To(ContainSubstring("revision: 0123456789abcdef0123456789abcdef01234567"))
4946
})
47+
48+
It("writes back every artifact it is given, primary and companion", func() {
49+
// The binding replaces the whole artifacts list, so a companion is only
50+
// retained if it is passed in. Dropping it here is what lost companions
51+
// on a controller restart (the distributed longcat-video base_model bug).
52+
fileName := filepath.Join(GinkgoT().TempDir(), "models.yaml")
53+
Expect(os.WriteFile(fileName, []byte(`
54+
- name: avatar
55+
backend: longcat-video
56+
artifacts:
57+
- name: model
58+
target: model
59+
source: {type: huggingface, repo: owner/avatar}
60+
- name: base_model
61+
target: companion
62+
source: {type: huggingface, repo: owner/base}
63+
parameters: {model: owner/avatar}
64+
`), 0644)).To(Succeed())
65+
primaryKey := "1111111111111111111111111111111111111111111111111111111111111111"
66+
companionKey := "2222222222222222222222222222222222222222222222222222222222222222"
67+
resolved := func(repo, key string) modelartifacts.Spec {
68+
return modelartifacts.Spec{
69+
Name: "x", Target: "companion",
70+
Source: modelartifacts.Source{Type: "huggingface", Repo: repo, Revision: "main"},
71+
Resolved: &modelartifacts.Resolved{
72+
Endpoint: "https://huggingface.co",
73+
Revision: "0123456789abcdef0123456789abcdef01234567",
74+
CacheKey: key,
75+
},
76+
}
77+
}
78+
primary := resolved("owner/avatar", primaryKey)
79+
primary.Name, primary.Target = "model", "model"
80+
companion := resolved("owner/base", companionKey)
81+
companion.Name = "base_model"
82+
83+
Expect(persistArtifactBinding(fileName, "avatar", []modelartifacts.Spec{primary, companion})).To(Succeed())
84+
85+
updated, err := os.ReadFile(fileName)
86+
Expect(err).NotTo(HaveOccurred())
87+
Expect(string(updated)).To(ContainSubstring("name: base_model"))
88+
Expect(string(updated)).To(ContainSubstring(primaryKey))
89+
Expect(string(updated)).To(ContainSubstring(companionKey))
90+
})
5091
})

core/config/model_config_loader.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -435,12 +435,15 @@ func (bcl *ModelConfigLoader) PreloadWithContext(ctx context.Context, modelPath
435435
bcl.Unlock()
436436
continue
437437
}
438-
if artifactResult != nil && bindingNeedsPersistence(current, *artifactResult) && current.modelConfigFile != "" {
438+
// Persist the WHOLE resolved artifact set (primary + every companion),
439+
// not just the primary result: writing back only the primary dropped
440+
// companions from disk and lost them on the next restart.
441+
if artifactResult != nil && bindingNeedsPersistence(current, updated.Artifacts) && current.modelConfigFile != "" {
439442
modelartifacts.ReportProgress(ctx, modelartifacts.ProgressEvent{
440443
Phase: modelartifacts.PhasePersisting,
441444
Artifact: artifactResult.Spec.Name,
442445
})
443-
if err := persistArtifactBinding(current.modelConfigFile, current.Name, *artifactResult); err != nil {
446+
if err := persistArtifactBinding(current.modelConfigFile, current.Name, updated.Artifacts); err != nil {
444447
bcl.Unlock()
445448
return err
446449
}
@@ -549,8 +552,14 @@ func (bcl *ModelConfigLoader) preloadOne(
549552
return updated, artifactResult, nil
550553
}
551554

552-
func bindingNeedsPersistence(current ModelConfig, result modelartifacts.Result) bool {
553-
return len(current.Artifacts) == 0 || !reflect.DeepEqual(current.Artifacts[0], result.Spec)
555+
// bindingNeedsPersistence reports whether the freshly resolved artifact set
556+
// differs from what is currently on the config, and so has to be written back.
557+
// It compares the WHOLE set, not just the primary: a companion that resolved
558+
// for the first time (or changed) must trigger a write even when the primary is
559+
// unchanged, or its resolved state would never reach disk and would be lost on
560+
// the next restart.
561+
func bindingNeedsPersistence(current ModelConfig, resolved []modelartifacts.Spec) bool {
562+
return !reflect.DeepEqual(current.Artifacts, resolved)
554563
}
555564

556565
func (bcl *ModelConfigLoader) displayPreloadedModel(config ModelConfig) {

core/config/model_config_loader_companion_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,39 @@ parameters: {model: meituan-longcat/LongCat-Video-Avatar-1.5}
111111
Expect(loaded.ModelFileName()).To(ContainSubstring(loaded.Artifacts[0].Resolved.CacheKey))
112112
})
113113

114+
It("keeps every resolved artifact in the persisted file across a reload", func() {
115+
// Regression for the distributed longcat-video companion loss: a
116+
// controller resolves the primary and companion in memory, but if the
117+
// binding it writes back to disk carries only the primary, the companion
118+
// is gone the moment the process restarts and reloads the file. With no
119+
// companion in the config, withCompanionArtifactOptions synthesizes no
120+
// base_model option, so the remote backend falls back to downloading the
121+
// base repo itself and fails ("base_model must point to a LongCat-Video
122+
// checkpoint"). The persisted document, reloaded fresh, must still name
123+
// the companion.
124+
modelsPath := GinkgoT().TempDir()
125+
configPath := filepath.Join(modelsPath, "avatar.yaml")
126+
Expect(os.WriteFile(configPath, []byte(companionConfig), 0644)).To(Succeed())
127+
128+
fake := &companionMaterializer{}
129+
loader := NewModelConfigLoader(modelsPath, WithArtifactMaterializer(fake))
130+
Expect(loader.LoadModelConfigsFromPath(modelsPath)).To(Succeed())
131+
Expect(loader.PreloadWithContext(context.Background(), modelsPath)).To(Succeed())
132+
133+
// A fresh loader models the restart: it only ever sees what was written
134+
// back to disk, never the in-memory state the first loader held.
135+
reloaded := NewModelConfigLoader(modelsPath, WithArtifactMaterializer(&companionMaterializer{}))
136+
Expect(reloaded.LoadModelConfigsFromPath(modelsPath)).To(Succeed())
137+
138+
persisted, found := reloaded.GetModelConfig("avatar")
139+
Expect(found).To(BeTrue())
140+
Expect(persisted.Artifacts).To(HaveLen(2))
141+
Expect(persisted.Artifacts[1].Name).To(Equal("base_model"))
142+
Expect(persisted.Artifacts[1].Target).To(Equal(modelartifacts.TargetCompanion))
143+
Expect(persisted.Artifacts[1].Resolved).ToNot(BeNil())
144+
Expect(persisted.Artifacts[1].Resolved.CacheKey).ToNot(BeEmpty())
145+
})
146+
114147
It("fails the load when an explicitly declared companion cannot be acquired", func() {
115148
// Explicit artifacts are all-or-nothing: a config that names a companion
116149
// is asserting the backend needs it, so silently loading without it

0 commit comments

Comments
 (0)