|
|
package gallery |
|
|
|
|
|
import ( |
|
|
"context" |
|
|
"errors" |
|
|
"fmt" |
|
|
"os" |
|
|
"path/filepath" |
|
|
"slices" |
|
|
"strings" |
|
|
|
|
|
"dario.cat/mergo" |
|
|
lconfig "github.com/mudler/LocalAI/core/config" |
|
|
"github.com/mudler/LocalAI/pkg/downloader" |
|
|
"github.com/mudler/LocalAI/pkg/model" |
|
|
"github.com/mudler/LocalAI/pkg/system" |
|
|
"github.com/mudler/LocalAI/pkg/utils" |
|
|
|
|
|
"github.com/mudler/xlog" |
|
|
"gopkg.in/yaml.v3" |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
type ModelConfig struct { |
|
|
Description string `yaml:"description"` |
|
|
Icon string `yaml:"icon"` |
|
|
License string `yaml:"license"` |
|
|
URLs []string `yaml:"urls"` |
|
|
Name string `yaml:"name"` |
|
|
ConfigFile string `yaml:"config_file"` |
|
|
Files []File `yaml:"files"` |
|
|
PromptTemplates []PromptTemplate `yaml:"prompt_templates"` |
|
|
} |
|
|
|
|
|
type File struct { |
|
|
Filename string `yaml:"filename" json:"filename"` |
|
|
SHA256 string `yaml:"sha256" json:"sha256"` |
|
|
URI string `yaml:"uri" json:"uri"` |
|
|
} |
|
|
|
|
|
type PromptTemplate struct { |
|
|
Name string `yaml:"name"` |
|
|
Content string `yaml:"content"` |
|
|
} |
|
|
|
|
|
|
|
|
func InstallModelFromGallery( |
|
|
ctx context.Context, |
|
|
modelGalleries, backendGalleries []lconfig.Gallery, |
|
|
systemState *system.SystemState, |
|
|
modelLoader *model.ModelLoader, |
|
|
name string, req GalleryModel, downloadStatus func(string, string, string, float64), enforceScan, automaticallyInstallBackend bool) error { |
|
|
|
|
|
applyModel := func(model *GalleryModel) error { |
|
|
name = strings.ReplaceAll(name, string(os.PathSeparator), "__") |
|
|
|
|
|
var config ModelConfig |
|
|
|
|
|
if len(model.URL) > 0 { |
|
|
var err error |
|
|
config, err = GetGalleryConfigFromURLWithContext[ModelConfig](ctx, model.URL, systemState.Model.ModelsPath) |
|
|
if err != nil { |
|
|
return err |
|
|
} |
|
|
config.Description = model.Description |
|
|
config.License = model.License |
|
|
} else if len(model.ConfigFile) > 0 { |
|
|
|
|
|
reYamlConfig, err := yaml.Marshal(model.ConfigFile) |
|
|
if err != nil { |
|
|
return err |
|
|
} |
|
|
config = ModelConfig{ |
|
|
ConfigFile: string(reYamlConfig), |
|
|
Description: model.Description, |
|
|
License: model.License, |
|
|
URLs: model.URLs, |
|
|
Name: model.Name, |
|
|
Files: make([]File, 0), |
|
|
|
|
|
} |
|
|
} else { |
|
|
return fmt.Errorf("invalid gallery model %+v", model) |
|
|
} |
|
|
|
|
|
installName := model.Name |
|
|
if req.Name != "" { |
|
|
installName = req.Name |
|
|
} |
|
|
|
|
|
|
|
|
config.URLs = append(config.URLs, model.URLs...) |
|
|
config.Icon = model.Icon |
|
|
config.Files = append(config.Files, req.AdditionalFiles...) |
|
|
config.Files = append(config.Files, model.AdditionalFiles...) |
|
|
|
|
|
|
|
|
if req.Overrides != nil { |
|
|
if err := mergo.Merge(&model.Overrides, req.Overrides, mergo.WithOverride); err != nil { |
|
|
return err |
|
|
} |
|
|
} |
|
|
|
|
|
installedModel, err := InstallModel(ctx, systemState, installName, &config, model.Overrides, downloadStatus, enforceScan) |
|
|
if err != nil { |
|
|
return err |
|
|
} |
|
|
xlog.Debug("Installed model", "model", installedModel.Name) |
|
|
if automaticallyInstallBackend && installedModel.Backend != "" { |
|
|
xlog.Debug("Installing backend", "backend", installedModel.Backend) |
|
|
|
|
|
if err := InstallBackendFromGallery(ctx, backendGalleries, systemState, modelLoader, installedModel.Backend, downloadStatus, false); err != nil { |
|
|
return err |
|
|
} |
|
|
} |
|
|
|
|
|
return nil |
|
|
} |
|
|
|
|
|
models, err := AvailableGalleryModels(modelGalleries, systemState) |
|
|
if err != nil { |
|
|
return err |
|
|
} |
|
|
|
|
|
model := FindGalleryElement(models, name) |
|
|
if model == nil { |
|
|
return fmt.Errorf("no model found with name %q", name) |
|
|
} |
|
|
|
|
|
return applyModel(model) |
|
|
} |
|
|
|
|
|
func InstallModel(ctx context.Context, systemState *system.SystemState, nameOverride string, config *ModelConfig, configOverrides map[string]interface{}, downloadStatus func(string, string, string, float64), enforceScan bool) (*lconfig.ModelConfig, error) { |
|
|
basePath := systemState.Model.ModelsPath |
|
|
|
|
|
err := os.MkdirAll(basePath, 0750) |
|
|
if err != nil { |
|
|
return nil, fmt.Errorf("failed to create base path: %v", err) |
|
|
} |
|
|
|
|
|
if len(configOverrides) > 0 { |
|
|
xlog.Debug("Config overrides", "overrides", configOverrides) |
|
|
} |
|
|
|
|
|
|
|
|
for i, file := range config.Files { |
|
|
|
|
|
select { |
|
|
case <-ctx.Done(): |
|
|
return nil, ctx.Err() |
|
|
default: |
|
|
} |
|
|
|
|
|
xlog.Debug("Checking file exists and matches SHA", "filename", file.Filename) |
|
|
|
|
|
if err := utils.VerifyPath(file.Filename, basePath); err != nil { |
|
|
return nil, err |
|
|
} |
|
|
|
|
|
|
|
|
filePath := filepath.Join(basePath, file.Filename) |
|
|
|
|
|
if enforceScan { |
|
|
scanResults, err := downloader.HuggingFaceScan(downloader.URI(file.URI)) |
|
|
if err != nil && errors.Is(err, downloader.ErrUnsafeFilesFound) { |
|
|
xlog.Error("Contains unsafe file(s)!", "model", config.Name, "clamAV", scanResults.ClamAVInfectedFiles, "pickles", scanResults.DangerousPickles) |
|
|
return nil, err |
|
|
} |
|
|
} |
|
|
uri := downloader.URI(file.URI) |
|
|
if err := uri.DownloadFileWithContext(ctx, filePath, file.SHA256, i, len(config.Files), downloadStatus); err != nil { |
|
|
return nil, err |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
for _, template := range config.PromptTemplates { |
|
|
if err := utils.VerifyPath(template.Name+".tmpl", basePath); err != nil { |
|
|
return nil, err |
|
|
} |
|
|
|
|
|
filePath := filepath.Join(basePath, template.Name+".tmpl") |
|
|
|
|
|
|
|
|
err := os.MkdirAll(filepath.Dir(filePath), 0750) |
|
|
if err != nil { |
|
|
return nil, fmt.Errorf("failed to create parent directory for prompt template %q: %v", template.Name, err) |
|
|
} |
|
|
|
|
|
err = os.WriteFile(filePath, []byte(template.Content), 0600) |
|
|
if err != nil { |
|
|
return nil, fmt.Errorf("failed to write prompt template %q: %v", template.Name, err) |
|
|
} |
|
|
|
|
|
xlog.Debug("Prompt template written", "template", template.Name) |
|
|
} |
|
|
|
|
|
name := config.Name |
|
|
if nameOverride != "" { |
|
|
name = nameOverride |
|
|
} |
|
|
|
|
|
if err := utils.VerifyPath(name+".yaml", basePath); err != nil { |
|
|
return nil, err |
|
|
} |
|
|
|
|
|
modelConfig := lconfig.ModelConfig{} |
|
|
|
|
|
|
|
|
if len(configOverrides) != 0 || len(config.ConfigFile) != 0 { |
|
|
configFilePath := filepath.Join(basePath, name+".yaml") |
|
|
|
|
|
|
|
|
configMap := make(map[string]interface{}) |
|
|
err = yaml.Unmarshal([]byte(config.ConfigFile), &configMap) |
|
|
if err != nil { |
|
|
return nil, fmt.Errorf("failed to unmarshal config YAML: %v", err) |
|
|
} |
|
|
|
|
|
configMap["name"] = name |
|
|
|
|
|
if configOverrides != nil { |
|
|
if err := mergo.Merge(&configMap, configOverrides, mergo.WithOverride); err != nil { |
|
|
return nil, err |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
updatedConfigYAML, err := yaml.Marshal(configMap) |
|
|
if err != nil { |
|
|
return nil, fmt.Errorf("failed to marshal updated config YAML: %v", err) |
|
|
} |
|
|
|
|
|
err = yaml.Unmarshal(updatedConfigYAML, &modelConfig) |
|
|
if err != nil { |
|
|
return nil, fmt.Errorf("failed to unmarshal updated config YAML: %v", err) |
|
|
} |
|
|
|
|
|
if valid, err := modelConfig.Validate(); !valid { |
|
|
return nil, fmt.Errorf("failed to validate updated config YAML: %v", err) |
|
|
} |
|
|
|
|
|
err = os.WriteFile(configFilePath, updatedConfigYAML, 0600) |
|
|
if err != nil { |
|
|
return nil, fmt.Errorf("failed to write updated config file: %v", err) |
|
|
} |
|
|
|
|
|
xlog.Debug("Written config file", "file", configFilePath) |
|
|
} |
|
|
|
|
|
|
|
|
modelFile := filepath.Join(basePath, galleryFileName(name)) |
|
|
data, err := yaml.Marshal(config) |
|
|
if err != nil { |
|
|
return nil, err |
|
|
} |
|
|
|
|
|
xlog.Debug("Written gallery file", "file", modelFile) |
|
|
|
|
|
return &modelConfig, os.WriteFile(modelFile, data, 0600) |
|
|
} |
|
|
|
|
|
func galleryFileName(name string) string { |
|
|
return "._gallery_" + name + ".yaml" |
|
|
} |
|
|
|
|
|
func GetLocalModelConfiguration(basePath string, name string) (*ModelConfig, error) { |
|
|
name = strings.ReplaceAll(name, string(os.PathSeparator), "__") |
|
|
galleryFile := filepath.Join(basePath, galleryFileName(name)) |
|
|
return ReadConfigFile[ModelConfig](galleryFile) |
|
|
} |
|
|
|
|
|
func listModelFiles(systemState *system.SystemState, name string) ([]string, error) { |
|
|
|
|
|
configFile := filepath.Join(systemState.Model.ModelsPath, fmt.Sprintf("%s.yaml", name)) |
|
|
if err := utils.VerifyPath(configFile, systemState.Model.ModelsPath); err != nil { |
|
|
return nil, fmt.Errorf("failed to verify path %s: %w", configFile, err) |
|
|
} |
|
|
|
|
|
|
|
|
name = strings.ReplaceAll(name, string(os.PathSeparator), "__") |
|
|
|
|
|
galleryFile := filepath.Join(systemState.Model.ModelsPath, galleryFileName(name)) |
|
|
if err := utils.VerifyPath(galleryFile, systemState.Model.ModelsPath); err != nil { |
|
|
return nil, fmt.Errorf("failed to verify path %s: %w", galleryFile, err) |
|
|
} |
|
|
|
|
|
additionalFiles := []string{} |
|
|
allFiles := []string{} |
|
|
|
|
|
|
|
|
dat, err := os.ReadFile(configFile) |
|
|
if err == nil { |
|
|
modelConfig := &lconfig.ModelConfig{} |
|
|
|
|
|
err = yaml.Unmarshal(dat, &modelConfig) |
|
|
if err != nil { |
|
|
return nil, err |
|
|
} |
|
|
if modelConfig.Model != "" { |
|
|
additionalFiles = append(additionalFiles, modelConfig.ModelFileName()) |
|
|
} |
|
|
|
|
|
if modelConfig.MMProj != "" { |
|
|
additionalFiles = append(additionalFiles, modelConfig.MMProjFileName()) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
galleryconfig, err := ReadConfigFile[ModelConfig](galleryFile) |
|
|
if err == nil && galleryconfig != nil { |
|
|
for _, f := range galleryconfig.Files { |
|
|
fullPath := filepath.Join(systemState.Model.ModelsPath, f.Filename) |
|
|
if err := utils.VerifyPath(fullPath, systemState.Model.ModelsPath); err != nil { |
|
|
return allFiles, fmt.Errorf("failed to verify path %s: %w", fullPath, err) |
|
|
} |
|
|
allFiles = append(allFiles, fullPath) |
|
|
} |
|
|
} else { |
|
|
xlog.Error("failed to read gallery file", "error", err, "file", configFile) |
|
|
} |
|
|
|
|
|
for _, f := range additionalFiles { |
|
|
fullPath := filepath.Join(filepath.Join(systemState.Model.ModelsPath, f)) |
|
|
if err := utils.VerifyPath(fullPath, systemState.Model.ModelsPath); err != nil { |
|
|
return allFiles, fmt.Errorf("failed to verify path %s: %w", fullPath, err) |
|
|
} |
|
|
allFiles = append(allFiles, fullPath) |
|
|
} |
|
|
|
|
|
allFiles = append(allFiles, galleryFile) |
|
|
|
|
|
|
|
|
allFiles = utils.Unique(allFiles) |
|
|
|
|
|
return allFiles, nil |
|
|
} |
|
|
|
|
|
func DeleteModelFromSystem(systemState *system.SystemState, name string) error { |
|
|
configFile := filepath.Join(systemState.Model.ModelsPath, fmt.Sprintf("%s.yaml", name)) |
|
|
|
|
|
filesToRemove, err := listModelFiles(systemState, name) |
|
|
if err != nil { |
|
|
return err |
|
|
} |
|
|
|
|
|
allOtherFiles := []string{} |
|
|
|
|
|
fi, err := os.ReadDir(systemState.Model.ModelsPath) |
|
|
if err != nil { |
|
|
return err |
|
|
} |
|
|
for _, f := range fi { |
|
|
if f.IsDir() { |
|
|
continue |
|
|
} |
|
|
if strings.HasPrefix(f.Name(), "._gallery_") { |
|
|
continue |
|
|
} |
|
|
if !strings.HasSuffix(f.Name(), ".yaml") && !strings.HasSuffix(f.Name(), ".yml") { |
|
|
continue |
|
|
} |
|
|
if f.Name() == fmt.Sprintf("%s.yaml", name) || f.Name() == fmt.Sprintf("%s.yml", name) { |
|
|
continue |
|
|
} |
|
|
|
|
|
name := strings.TrimSuffix(f.Name(), ".yaml") |
|
|
name = strings.TrimSuffix(name, ".yml") |
|
|
|
|
|
xlog.Debug("Checking file", "file", f.Name()) |
|
|
files, err := listModelFiles(systemState, name) |
|
|
if err != nil { |
|
|
xlog.Debug("failed to list files for model", "error", err, "model", f.Name()) |
|
|
continue |
|
|
} |
|
|
allOtherFiles = append(allOtherFiles, files...) |
|
|
} |
|
|
|
|
|
xlog.Debug("Files to remove", "files", filesToRemove) |
|
|
xlog.Debug("All other files", "files", allOtherFiles) |
|
|
|
|
|
|
|
|
for _, f := range filesToRemove { |
|
|
if slices.Contains(allOtherFiles, f) { |
|
|
xlog.Debug("Skipping file because it is part of another model", "file", f) |
|
|
continue |
|
|
} |
|
|
if e := os.Remove(f); e != nil { |
|
|
xlog.Error("failed to remove file", "error", e, "file", f) |
|
|
} |
|
|
} |
|
|
|
|
|
return os.Remove(configFile) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
func SafetyScanGalleryModels(galleries []lconfig.Gallery, systemState *system.SystemState) error { |
|
|
galleryModels, err := AvailableGalleryModels(galleries, systemState) |
|
|
if err != nil { |
|
|
return err |
|
|
} |
|
|
for _, gM := range galleryModels { |
|
|
if gM.Installed { |
|
|
err = errors.Join(err, SafetyScanGalleryModel(gM)) |
|
|
} |
|
|
} |
|
|
return err |
|
|
} |
|
|
|
|
|
func SafetyScanGalleryModel(galleryModel *GalleryModel) error { |
|
|
for _, file := range galleryModel.AdditionalFiles { |
|
|
scanResults, err := downloader.HuggingFaceScan(downloader.URI(file.URI)) |
|
|
if err != nil && errors.Is(err, downloader.ErrUnsafeFilesFound) { |
|
|
xlog.Error("Contains unsafe file(s)!", "model", galleryModel.Name, "clamAV", scanResults.ClamAVInfectedFiles, "pickles", scanResults.DangerousPickles) |
|
|
return err |
|
|
} |
|
|
} |
|
|
return nil |
|
|
} |
|
|
|