Skip to content

Commit 626ae4d

Browse files
localai-botmudler
andauthored
fix(model-artifacts): materialize longcat-video on the controller, and support companion repos (#10949)
* fix(model-artifacts): materialize longcat-video checkpoints on the controller longcat-video loads a checkpoint directory: its backend.py takes request.ModelFile when os.path.isdir(request.ModelFile) and otherwise falls back to snapshot_download. That places it in the same class as transformers/vllm/diffusers/sglang, but the allow-list added in #10910 did not enumerate it, so PrimaryArtifactSpec returned no managed artifact for a bare HuggingFace repo id. The consequence in distributed mode: nothing was acquired on the controller, ModelFileName fell through to the raw repo id, and staging skipped the resulting phantom /models/<owner>/<repo> path. The worker received a blank ModelFile, fell back to request.Model, and downloaded ~83GB from HuggingFace inside the remote LoadModel deadline - so the load could only ever fail with DeadlineExceeded while an abandoned backend process kept downloading. Note this materializes the full repository. The backend restricts its own snapshot_download with allow_patterns, and the avatar repo ships both base_model/ and base_model_int8/ where only one is ever loaded; inferred specs have no way to carry patterns today. Tracked separately. Assisted-by: Claude:opus-4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): warn when staging skips a non-existent model path stageModelFiles logs "Staging model files for remote node" up front, then silently drops any path field that does not exist on the controller. The skip itself is legitimate and must stay: a backend outside managedArtifactBackends that takes a bare HuggingFace repo id gets an optimistically constructed path (ModelFileName falls through to the raw model reference) that was never materialized, and sources its own weights on the worker. Erroring would break those configs. But at debug level the operator is left with a reassuring staging line and no trace of the skip, so a genuine controller-side acquisition gap is indistinguishable from a healthy pass-through - it surfaces much later as a remote LoadModel timeout, on a worker that is quietly downloading tens of gigabytes. Raise the skip to warn and name the field, path, node and tracking key. Behavior is unchanged. Assisted-by: Claude:opus-4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(model-artifacts): allow a config to declare companion artifacts A composed pipeline needs more than one HuggingFace snapshot. LongCat-Video-Avatar-1.5 loads its own transformer but takes the tokenizer, text encoder and VAE from the separate LongCat-Video base repo, so a single-artifact config cannot express it and the backend is left to fetch the second repo itself at load time. Widen the artifact model to target: model plus any number of named target: companion entries. Normalize accepts the new target and constrains a companion name to [a-z0-9][a-z0-9_-]{0,63} because that name is the option key the backend later receives; a companion may not claim primary_file, which only means anything for a load target. ModelConfig.Validate requires exactly one primary and requires it first, since Artifacts[0] is what ModelFileName, size estimation and staging all resolve from. Both acquisition paths now loop instead of touching index 0 alone: preloadOne for an already-installed config, bindPrimaryArtifact for a gallery install. Failure policy differs by provenance. An inferred primary keeps its warn-and-fall-back, because the legacy download path still exists for it. Companions are explicit by construction, so they are all-or-nothing: a config naming one is asserting the backend needs it, and failing at the acquisition boundary is far more legible than a missing-weights error surfacing later inside the backend. The cache key is deliberately unchanged. It hashes source identity only, never name or target, so every already-installed managed model still hits its existing snapshot instead of silently re-downloading. Two specs pin that: one proving a companion and a primary with identical sources agree on the key, and one pinning the digest of a known primary outright. Assisted-by: Claude:opus-4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(model-artifacts): hand resolved companion snapshots to the backend A materialized companion is useless until the backend can find it, and its location is a content-addressed cache key that does not exist until the artifact resolves. A static gallery override cannot carry that, and persisting it into the config YAML would rot the moment a re-resolve produced a new key. Synthesize it instead at load time: each resolved companion becomes "<artifact name>:<snapshot path>" in ModelOptions.Options, reusing the key:value convention backends already parse for options like attention_backend. The value stays relative to the models directory so a remote worker can resolve it under its own ModelPath once staging has rewritten the model root. An option the author set explicitly always wins, so pinning a companion to a local checkout still beats the managed snapshot. longcat-video resolves base_model through ModelPath, the same convention qwen-tts, voxcpm, outetts and ace-step already use for companion assets. Its sibling-directory heuristic is deleted: it looked for a LongCat-Video directory next to the model, which cannot exist under the content addressed .artifacts/huggingface/<key>/snapshot layout, so it was dead code the moment the model became managed. The gallery entry declares both repositories and restricts each with allow_patterns. The avatar repo ships base_model/ and base_model_int8/ and only ever loads one, so fetching the whole repo would roughly double the download. The patterns match the entry's own options (use_distill true, use_int8 default false); enabling use_int8 here also requires adding base_model_int8/**, which is called out in the entry. Assisted-by: Claude:opus-4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): stage managed artifact trees from the models root Staging anchored the worker's models directory on the primary snapshot whenever a model was managed, so a companion snapshot could not reach the worker at all. frontendModelsDir was derived by stripping the Model relative path off the end of ModelFile. For a managed artifact nothing matches: ModelFile is .artifacts/huggingface/<key>/snapshot while Model stays a bare HuggingFace repo id, so the strip was a no-op and the "models directory" came out as the snapshot itself. Two consequences, both silent. Staging keys lost the .artifacts/huggingface/<key>/snapshot prefix, so two snapshots of one model were indistinguishable on the worker. And a companion, which lives in a sibling snapshot directory outside the primary, fell outside that directory entirely: StagingKeyMapper.Key collapsed its files to bare basenames and resolveOptionPath could not resolve the relative option at all, so it was skipped without a word. Derive the models root from the artifact tree instead when the path runs through it, and compute the worker's ModelPath from the file's path relative to that root rather than from the Model field. The legacy layout is unaffected: where Model really is the relative path, the new derivation reduces to the old one, which a regression spec pins. This deliberately changes an invariant that router_dirstage_test.go pinned: for a managed primary, ModelFile and ModelPath were both the snapshot directory, and staging keys were relative to it. Now ModelFile is the snapshot, ModelPath is the models root above it, and keys keep the full relative path. That spec is updated rather than accommodated, with the reasoning recorded inline, because the old invariant is exactly what made a sibling companion unreachable. Assisted-by: Claude:opus-4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 09b85ee commit 626ae4d

19 files changed

Lines changed: 1021 additions & 35 deletions

backend/python/longcat-video/backend.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ def __init__(self):
109109
self.device_index = 0
110110
self.cp_split_hw = None
111111
self._dist_store_dir = None
112+
self.model_path = ""
112113

113114
def Health(self, request, context):
114115
return backend_pb2.Reply(message=b"OK")
@@ -118,6 +119,11 @@ def LoadModel(self, request, context):
118119
if request.ModelFile and os.path.isdir(request.ModelFile):
119120
model = request.ModelFile
120121

122+
# A managed companion snapshot (see base_model) is passed as a path
123+
# relative to the models directory, which is ModelPath here and a
124+
# different directory on a remote worker once staging has rewritten it.
125+
self.model_path = getattr(request, "ModelPath", "") or ""
126+
121127
model_kind = classify_model(model)
122128
if model_kind is None:
123129
return self._fail(
@@ -376,6 +382,26 @@ def _ensure_distributed(self):
376382
)
377383
self.cp_split_hw = self.context_parallel_util.get_optimal_split(1)
378384

385+
def _resolve_option_path(self, value):
386+
"""Resolve an option that may name a local directory or a HF repo id.
387+
388+
A managed companion artifact is handed to us relative to the models
389+
directory, so it only becomes a real path once joined with ModelPath;
390+
that indirection is what lets the same config work on a remote worker,
391+
where staging puts the snapshot somewhere else entirely. Anything that
392+
is already an absolute directory, or is not a path at all (a plain repo
393+
id), is passed through untouched for snapshot_download to handle.
394+
"""
395+
if not value:
396+
return value
397+
candidate = normalize_model_source(str(value))
398+
if os.path.isabs(candidate) or not self.model_path:
399+
return value
400+
joined = os.path.join(self.model_path, candidate)
401+
if os.path.isdir(joined):
402+
return joined
403+
return value
404+
379405
def _resolve_checkpoint(self, model, patterns):
380406
source = normalize_model_source(model)
381407
if os.path.isdir(source):
@@ -440,13 +466,7 @@ def _load_avatar_model(self, model):
440466
avatar_patterns.append(f"{model_subfolder}/**")
441467
checkpoint = self._resolve_checkpoint(model, avatar_patterns)
442468

443-
base_model = self.options.get("base_model")
444-
if not base_model and os.path.isdir(normalize_model_source(model)):
445-
sibling = os.path.join(
446-
os.path.dirname(normalize_model_source(model)), "LongCat-Video"
447-
)
448-
if os.path.isdir(sibling):
449-
base_model = sibling
469+
base_model = self._resolve_option_path(self.options.get("base_model"))
450470
base_model = base_model or BASE_MODEL_ID
451471
if classify_model(str(base_model)) != MODEL_KIND_BASE:
452472
raise ValueError("base_model must point to a LongCat-Video checkpoint")
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package backend
2+
3+
import (
4+
"strings"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
9+
"github.com/mudler/LocalAI/core/config"
10+
"github.com/mudler/LocalAI/pkg/modelartifacts"
11+
)
12+
13+
// A companion snapshot only becomes useful once the backend can find it, and
14+
// its location is a content-addressed cache key that is unknowable until the
15+
// artifact resolves. A static gallery override therefore cannot carry it. The
16+
// path is instead synthesized into ModelOptions.Options at load time, under the
17+
// companion's own name, using the same key:value convention backends already
18+
// parse for options like attention_backend.
19+
var _ = Describe("companion artifact backend options", func() {
20+
const (
21+
primaryKey = "1111111111111111111111111111111111111111111111111111111111111111"
22+
companionKey = "2222222222222222222222222222222222222222222222222222222222222222"
23+
)
24+
25+
resolved := func(key string) *modelartifacts.Resolved {
26+
return &modelartifacts.Resolved{
27+
Endpoint: "https://huggingface.co",
28+
Revision: "0123456789abcdef0123456789abcdef01234567",
29+
CacheKey: key,
30+
}
31+
}
32+
33+
threads := 1
34+
configWithCompanion := func(options ...string) config.ModelConfig {
35+
return config.ModelConfig{
36+
Backend: "longcat-video",
37+
Options: options,
38+
Threads: &threads,
39+
Artifacts: []modelartifacts.Spec{
40+
{
41+
Name: modelartifacts.TargetModel, Target: modelartifacts.TargetModel,
42+
Source: modelartifacts.Source{Type: "huggingface", Repo: "meituan-longcat/LongCat-Video-Avatar-1.5", Revision: "main"},
43+
Resolved: resolved(primaryKey),
44+
},
45+
{
46+
Name: "base_model", Target: modelartifacts.TargetCompanion,
47+
Source: modelartifacts.Source{Type: "huggingface", Repo: "meituan-longcat/LongCat-Video", Revision: "main"},
48+
Resolved: resolved(companionKey),
49+
},
50+
},
51+
}
52+
}
53+
54+
optionValue := func(options []string, key string) (string, bool) {
55+
for _, option := range options {
56+
name, value, found := strings.Cut(option, ":")
57+
if found && name == key {
58+
return value, true
59+
}
60+
}
61+
return "", false
62+
}
63+
64+
It("exposes a resolved companion snapshot as an option named after the artifact", func() {
65+
opts := grpcModelOpts(configWithCompanion("attention_backend:sdpa"), "/models")
66+
67+
value, found := optionValue(opts.Options, "base_model")
68+
Expect(found).To(BeTrue())
69+
expected, err := modelartifacts.RelativeSnapshotPath(companionKey)
70+
Expect(err).NotTo(HaveOccurred())
71+
Expect(value).To(Equal(expected))
72+
// The companion path must never be confused with the load target.
73+
Expect(value).ToNot(ContainSubstring(primaryKey))
74+
// Author-supplied options survive untouched.
75+
Expect(opts.Options).To(ContainElement("attention_backend:sdpa"))
76+
})
77+
78+
It("keeps the path relative so the worker resolves it under its own ModelPath", func() {
79+
opts := grpcModelOpts(configWithCompanion(), "/models")
80+
81+
value, found := optionValue(opts.Options, "base_model")
82+
Expect(found).To(BeTrue())
83+
Expect(strings.HasPrefix(value, "/")).To(BeFalse())
84+
})
85+
86+
It("does not override an explicitly configured option of the same name", func() {
87+
// An operator pinning base_model to a local checkout must win over the
88+
// synthesized value.
89+
opts := grpcModelOpts(configWithCompanion("base_model:/opt/checkouts/LongCat-Video"), "/models")
90+
91+
Expect(opts.Options).To(ContainElement("base_model:/opt/checkouts/LongCat-Video"))
92+
values := 0
93+
for _, option := range opts.Options {
94+
if strings.HasPrefix(option, "base_model:") {
95+
values++
96+
}
97+
}
98+
Expect(values).To(Equal(1))
99+
})
100+
101+
It("synthesizes nothing for a config without companions", func() {
102+
cfg := config.ModelConfig{
103+
Backend: "transformers",
104+
Options: []string{"attention_backend:sdpa"},
105+
Threads: &threads,
106+
Artifacts: []modelartifacts.Spec{{
107+
Name: modelartifacts.TargetModel, Target: modelartifacts.TargetModel,
108+
Source: modelartifacts.Source{Type: "huggingface", Repo: "owner/repo", Revision: "main"},
109+
Resolved: resolved(primaryKey),
110+
}},
111+
}
112+
opts := grpcModelOpts(cfg, "/models")
113+
Expect(opts.Options).To(Equal([]string{"attention_backend:sdpa"}))
114+
})
115+
116+
It("skips a companion that has not been resolved yet", func() {
117+
cfg := configWithCompanion()
118+
cfg.Artifacts[1].Resolved = nil
119+
opts := grpcModelOpts(cfg, "/models")
120+
_, found := optionValue(opts.Options, "base_model")
121+
Expect(found).To(BeFalse())
122+
})
123+
})

core/backend/options.go

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"math/rand/v2"
88
"os"
99
"path/filepath"
10+
"slices"
1011
"strings"
1112
"time"
1213

@@ -280,6 +281,48 @@ func EffectiveBatchSize(c config.ModelConfig) int {
280281
return DefaultBatchSize
281282
}
282283

284+
// withCompanionArtifactOptions surfaces each resolved companion snapshot to the
285+
// backend as "<artifact name>:<snapshot path>", reusing the key:value option
286+
// convention backends already parse.
287+
//
288+
// The value is deliberately relative to the models directory and deliberately
289+
// not persisted to the config YAML. It is derived from a content-addressed cache
290+
// key that only exists after the artifact resolves, so a static gallery override
291+
// could not carry it, and a persisted copy would rot the moment a re-resolve
292+
// produced a new key. Staying relative also lets a remote worker resolve it
293+
// under its own ModelPath after staging rewrites the model root.
294+
//
295+
// An option the author set explicitly always wins: pinning a companion to a
296+
// local checkout has to beat the managed snapshot.
297+
func withCompanionArtifactOptions(options []string, artifacts []modelartifacts.Spec) []string {
298+
configured := make(map[string]struct{}, len(options))
299+
for _, option := range options {
300+
if name, _, found := strings.Cut(option, ":"); found {
301+
configured[name] = struct{}{}
302+
}
303+
}
304+
305+
// Copy before appending: opts.Options would otherwise share (and could
306+
// reallocate away from) the config's own slice.
307+
combined := slices.Clone(options)
308+
for _, artifact := range artifacts {
309+
if artifact.Target != modelartifacts.TargetCompanion || artifact.Resolved == nil {
310+
continue
311+
}
312+
if _, exists := configured[artifact.Name]; exists {
313+
xlog.Debug("keeping the configured companion option over the managed snapshot", "artifact", artifact.Name)
314+
continue
315+
}
316+
snapshot, err := modelartifacts.RelativeSnapshotPath(artifact.Resolved.CacheKey)
317+
if err != nil {
318+
xlog.Warn("skipping companion artifact with an unusable cache key", "artifact", artifact.Name, "error", err)
319+
continue
320+
}
321+
combined = append(combined, artifact.Name+":"+snapshot)
322+
}
323+
return combined
324+
}
325+
283326
func grpcModelOpts(c config.ModelConfig, modelPath string) *pb.ModelOptions {
284327
ctxSize := EffectiveContextSize(c)
285328
b := EffectiveBatchSize(c)
@@ -370,7 +413,7 @@ func grpcModelOpts(c config.ModelConfig, modelPath string) *pb.ModelOptions {
370413
IMG2IMG: c.Diffusers.IMG2IMG,
371414
CLIPModel: c.Diffusers.ClipModel,
372415
CLIPSubfolder: c.Diffusers.ClipSubFolder,
373-
Options: c.Options,
416+
Options: withCompanionArtifactOptions(c.Options, c.Artifacts),
374417
Overrides: c.Overrides,
375418
EngineArgs: engineArgsJSON,
376419
CLIPSkip: int32(c.Diffusers.ClipSkip),
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package config_test
2+
3+
import (
4+
. "github.com/onsi/ginkgo/v2"
5+
. "github.com/onsi/gomega"
6+
"gopkg.in/yaml.v3"
7+
8+
"github.com/mudler/LocalAI/core/config"
9+
)
10+
11+
// A composed pipeline needs more than one snapshot: LongCat-Video-Avatar-1.5
12+
// loads its own transformer but takes tokenizer, text encoder and VAE from the
13+
// LongCat-Video base repo. The config expresses that as one target: model
14+
// artifact followed by named target: companion artifacts. Exactly one primary,
15+
// and it must be first, because ModelFileName() and every load path resolve the
16+
// load target from Artifacts[0].
17+
var _ = Describe("multi-artifact config validation", func() {
18+
parse := func(doc string) config.ModelConfig {
19+
var c config.ModelConfig
20+
Expect(yaml.Unmarshal([]byte(doc), &c)).To(Succeed())
21+
return c
22+
}
23+
24+
It("accepts a primary followed by a named companion", func() {
25+
c := parse(`
26+
backend: longcat-video
27+
artifacts:
28+
- name: model
29+
target: model
30+
source: {type: huggingface, repo: meituan-longcat/LongCat-Video-Avatar-1.5}
31+
- name: base_model
32+
target: companion
33+
source: {type: huggingface, repo: meituan-longcat/LongCat-Video}
34+
parameters: {model: meituan-longcat/LongCat-Video-Avatar-1.5}
35+
`)
36+
valid, err := c.Validate()
37+
Expect(err).NotTo(HaveOccurred())
38+
Expect(valid).To(BeTrue())
39+
})
40+
41+
It("rejects a config whose artifacts are all companions", func() {
42+
// The reachable way to end up without a primary. Two primaries cannot
43+
// coexist by construction: both would have to be named "model", so the
44+
// duplicate-name rule catches that case first (covered below).
45+
c := parse(`
46+
backend: longcat-video
47+
artifacts:
48+
- name: base_model
49+
target: companion
50+
source: {type: huggingface, repo: owner/base}
51+
parameters: {model: owner/main}
52+
`)
53+
valid, err := c.Validate()
54+
Expect(valid).To(BeFalse())
55+
Expect(err).To(MatchError(ContainSubstring("exactly one")))
56+
})
57+
58+
It("rejects a second artifact claiming the primary target", func() {
59+
c := parse(`
60+
backend: longcat-video
61+
artifacts:
62+
- name: model
63+
target: model
64+
source: {type: huggingface, repo: owner/one}
65+
- name: second
66+
target: model
67+
source: {type: huggingface, repo: owner/two}
68+
parameters: {model: owner/one}
69+
`)
70+
valid, err := c.Validate()
71+
Expect(valid).To(BeFalse())
72+
Expect(err).To(MatchError(ContainSubstring("primary artifact name")))
73+
})
74+
75+
It("rejects a companion declared before the primary", func() {
76+
// Artifacts[0] is the load target everywhere; a companion in that slot
77+
// would silently point the backend at the wrong snapshot.
78+
c := parse(`
79+
backend: longcat-video
80+
artifacts:
81+
- name: base_model
82+
target: companion
83+
source: {type: huggingface, repo: owner/base}
84+
- name: model
85+
target: model
86+
source: {type: huggingface, repo: owner/main}
87+
parameters: {model: owner/main}
88+
`)
89+
valid, err := c.Validate()
90+
Expect(valid).To(BeFalse())
91+
Expect(err).To(MatchError(ContainSubstring("first")))
92+
})
93+
94+
It("rejects companions sharing a name", func() {
95+
c := parse(`
96+
backend: longcat-video
97+
artifacts:
98+
- name: model
99+
target: model
100+
source: {type: huggingface, repo: owner/main}
101+
- name: base_model
102+
target: companion
103+
source: {type: huggingface, repo: owner/base}
104+
- name: base_model
105+
target: companion
106+
source: {type: huggingface, repo: owner/other}
107+
parameters: {model: owner/main}
108+
`)
109+
valid, err := c.Validate()
110+
Expect(valid).To(BeFalse())
111+
Expect(err).To(MatchError(ContainSubstring("duplicate")))
112+
})
113+
})

core/config/model_artifact_inference.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ var managedArtifactBackends = map[string]struct{}{
2121
"transformers-musicgen": {}, "mamba": {}, "diffusers": {}, "qwen-asr": {},
2222
"fish-speech": {}, "nemo": {}, "voxcpm": {}, "qwen-tts": {},
2323
"liquid-audio": {}, "vllm": {}, "vllm-omni": {}, "sglang": {},
24+
"longcat-video": {},
2425
}
2526

2627
// IsManagedArtifactBackend reports whether backend consumes a model as a

core/config/model_artifact_inference_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ var _ = Describe("PrimaryArtifactSpec backend gating", func() {
4242
Expect(spec.Source.Repo).To(Equal("owner/repo"))
4343
})
4444

45+
It("infers a managed artifact for longcat-video from a bare repo id", func() {
46+
// longcat-video loads a checkpoint directory (its backend.py takes
47+
// request.ModelFile when os.path.isdir), so it belongs to the same
48+
// class as transformers/vllm/diffusers. Off the allow-list, the
49+
// controller never acquires the weights and the backend re-downloads
50+
// them from HuggingFace inside the remote LoadModel deadline.
51+
c := parse("backend: longcat-video\nparameters: {model: meituan-longcat/LongCat-Video-Avatar-1.5}\n")
52+
spec, inferred, managed, err := c.PrimaryArtifactSpec("/models")
53+
Expect(err).NotTo(HaveOccurred())
54+
Expect(managed).To(BeTrue())
55+
Expect(inferred).To(BeTrue())
56+
Expect(spec.Source.Repo).To(Equal("meituan-longcat/LongCat-Video-Avatar-1.5"))
57+
})
58+
4559
It("keeps explicit artifacts managed even on a single-file backend", func() {
4660
// An explicitly declared artifacts: block is a deliberate choice;
4761
// single-file resolution (PrimaryFile) handles the load path.

0 commit comments

Comments
 (0)