mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 18:09:05 -04:00
ResolveMetaModel detached the resolved entry's Overrides and ConfigFile with maps.Clone, which only copies the top level. Gallery overrides are nested in practice (parameters.model is near-universal) and the install path merges the caller's request with mergo.WithOverride, which recurses into nested maps and overwrites them in place, so the gallery entry's own inner maps were still reachable and still got rewritten by the last caller to install. Copy both maps all the way down instead, recursing through the container shapes a YAML decoder produces. ConfigFile is not mutated on the install path today, but it carries the same kind of nested payload and leaving it shallowly cloned would invite the bug back. Also fix two specs that passed whether or not their target fix was present: - "does not write the caller's overrides back into the gallery entry" re-read the catalog from disk, which re-unmarshals fresh structs and so cannot observe in-memory aliasing. It now asserts against the in-memory gallery entry and drives the real mergo merge. - "round-trips the resolution record to disk under the meta's name" asserted a name that is already correct in the config_file branch. It now drives the url branch via a file:// fixture, where the meta-name overlay actually applies. Both were verified red by reverting their fix. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
710 lines
24 KiB
Go
710 lines
24 KiB
Go
package gallery
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"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/modelartifacts"
|
|
"github.com/mudler/LocalAI/pkg/system"
|
|
"github.com/mudler/LocalAI/pkg/utils"
|
|
|
|
"github.com/mudler/xlog"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
/*
|
|
|
|
description: |
|
|
foo
|
|
license: ""
|
|
|
|
urls:
|
|
-
|
|
-
|
|
|
|
name: "bar"
|
|
|
|
config_file: |
|
|
# Note, name will be injected. or generated by the alias wanted by the user
|
|
threads: 14
|
|
|
|
files:
|
|
- filename: ""
|
|
sha: ""
|
|
uri: ""
|
|
|
|
prompt_templates:
|
|
- name: ""
|
|
content: ""
|
|
|
|
*/
|
|
// ModelConfig is the model configuration which contains all the model details
|
|
// This configuration is read from the gallery endpoint and is used to download and install the model
|
|
// It is the internal structure, separated from the request
|
|
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"`
|
|
|
|
// The fields below record how a meta entry was resolved, so a reinstall
|
|
// or upgrade can honor the same pin and so operators can see which
|
|
// variant a stable model name is actually backed by.
|
|
MetaName string `yaml:"meta_name,omitempty"`
|
|
ResolvedVariant string `yaml:"resolved_variant,omitempty"`
|
|
PinnedVariant string `yaml:"pinned_variant,omitempty"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// ResolveMetaModel turns a meta gallery entry into the concrete entry that
|
|
// should be installed on this host, returning it renamed to the meta's name
|
|
// and carrying the meta's presentation metadata.
|
|
//
|
|
// Why the metadata split: the payload (url, config_file, files, overrides)
|
|
// must come from the variant because that is what actually gets downloaded,
|
|
// while the presentation (name, description, icon, tags) must come from the
|
|
// meta so the installed model presents as the model rather than the variant.
|
|
func ResolveMetaModel(models []*GalleryModel, meta *GalleryModel, env ResolveEnv, pin string) (*GalleryModel, Candidate, error) {
|
|
candidate, err := ResolveCandidate(meta.Candidates, env, pin)
|
|
if err != nil {
|
|
return nil, Candidate{}, fmt.Errorf("resolving variant for model %q: %w", meta.Name, err)
|
|
}
|
|
|
|
concrete := FindGalleryElement(models, candidate.Model)
|
|
if concrete == nil {
|
|
return nil, Candidate{}, fmt.Errorf("model %q references variant %q which does not exist in any configured gallery", meta.Name, candidate.Model)
|
|
}
|
|
if concrete.IsMeta() {
|
|
return nil, Candidate{}, fmt.Errorf("model %q references variant %q which is itself a meta entry; meta entries may not nest", meta.Name, candidate.Model)
|
|
}
|
|
|
|
// A pin is an operator override and deliberately bypasses the hardware
|
|
// checks, but a silent bypass makes a later out-of-memory failure
|
|
// impossible to trace back to the pin, so it is recorded loudly here.
|
|
// It is warned about only once the pin is known to name a real, installable
|
|
// entry, otherwise a pin naming a listed-but-nonexistent variant would warn
|
|
// about VRAM and then fail for an entirely unrelated reason.
|
|
if pin != "" {
|
|
if floor, declared, verr := candidate.EffectiveMinVRAM(); verr == nil && declared && env.VRAM < floor {
|
|
xlog.Warn("Pinned model variant declares more VRAM than this system reports; installing anyway because the pin overrides hardware resolution",
|
|
"model", meta.Name, "variant", candidate.Model, "required_vram", floor, "available_vram", env.VRAM)
|
|
}
|
|
}
|
|
|
|
resolved := *concrete
|
|
resolved.Name = meta.Name
|
|
resolved.Description = meta.Description
|
|
resolved.Icon = meta.Icon
|
|
resolved.License = meta.License
|
|
resolved.Candidates = nil
|
|
|
|
// The struct copy above is shallow, so every reference-typed field still
|
|
// aliases the gallery's own entries. The install path mutates Overrides in
|
|
// place (mergo merges the caller's request overrides into it) and appends to
|
|
// the URL and tag slices, which would write the caller's request into the
|
|
// gallery catalog itself and leak between installs the moment this path
|
|
// reads from a cached, long-lived gallery listing. Detach them here.
|
|
//
|
|
// Overrides and ConfigFile are copied all the way down rather than cloned at
|
|
// the top level only: gallery overrides are nested in practice (a
|
|
// parameters.model map is near-universal), and mergo recurses into nested
|
|
// maps and overwrites them in place, so a top-level clone would still hand
|
|
// the caller the gallery's own inner maps. The slices below hold value types,
|
|
// so cloning them once fully detaches them.
|
|
resolved.Overrides = deepCopyStringMap(concrete.Overrides)
|
|
resolved.ConfigFile = deepCopyStringMap(concrete.ConfigFile)
|
|
resolved.AdditionalFiles = slices.Clone(concrete.AdditionalFiles)
|
|
resolved.URLs = slices.Clone(meta.URLs)
|
|
resolved.Tags = slices.Clone(meta.Tags)
|
|
|
|
return &resolved, candidate, nil
|
|
}
|
|
|
|
// deepCopyStringMap copies a decoded YAML map so no part of the result, at any
|
|
// depth, is reachable from the original.
|
|
func deepCopyStringMap(m map[string]any) map[string]any {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
out := make(map[string]any, len(m))
|
|
for k, v := range m {
|
|
out[k] = deepCopyYAMLValue(v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// deepCopyYAMLValue recurses through the only container shapes a YAML decoder
|
|
// produces. Scalars are returned as-is because they cannot be mutated through
|
|
// the copy. map[any]any is handled as well because gopkg.in/yaml.v2 decodes
|
|
// non-string keys into it, and gallery documents are not guaranteed to have
|
|
// passed through the v3 decoder.
|
|
func deepCopyYAMLValue(v any) any {
|
|
switch t := v.(type) {
|
|
case map[string]any:
|
|
return deepCopyStringMap(t)
|
|
case map[any]any:
|
|
out := make(map[any]any, len(t))
|
|
for k, val := range t {
|
|
out[k] = deepCopyYAMLValue(val)
|
|
}
|
|
return out
|
|
case []any:
|
|
out := make([]any, len(t))
|
|
for i, val := range t {
|
|
out[i] = deepCopyYAMLValue(val)
|
|
}
|
|
return out
|
|
default:
|
|
return v
|
|
}
|
|
}
|
|
|
|
// Installs a model from the gallery
|
|
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, requireBackendIntegrity bool, options ...InstallOption) error {
|
|
|
|
installOpts := applyInstallOptions(options...)
|
|
|
|
applyModel := func(model *GalleryModel, record *ModelConfig) 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 {
|
|
// TODO: is this worse than using the override method with a blank cfg yaml?
|
|
reYamlConfig, err := yaml.Marshal(model.ConfigFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
config = ModelConfig{
|
|
ConfigFile: string(reYamlConfig),
|
|
Description: model.Description,
|
|
License: model.License,
|
|
Name: model.Name,
|
|
Files: make([]File, 0), // Real values get added below, must be blank
|
|
// URLs are deliberately not seeded here: they are appended once
|
|
// below for both branches, and seeding them too would write every
|
|
// URL twice into the persisted gallery file.
|
|
// Prompt Template Skipped for now - I expect in this mode that they will be delivered as files.
|
|
}
|
|
} else {
|
|
return fmt.Errorf("invalid gallery model %+v", model)
|
|
}
|
|
|
|
if record != nil {
|
|
config.MetaName = record.MetaName
|
|
config.ResolvedVariant = record.ResolvedVariant
|
|
config.PinnedVariant = record.PinnedVariant
|
|
// The variant's own name would otherwise be persisted here, which
|
|
// contradicts the whole point of a meta entry: the model is known by
|
|
// the meta's stable name regardless of which variant backs it.
|
|
config.Name = record.MetaName
|
|
}
|
|
|
|
installName := model.Name
|
|
if req.Name != "" {
|
|
installName = req.Name
|
|
}
|
|
|
|
// Copy the model configuration from the request schema
|
|
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...)
|
|
|
|
// TODO model.Overrides could be merged with user overrides (not defined yet)
|
|
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, options...)
|
|
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, requireBackendIntegrity); 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)
|
|
}
|
|
|
|
// Meta-ness is checked before anything looks at the URL: a meta entry also
|
|
// carries a url as a fallback for older LocalAI releases that do not
|
|
// understand candidates, so carrying both is normal and meta wins here.
|
|
if !model.IsMeta() {
|
|
return applyModel(model, nil)
|
|
}
|
|
|
|
pin := installOpts.variant
|
|
// A previously recorded pin survives reinstalls and upgrades, so a user
|
|
// who deliberately chose a variant is not silently re-resolved onto a
|
|
// different one by a hardware or gallery change.
|
|
//
|
|
// The record is keyed by the name the model was installed under, not by the
|
|
// gallery entry name: applyModel writes it to ._gallery_<installName>.yaml,
|
|
// where installName is req.Name whenever the caller supplied one. Reading it
|
|
// back under the meta's own name would miss the record for every custom-named
|
|
// install and silently re-resolve a deliberately pinned model onto a
|
|
// different variant, possibly swapping its backend.
|
|
if pin == "" {
|
|
installName := model.Name
|
|
if req.Name != "" {
|
|
installName = req.Name
|
|
}
|
|
if previous, err := GetLocalModelConfiguration(systemState.Model.ModelsPath, installName); err == nil && previous != nil {
|
|
pin = previous.PinnedVariant
|
|
}
|
|
}
|
|
|
|
env := ResolveEnv{
|
|
Capability: systemState.DetectedCapability(),
|
|
VRAM: systemState.VRAM,
|
|
}
|
|
|
|
resolved, candidate, err := ResolveMetaModel(models, model, env, pin)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
xlog.Info("Resolved meta model to variant",
|
|
"model", model.Name, "variant", candidate.Model,
|
|
"capability", env.Capability, "vram", env.VRAM, "pinned", pin != "")
|
|
|
|
return applyModel(resolved, &ModelConfig{
|
|
MetaName: model.Name,
|
|
ResolvedVariant: candidate.Model,
|
|
PinnedVariant: pin,
|
|
})
|
|
}
|
|
|
|
func InstallModel(ctx context.Context, systemState *system.SystemState, nameOverride string, galleryConfig *ModelConfig, configOverrides map[string]any, downloadStatus func(string, string, string, float64), enforceScan bool, options ...InstallOption) (*lconfig.ModelConfig, error) {
|
|
installOptions := applyInstallOptions(options...)
|
|
config := galleryConfig
|
|
basePath := systemState.Model.ModelsPath
|
|
// Create base path if it doesn't exist
|
|
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)
|
|
}
|
|
|
|
name := config.Name
|
|
if nameOverride != "" {
|
|
name = nameOverride
|
|
}
|
|
|
|
if err := utils.VerifyPath(name+".yaml", basePath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
modelConfig := lconfig.ModelConfig{}
|
|
configFilePath := filepath.Join(basePath, name+".yaml")
|
|
var updatedConfigYAML []byte
|
|
writeConfig := len(configOverrides) != 0 || len(config.ConfigFile) != 0
|
|
|
|
if writeConfig {
|
|
// Read and update config file as map[string]interface{}
|
|
configMap := make(map[string]any)
|
|
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
|
|
}
|
|
}
|
|
|
|
// Marshal the merged map for typed validation and default application.
|
|
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)
|
|
}
|
|
|
|
// Apply model-family-specific inference defaults so they are persisted in the config YAML.
|
|
// Apply to the typed struct for validation, and merge into configMap for serialization
|
|
// (configMap preserves unknown fields that ModelConfig would drop).
|
|
lconfig.ApplyInferenceDefaults(&modelConfig, name, modelConfig.Model)
|
|
|
|
// Merge inference defaults into configMap so they are persisted without losing unknown fields.
|
|
if modelConfig.Temperature != nil {
|
|
if _, exists := configMap["temperature"]; !exists {
|
|
configMap["temperature"] = *modelConfig.Temperature
|
|
}
|
|
}
|
|
if modelConfig.TopP != nil {
|
|
if _, exists := configMap["top_p"]; !exists {
|
|
configMap["top_p"] = *modelConfig.TopP
|
|
}
|
|
}
|
|
if modelConfig.TopK != nil {
|
|
if _, exists := configMap["top_k"]; !exists {
|
|
configMap["top_k"] = *modelConfig.TopK
|
|
}
|
|
}
|
|
if modelConfig.MinP != nil {
|
|
if _, exists := configMap["min_p"]; !exists {
|
|
configMap["min_p"] = *modelConfig.MinP
|
|
}
|
|
}
|
|
if modelConfig.RepeatPenalty != 0 {
|
|
if _, exists := configMap["repeat_penalty"]; !exists {
|
|
configMap["repeat_penalty"] = modelConfig.RepeatPenalty
|
|
}
|
|
}
|
|
if modelConfig.PresencePenalty != 0 {
|
|
if _, exists := configMap["presence_penalty"]; !exists {
|
|
configMap["presence_penalty"] = modelConfig.PresencePenalty
|
|
}
|
|
}
|
|
|
|
if valid, err := modelConfig.Validate(); !valid {
|
|
return nil, fmt.Errorf("failed to validate updated config YAML: %v", err)
|
|
}
|
|
|
|
if len(config.Files) == 0 {
|
|
artifactSpec, inferred, hasArtifact, err := modelConfig.PrimaryArtifactSpec(basePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if hasArtifact && enforceScan {
|
|
artifact, err := artifactSpec.Normalize()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
probe := downloader.URI(fmt.Sprintf("%s/%s/resolve/%s/.gitattributes", strings.TrimRight(downloader.HF_ENDPOINT, "/"), artifact.Source.Repo, url.PathEscape(artifact.Source.Revision)))
|
|
if _, err := downloader.HuggingFaceScan(probe); errors.Is(err, downloader.ErrUnsafeFilesFound) {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if hasArtifact {
|
|
applied, err := bindPrimaryArtifact(ctx, basePath, &modelConfig, configMap, installOptions.materializer, artifactSpec, inferred)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if applied {
|
|
// Re-marshal from configMap after artifact binding to preserve unknown fields.
|
|
updatedConfigYAML, err = yaml.Marshal(configMap)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal config with inference defaults: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Download files and verify their SHA
|
|
tasks := make([]downloader.FileTask, 0, len(config.Files))
|
|
for i, file := range config.Files {
|
|
// Check for cancellation before each file
|
|
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
|
|
}
|
|
|
|
// Create file path
|
|
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
|
|
}
|
|
}
|
|
tasks = append(tasks, downloader.FileTask{
|
|
URI: downloader.URI(file.URI),
|
|
Destination: filePath,
|
|
SHA256: file.SHA256,
|
|
FileIndex: i,
|
|
TotalFiles: len(config.Files),
|
|
})
|
|
}
|
|
if err := downloader.DownloadFilesWithContext(ctx, tasks, downloadStatus); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Write prompt template contents to separate files
|
|
for _, template := range config.PromptTemplates {
|
|
if err := utils.VerifyPath(template.Name+".tmpl", basePath); err != nil {
|
|
return nil, err
|
|
}
|
|
// Create file path
|
|
filePath := filepath.Join(basePath, template.Name+".tmpl")
|
|
|
|
// Create parent directory
|
|
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)
|
|
}
|
|
// Create and write file content
|
|
err = os.WriteFile(filePath, []byte(template.Content), 0644)
|
|
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)
|
|
}
|
|
|
|
if writeConfig {
|
|
if len(modelConfig.Artifacts) > 0 {
|
|
modelartifacts.ReportProgress(ctx, modelartifacts.ProgressEvent{
|
|
Phase: modelartifacts.PhasePersisting, Artifact: modelConfig.Artifacts[0].Name,
|
|
})
|
|
}
|
|
if err := writeModelConfigAtomic(configFilePath, updatedConfigYAML); err != nil {
|
|
return nil, fmt.Errorf("failed to atomically write updated config file: %w", err)
|
|
}
|
|
|
|
xlog.Debug("Written config file", "file", configFilePath)
|
|
}
|
|
|
|
// Save the model gallery file for further reference
|
|
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, 0644)
|
|
}
|
|
|
|
func galleryFileName(name string) string {
|
|
return "._gallery_" + name + ".yaml"
|
|
}
|
|
|
|
// GalleryFileName returns the on-disk filename of the gallery metadata file
|
|
// for a given installed model name (e.g. "._gallery_<name>.yaml").
|
|
func GalleryFileName(name string) string {
|
|
return galleryFileName(name)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// os.PathSeparator is not allowed in model names. Replace them with "__" to avoid conflicts with file paths.
|
|
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{}
|
|
|
|
// Galleryname is the name of the model in this case
|
|
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 != "" && len(modelConfig.Artifacts) == 0 {
|
|
additionalFiles = append(additionalFiles, modelConfig.ModelFileName())
|
|
}
|
|
|
|
if modelConfig.MMProj != "" {
|
|
additionalFiles = append(additionalFiles, modelConfig.MMProjFileName())
|
|
}
|
|
}
|
|
|
|
// read the model config
|
|
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)
|
|
|
|
// skip duplicates
|
|
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{}
|
|
// Get all files of all other models
|
|
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)
|
|
|
|
// Removing files
|
|
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)
|
|
}
|
|
|
|
// This is ***NEVER*** going to be perfect or finished.
|
|
// This is a BEST EFFORT function to surface known-vulnerable models to users.
|
|
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
|
|
}
|