Skip to content

Commit e5c95e0

Browse files
localai-botmudlerclaude
authored
fix(distributed): stage backend companion assets to remote nodes (#10330)
A model whose ModelFile is a single file (e.g. sherpa-onnx VITS/piper: the .onnx) failed to load on remote worker nodes because the sibling assets the backend resolves from the model dir — tokens.txt, lexicon.txt, the espeak-ng-data / dict directories, Kokoro's voices.bin — were never staged. Only the declared ModelFile was shipped, so the worker hit "failed to create sherpa-onnx TTS engine" and TTS produced no audio. Lean on the existing option-path staging instead of hardcoding filenames: - stageGenericOptions now also resolves an option value relative to the model's own directory (not just the frontend models dir), so a shared config can declare companions with bare names regardless of whether Model includes a subdirectory; and it expands directory-valued options (e.g. espeak-ng-data) file-by-file rather than handing a directory fd to the stager. - gallery/sherpa-onnx-tts.yaml declares the companion assets as option paths (tokens, lexicon, espeak-ng-data, voices.bin, dict, per-lang lexicons). The backend ignores these keys and keeps resolving siblings from the model dir; they exist only so distributed staging ships them. Absent files are skipped. Adds router_optionstage_test.go covering file + directory companion staging via the model-dir fallback. Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4d3d54d commit e5c95e0

3 files changed

Lines changed: 165 additions & 13 deletions

File tree

core/services/nodes/router.go

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -908,6 +908,17 @@ func (r *SmartRouter) stageModelFiles(ctx context.Context, node *BackendNode, op
908908
frontendModelsDir = filepath.Clean(strings.TrimSuffix(opts.ModelFile, opts.Model))
909909
}
910910

911+
// Local model directory, captured before the ModelFile field is rewritten to
912+
// its remote path below. Companion assets declared as option paths (e.g.
913+
// sherpa-onnx's tokens.txt / espeak-ng-data) live beside the model, so option
914+
// values are resolved relative to this dir as well as frontendModelsDir —
915+
// letting a shared config declare them with bare names regardless of whether
916+
// Model includes a subdirectory.
917+
localModelDir := ""
918+
if opts.ModelFile != "" {
919+
localModelDir = filepath.Dir(opts.ModelFile)
920+
}
921+
911922
// keyMapper generates storage keys namespaced under trackingKey, preserving
912923
// subdirectory structure relative to frontendModelsDir. This ensures:
913924
// 1. All files for a model land in one directory on the worker for clean deletion
@@ -1079,8 +1090,8 @@ func (r *SmartRouter) stageModelFiles(ctx context.Context, node *BackendNode, op
10791090

10801091
// Stage file paths referenced in generic Options (key:value pairs where values
10811092
// are file paths). Options stay as relative paths — backends resolve them via ModelPath.
1082-
r.stageGenericOptions(ctx, node, opts.Options, frontendModelsDir, keyMapper.Key)
1083-
r.stageGenericOptions(ctx, node, opts.Overrides, frontendModelsDir, keyMapper.Key)
1093+
r.stageGenericOptions(ctx, node, opts.Options, frontendModelsDir, localModelDir, keyMapper.Key)
1094+
r.stageGenericOptions(ctx, node, opts.Overrides, frontendModelsDir, localModelDir, keyMapper.Key)
10841095

10851096
return opts, nil
10861097
}
@@ -1196,36 +1207,86 @@ func (r *SmartRouter) stageCompanionFiles(ctx context.Context, node *BackendNode
11961207
}
11971208

11981209
// stageGenericOptions iterates key:value option strings and stages any values
1199-
// that resolve to existing files relative to the frontend models directory.
1200-
// Option values are NOT rewritten — backends resolve them via ModelPath.
1201-
// keyFn generates the namespaced storage key for each file path.
1202-
func (r *SmartRouter) stageGenericOptions(ctx context.Context, node *BackendNode, options []string, frontendModelsDir string, keyFn func(string) string) {
1210+
// that resolve to existing files relative to the frontend models directory or
1211+
// the model's own directory. Option values are NOT rewritten — backends resolve
1212+
// them via ModelPath. keyFn generates the namespaced storage key for each file.
1213+
func (r *SmartRouter) stageGenericOptions(ctx context.Context, node *BackendNode, options []string, frontendModelsDir, modelDir string, keyFn func(string) string) {
12031214
for _, opt := range options {
12041215
optKey, val, ok := strings.Cut(opt, ":")
12051216
if !ok || val == "" {
12061217
continue
12071218
}
12081219

1209-
// Check if value is an existing file path (absolute or relative to frontend models dir)
1210-
absPath := val
1211-
if !filepath.IsAbs(val) && frontendModelsDir != "" {
1212-
absPath = filepath.Join(frontendModelsDir, val)
1220+
// Resolve the value to an existing path: absolute as-is, otherwise
1221+
// relative to frontendModelsDir first, then the model's own directory
1222+
// (where backends like sherpa-onnx keep companion assets such as
1223+
// tokens.txt and espeak-ng-data).
1224+
absPath, ok := resolveOptionPath(val, frontendModelsDir, modelDir)
1225+
if !ok {
1226+
continue
1227+
}
1228+
info, err := os.Stat(absPath)
1229+
if err != nil {
1230+
continue
12131231
}
1214-
if _, err := os.Stat(absPath); os.IsNotExist(err) {
1232+
1233+
// A directory option value (e.g. sherpa-onnx's espeak-ng-data) is staged
1234+
// file-by-file so the whole tree is recreated beside the model on the
1235+
// worker; a single file is staged directly. Values are never rewritten —
1236+
// backends resolve relative paths via ModelPath.
1237+
if err == nil && info.IsDir() {
1238+
r.stageOptionDir(ctx, node, absPath, keyFn)
1239+
xlog.Debug("Staged option directory", "option", optKey, "localPath", absPath)
12151240
continue
12161241
}
12171242

1218-
// Stage the file to the worker using the namespaced key
12191243
key := keyFn(absPath)
12201244
if _, err := r.fileStager.EnsureRemote(ctx, node.ID, absPath, key); err != nil {
12211245
xlog.Warn("Failed to stage option file, skipping", "option", opt, "path", absPath, "error", err)
12221246
continue
12231247
}
1224-
// Leave option value unchanged — backend resolves relative paths via ModelPath
12251248
xlog.Debug("Staged option file", "option", optKey, "localPath", absPath)
12261249
}
12271250
}
12281251

1252+
// resolveOptionPath finds an existing local path for an option value: an
1253+
// absolute path as-is, otherwise relative to frontendModelsDir, then to the
1254+
// model's own directory. Returns false when none exists.
1255+
func resolveOptionPath(val, frontendModelsDir, modelDir string) (string, bool) {
1256+
if filepath.IsAbs(val) {
1257+
if _, err := os.Stat(val); err == nil {
1258+
return val, true
1259+
}
1260+
return "", false
1261+
}
1262+
for _, base := range []string{frontendModelsDir, modelDir} {
1263+
if base == "" {
1264+
continue
1265+
}
1266+
p := filepath.Join(base, val)
1267+
if _, err := os.Stat(p); err == nil {
1268+
return p, true
1269+
}
1270+
}
1271+
return "", false
1272+
}
1273+
1274+
// stageOptionDir stages every regular file under an option-declared directory
1275+
// (e.g. sherpa-onnx's espeak-ng-data) using the structure-preserving key, so the
1276+
// tree is recreated beside the model on the worker. Per-file errors are logged
1277+
// and skipped; the option value itself is not rewritten.
1278+
func (r *SmartRouter) stageOptionDir(ctx context.Context, node *BackendNode, dir string, keyFn func(string) string) {
1279+
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {
1280+
if walkErr != nil || d.IsDir() {
1281+
return nil
1282+
}
1283+
if _, err := r.fileStager.EnsureRemote(ctx, node.ID, path, keyFn(path)); err != nil {
1284+
xlog.Warn("Failed to stage option directory file, skipping", "path", path, "error", err)
1285+
}
1286+
return nil
1287+
})
1288+
}
1289+
12291290
// probeHealth checks whether a backend process on the given node/addr is alive
12301291
// via a gRPC health check with a 2-second timeout. The client is closed after
12311292
// the check.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package nodes
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
8+
. "github.com/onsi/ginkgo/v2"
9+
. "github.com/onsi/gomega"
10+
11+
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
12+
)
13+
14+
// These tests cover staging of companion assets declared as option file paths
15+
// (the "vae_path:..." convention). Backends like sherpa-onnx keep a single-file
16+
// ModelFile (the .onnx) but resolve sibling assets — tokens.txt and the
17+
// espeak-ng-data directory — relative to the model dir. Those siblings must be
18+
// shipped to remote workers too, including directory-valued options expanded
19+
// file-by-file.
20+
var _ = Describe("stageGenericOptions companion assets", func() {
21+
var (
22+
stager *fakeFileStager
23+
router *SmartRouter
24+
node *BackendNode
25+
tmp string
26+
)
27+
28+
BeforeEach(func() {
29+
stager = &fakeFileStager{}
30+
router = &SmartRouter{
31+
fileStager: stager,
32+
stagingTracker: NewStagingTracker(),
33+
}
34+
node = &BackendNode{ID: "node-1", Name: "node-1", Address: "10.0.0.1:50051"}
35+
tmp = GinkgoT().TempDir()
36+
})
37+
38+
It("stages option-declared sibling files and expands directory options", func() {
39+
modelRel := "vits-piper-it_IT-paola-medium"
40+
modelDir := filepath.Join(tmp, "models", modelRel)
41+
dataDir := filepath.Join(modelDir, "espeak-ng-data")
42+
Expect(os.MkdirAll(filepath.Join(dataDir, "lang"), 0o755)).To(Succeed())
43+
44+
onnx := filepath.Join(modelDir, "it_IT-paola-medium.onnx")
45+
tokens := filepath.Join(modelDir, "tokens.txt")
46+
phontab := filepath.Join(dataDir, "phontab")
47+
langIt := filepath.Join(dataDir, "lang", "it")
48+
for _, f := range []string{onnx, tokens, phontab, langIt} {
49+
Expect(os.WriteFile(f, []byte("x"), 0o644)).To(Succeed())
50+
}
51+
52+
opts := &pb.ModelOptions{
53+
Model: filepath.Join(modelRel, "it_IT-paola-medium.onnx"),
54+
ModelFile: onnx,
55+
// Bare names: not found under the models root (Model includes a
56+
// subdir), so they must resolve relative to the model's own dir.
57+
Options: []string{
58+
"tts.noise_scale=0.667", // not a path; ignored by staging
59+
"tokens:tokens.txt",
60+
"data_dir:espeak-ng-data",
61+
},
62+
}
63+
64+
_, err := router.stageModelFiles(context.Background(), node, opts, "track-key")
65+
Expect(err).ToNot(HaveOccurred())
66+
67+
staged := make([]string, 0, len(stager.ensureCalls))
68+
for _, c := range stager.ensureCalls {
69+
staged = append(staged, c.localPath)
70+
}
71+
// The .onnx (ModelFile), the tokens.txt file option, and every file under
72+
// the espeak-ng-data directory option are staged; the directory path
73+
// itself is never handed to the stager.
74+
Expect(staged).To(ContainElements(onnx, tokens, phontab, langIt))
75+
Expect(staged).ToNot(ContainElement(dataDir))
76+
})
77+
})

gallery/sherpa-onnx-tts.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,17 @@ config_file: |
1212
# Speech rate multiplier. Applied at every TTS / TTSStream call
1313
# since the TTSRequest proto has no speed field.
1414
- tts.speed=1.0
15+
# Companion assets that sherpa-onnx TTS voices load from beside the .onnx
16+
# (tokens, lexicons, espeak-ng phonemization data, Kokoro voices bank / jieba
17+
# dict). Declared as option paths so distributed inference stages them to
18+
# remote worker nodes too; the backend ignores these keys and resolves the
19+
# files relative to the model dir. Bare names resolve against the model's own
20+
# directory; any that a given voice doesn't ship are skipped during staging.
21+
- tokens:tokens.txt
22+
- lexicon:lexicon.txt
23+
- data_dir:espeak-ng-data
24+
- voices:voices.bin
25+
- dict_dir:dict
26+
- lexicon_us:lexicon-us-en.txt
27+
- lexicon_gb:lexicon-gb-en.txt
28+
- lexicon_zh:lexicon-zh.txt

0 commit comments

Comments
 (0)