Files
LocalAI/core/config/model_artifact_binding.go
mudler's LocalAI [bot] 2fe10c3c4a 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>
2026-07-23 16:50:50 +02:00

119 lines
3.3 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
"github.com/mudler/LocalAI/pkg/modelartifacts"
)
// persistArtifactBinding writes the resolved artifact set back into a model's
// config document. It replaces the whole `artifacts:` list, so the caller must
// pass EVERY artifact the model declares — the primary and all companions — not
// just the one that triggered the write. Persisting only the primary silently
// dropped companions from disk, and on the next controller restart the reloaded
// config had no companion at all: withCompanionArtifactOptions then synthesized
// no companion option and a remote backend fell back to fetching the companion
// repo itself, failing the load (the distributed longcat-video base_model bug).
func persistArtifactBinding(fileName, modelName string, artifacts []modelartifacts.Spec) error {
data, err := os.ReadFile(fileName)
if err != nil {
return err
}
var document yaml.Node
if err := yaml.Unmarshal(data, &document); err != nil {
return err
}
target, err := findModelMapping(&document, modelName)
if err != nil {
return err
}
artifactValue := &yaml.Node{}
encoded, err := yaml.Marshal(artifacts)
if err != nil {
return err
}
if err := yaml.Unmarshal(encoded, artifactValue); err != nil {
return err
}
setMappingValue(target, "artifacts", artifactValue.Content[0])
updated, err := yaml.Marshal(&document)
if err != nil {
return err
}
return writeBindingAtomic(fileName, updated)
}
func findModelMapping(document *yaml.Node, modelName string) (*yaml.Node, error) {
if len(document.Content) != 1 {
return nil, fmt.Errorf("invalid model configuration document")
}
root := document.Content[0]
if root.Kind == yaml.MappingNode {
return root, nil
}
if root.Kind == yaml.SequenceNode {
for _, candidate := range root.Content {
if candidate.Kind == yaml.MappingNode {
name := mappingValue(candidate, "name")
if name != nil && name.Value == modelName {
return candidate, nil
}
}
}
}
return nil, fmt.Errorf("model %q not found in configuration document", modelName)
}
func mappingValue(mapping *yaml.Node, key string) *yaml.Node {
for index := 0; index+1 < len(mapping.Content); index += 2 {
if mapping.Content[index].Value == key {
return mapping.Content[index+1]
}
}
return nil
}
func setMappingValue(mapping *yaml.Node, key string, value *yaml.Node) {
for index := 0; index+1 < len(mapping.Content); index += 2 {
if mapping.Content[index].Value == key {
mapping.Content[index+1] = value
return
}
}
mapping.Content = append(mapping.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, value,
)
}
func writeBindingAtomic(fileName string, data []byte) error {
temporary, err := os.CreateTemp(filepath.Dir(fileName), ".artifact-binding-*")
if err != nil {
return err
}
temporaryName := temporary.Name()
defer func() { _ = os.Remove(temporaryName) }()
if err := temporary.Chmod(0600); err != nil {
_ = temporary.Close()
return err
}
if _, err := temporary.Write(data); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Sync(); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Chmod(temporaryName, 0644); err != nil {
return err
}
return os.Rename(temporaryName, fileName)
}