Skip to content

Commit aff9db9

Browse files
fix(distributed): resolve paths for virtual models (mudler#11911)
Virtual model names have no primary file to anchor the worker path. Companion assets still stage successfully, but relative options retain an incorrect model directory and fail to load. Derive the worker root from successfully staged option assets when the primary path is absent. Cover Buffalo packs, files, directories, overrides, and failed transfers. Document the frontend upgrade. Assisted-by: Codex:gpt-6 golangci-lint Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent e494033 commit aff9db9

3 files changed

Lines changed: 117 additions & 8 deletions

File tree

core/services/nodes/router.go

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1599,8 +1599,15 @@ func (r *SmartRouter) stageModelFiles(ctx context.Context, node *BackendNode, op
15991599

16001600
// Stage file paths referenced in generic Options (key:value pairs where values
16011601
// are file paths). Options stay as relative paths — backends resolve them via ModelPath.
1602-
r.stageGenericOptions(ctx, node, opts.Options, frontendModelsDir, localModelDir, keyMapper.Key)
1603-
r.stageGenericOptions(ctx, node, opts.Overrides, frontendModelsDir, localModelDir, keyMapper.Key)
1602+
for _, options := range [][]string{opts.Options, opts.Overrides} {
1603+
remoteRoot := r.stageGenericOptions(ctx, node, options, frontendModelsDir, localModelDir, keyMapper.Key)
1604+
if opts.ModelFile == "" && remoteRoot != "" {
1605+
// Virtual models have no primary file from which to derive the
1606+
// worker root. Their relative options must resolve against the
1607+
// companion assets we actually staged, not the frontend's root.
1608+
opts.ModelPath = remoteRoot
1609+
}
1610+
}
16041611

16051612
return opts, nil
16061613
}
@@ -1831,7 +1838,9 @@ func (r *SmartRouter) stageCompanionFiles(ctx context.Context, node *BackendNode
18311838
// that resolve to existing files relative to the frontend models directory or
18321839
// the model's own directory. Option values are NOT rewritten — backends resolve
18331840
// them via ModelPath. keyFn generates the namespaced storage key for each file.
1834-
func (r *SmartRouter) stageGenericOptions(ctx context.Context, node *BackendNode, options []string, frontendModelsDir, modelDir string, keyFn func(string) string) {
1841+
// Returns the staged models root, or empty when no asset was staged.
1842+
func (r *SmartRouter) stageGenericOptions(ctx context.Context, node *BackendNode, options []string, frontendModelsDir, modelDir string, keyFn func(string) string) string {
1843+
remoteRoot := ""
18351844
for _, opt := range options {
18361845
optKey, val, ok := strings.Cut(opt, ":")
18371846
if !ok || val == "" {
@@ -1856,18 +1865,23 @@ func (r *SmartRouter) stageGenericOptions(ctx context.Context, node *BackendNode
18561865
// worker; a single file is staged directly. Values are never rewritten —
18571866
// backends resolve relative paths via ModelPath.
18581867
if err == nil && info.IsDir() {
1859-
r.stageOptionDir(ctx, node, absPath, keyFn)
1868+
if remoteDir := r.stageOptionDir(ctx, node, absPath, keyFn); remoteDir != "" {
1869+
remoteRoot = DeriveRemoteModelPath(remoteDir, relativeToModelsDir(frontendModelsDir, absPath, filepath.Base(absPath)))
1870+
}
18601871
xlog.Debug("Staged option directory", "option", optKey, "localPath", absPath)
18611872
continue
18621873
}
18631874

18641875
key := keyFn(absPath)
1865-
if _, err := r.fileStager.EnsureRemote(ctx, node.ID, absPath, key); err != nil {
1876+
remotePath, err := r.fileStager.EnsureRemote(ctx, node.ID, absPath, key)
1877+
if err != nil {
18661878
xlog.Warn("Failed to stage option file, skipping", "option", opt, "path", absPath, "error", err)
18671879
continue
18681880
}
1881+
remoteRoot = DeriveRemoteModelPath(remotePath, relativeToModelsDir(frontendModelsDir, absPath, filepath.Base(absPath)))
18691882
xlog.Debug("Staged option file", "option", optKey, "localPath", absPath)
18701883
}
1884+
return remoteRoot
18711885
}
18721886

18731887
// resolveOptionPath finds an existing local path for an option value: an
@@ -1895,8 +1909,10 @@ func resolveOptionPath(val, frontendModelsDir, modelDir string) (string, bool) {
18951909
// stageOptionDir stages every regular file under an option-declared directory
18961910
// (e.g. sherpa-onnx's espeak-ng-data) using the structure-preserving key, so the
18971911
// tree is recreated beside the model on the worker. Per-file errors are logged
1898-
// and skipped; the option value itself is not rewritten.
1899-
func (r *SmartRouter) stageOptionDir(ctx context.Context, node *BackendNode, dir string, keyFn func(string) string) {
1912+
// and skipped; the option value itself is not rewritten. Returns the remote
1913+
// directory derived from a successfully staged file, or empty when none succeeds.
1914+
func (r *SmartRouter) stageOptionDir(ctx context.Context, node *BackendNode, dir string, keyFn func(string) string) string {
1915+
remoteDir := ""
19001916
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {
19011917
if walkErr != nil || d.IsDir() {
19021918
return nil
@@ -1911,11 +1927,17 @@ func (r *SmartRouter) stageOptionDir(ctx context.Context, node *BackendNode, dir
19111927
if isHashSidecar(path) {
19121928
return nil
19131929
}
1914-
if _, err := r.fileStager.EnsureRemote(ctx, node.ID, path, keyFn(path)); err != nil {
1930+
remotePath, err := r.fileStager.EnsureRemote(ctx, node.ID, path, keyFn(path))
1931+
if err != nil {
19151932
xlog.Warn("Failed to stage option directory file, skipping", "path", path, "error", err)
1933+
return nil
1934+
}
1935+
if rel, err := filepath.Rel(dir, path); err == nil {
1936+
remoteDir = DeriveRemoteModelPath(remotePath, rel)
19161937
}
19171938
return nil
19181939
})
1940+
return remoteDir
19191941
}
19201942

19211943
// probeHealth checks whether a backend process on the given node/addr is alive
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
package nodes
4+
5+
import (
6+
"context"
7+
"errors"
8+
"os"
9+
"path/filepath"
10+
11+
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
12+
. "github.com/onsi/ginkgo/v2"
13+
. "github.com/onsi/gomega"
14+
)
15+
16+
type failedCompanionStager struct{ FileStager }
17+
18+
func (failedCompanionStager) EnsureRemote(context.Context, string, string, string) (string, error) {
19+
return "", errors.New("worker unavailable")
20+
}
21+
22+
var _ = Describe("staging virtual model companions", func() {
23+
DescribeTable("anchors relative assets on the worker",
24+
func(options, overrides []string, files []string) {
25+
modelsDir := GinkgoT().TempDir()
26+
for _, name := range files {
27+
path := filepath.Join(modelsDir, name)
28+
Expect(os.MkdirAll(filepath.Dir(path), 0750)).To(Succeed())
29+
Expect(os.WriteFile(path, []byte("weights"), 0600)).To(Succeed())
30+
}
31+
stager := &fakeFileStager{}
32+
router := &SmartRouter{fileStager: stager, stagingTracker: NewStagingTracker()}
33+
input := &pb.ModelOptions{Model: "insightface-buffalo-m", ModelFile: filepath.Join(modelsDir, "insightface-buffalo-m"), ModelPath: modelsDir, Options: options, Overrides: overrides}
34+
staged, err := router.stageModelFiles(context.Background(), &BackendNode{ID: "worker"}, input, "insightface-buffalo-m")
35+
Expect(err).NotTo(HaveOccurred())
36+
Expect(staged.ModelPath).To(Equal("/remote/models/insightface-buffalo-m"))
37+
Expect(staged.Options).To(Equal(options))
38+
Expect(staged.Overrides).To(Equal(overrides))
39+
Expect(input.ModelPath).To(Equal(modelsDir))
40+
Expect(input.ModelFile).To(Equal(filepath.Join(modelsDir, "insightface-buffalo-m")))
41+
Expect(stager.ensureCalls).To(HaveLen(len(files)))
42+
for _, call := range stager.ensureCalls {
43+
rel, err := filepath.Rel(modelsDir, call.localPath)
44+
Expect(err).NotTo(HaveOccurred())
45+
Expect(filepath.Join(staged.ModelPath, rel)).To(Equal("/remote/" + call.key))
46+
}
47+
},
48+
Entry("Buffalo pack and MiniFASNet files", []string{"model_pack:buffalo_m", "antispoof_v2_onnx:MiniFASNetV2.onnx", "antispoof_v1se_onnx:MiniFASNetV1SE.onnx"}, nil, []string{"buffalo_m/det_2.5g.onnx", "buffalo_m/w600k_r50.onnx", "MiniFASNetV2.onnx", "MiniFASNetV1SE.onnx"}),
49+
Entry("only a nested companion directory", []string{"model_pack:packs/buffalo_m"}, nil, []string{"packs/buffalo_m/det_2.5g.onnx"}),
50+
Entry("only a companion file", []string{"antispoof_v2_onnx:MiniFASNetV2.onnx"}, nil, []string{"MiniFASNetV2.onnx"}),
51+
Entry("only override assets", nil, []string{"antispoof_v2_onnx:MiniFASNetV2.onnx"}, []string{"MiniFASNetV2.onnx"}),
52+
)
53+
It("keeps the original path when no assets are staged", func() {
54+
modelsDir := GinkgoT().TempDir()
55+
router := &SmartRouter{fileStager: &fakeFileStager{}, stagingTracker: NewStagingTracker()}
56+
input := &pb.ModelOptions{Model: "virtual", ModelFile: filepath.Join(modelsDir, "virtual"), ModelPath: modelsDir, Options: []string{"engine:insightface"}}
57+
staged, err := router.stageModelFiles(context.Background(), &BackendNode{ID: "worker"}, input, "virtual")
58+
Expect(err).NotTo(HaveOccurred())
59+
Expect(staged.ModelPath).To(Equal(modelsDir))
60+
})
61+
DescribeTable("keeps the original root when companion staging fails",
62+
func(directory bool) {
63+
modelsDir := GinkgoT().TempDir()
64+
relative := "MiniFASNetV2.onnx"
65+
if directory {
66+
relative = "buffalo_m/det_2.5g.onnx"
67+
}
68+
local := filepath.Join(modelsDir, relative)
69+
Expect(os.MkdirAll(filepath.Dir(local), 0750)).To(Succeed())
70+
Expect(os.WriteFile(local, []byte("weights"), 0600)).To(Succeed())
71+
value := relative
72+
if directory {
73+
value = "buffalo_m"
74+
}
75+
router := &SmartRouter{fileStager: failedCompanionStager{}, stagingTracker: NewStagingTracker()}
76+
input := &pb.ModelOptions{Model: "virtual", ModelFile: filepath.Join(modelsDir, "virtual"), ModelPath: modelsDir, Options: []string{"asset:" + value}}
77+
staged, err := router.stageModelFiles(context.Background(), &BackendNode{ID: "worker"}, input, "virtual")
78+
Expect(err).NotTo(HaveOccurred())
79+
Expect(staged.ModelPath).To(Equal(modelsDir))
80+
}, Entry("file", false), Entry("directory", true),
81+
)
82+
83+
})

docs/content/features/distributed-mode.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,6 +1190,10 @@ Notes:
11901190
- Verify `--heartbeat-interval` is not set too high
11911191
- Offline nodes automatically restore to healthy when they re-register (no re-approval needed)
11921192

1193+
**InsightFace reports a missing MiniFASNet file after staging:**
1194+
- Gallery models such as `insightface-buffalo-m` use a virtual primary name and load their files through options. The frontend derives the worker's model directory from successfully staged companion files or directories, so relative options resolve inside the model's staging directory.
1195+
- If logs show matching hashes for the staged files but InsightFace still reports a bare filename such as `MiniFASNetV2.onnx` as missing, upgrade the frontend to include this path-resolution fix. Re-uploading the same files does not correct the directory passed to the backend.
1196+
11931197
**Backend not installing:**
11941198
- Check the worker logs for `backend.install` events
11951199

0 commit comments

Comments
 (0)