Skip to content

Commit 77bbeed

Browse files
authored
feat(importer): unify importing code with CLI (#7299)
* feat(importer): support ollama and OCI, unify code Signed-off-by: Ettore Di Giacinto <[email protected]> * feat: support importing from local file Signed-off-by: Ettore Di Giacinto <[email protected]> * support also yaml config files Signed-off-by: Ettore Di Giacinto <[email protected]> * Correctly handle local files Signed-off-by: Ettore Di Giacinto <[email protected]> * Extract importing errors Signed-off-by: Ettore Di Giacinto <[email protected]> * Add importer tests Signed-off-by: Ettore Di Giacinto <[email protected]> * Add integration tests Signed-off-by: Ettore Di Giacinto <[email protected]> * chore(UX): improve and specify supported URI formats Signed-off-by: Ettore Di Giacinto <[email protected]> * fail if backend does not have a runfile Signed-off-by: Ettore Di Giacinto <[email protected]> * Adapt tests Signed-off-by: Ettore Di Giacinto <[email protected]> * feat(gallery): add cache for galleries Signed-off-by: Ettore Di Giacinto <[email protected]> * fix(ui): remove handler duplicate File input handlers are now handled by Alpine.js @change handlers in chat.html. Removed duplicate listeners to prevent files from being processed twice Signed-off-by: Ettore Di Giacinto <[email protected]> * fix(ui): be consistent in attachments in the chat Signed-off-by: Ettore Di Giacinto <[email protected]> * Fail if no importer matches Signed-off-by: Ettore Di Giacinto <[email protected]> * fix: propagate ops correctly Signed-off-by: Ettore Di Giacinto <[email protected]> * Fixups Signed-off-by: Ettore Di Giacinto <[email protected]> --------- Signed-off-by: Ettore Di Giacinto <[email protected]>
1 parent 3152611 commit 77bbeed

File tree

26 files changed

+885
-272
lines changed

26 files changed

+885
-272
lines changed

core/config/model_config.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
import (
4+
"fmt"
45
"os"
56
"regexp"
67
"slices"
@@ -475,7 +476,7 @@ func (cfg *ModelConfig) SetDefaults(opts ...ConfigLoaderOption) {
475476
cfg.syncKnownUsecasesFromString()
476477
}
477478

478-
func (c *ModelConfig) Validate() bool {
479+
func (c *ModelConfig) Validate() (bool, error) {
479480
downloadedFileNames := []string{}
480481
for _, f := range c.DownloadFiles {
481482
downloadedFileNames = append(downloadedFileNames, f.Filename)
@@ -489,17 +490,20 @@ func (c *ModelConfig) Validate() bool {
489490
}
490491
if strings.HasPrefix(n, string(os.PathSeparator)) ||
491492
strings.Contains(n, "..") {
492-
return false
493+
return false, fmt.Errorf("invalid file path: %s", n)
493494
}
494495
}
495496

496497
if c.Backend != "" {
497498
// a regex that checks that is a string name with no special characters, except '-' and '_'
498499
re := regexp.MustCompile(`^[a-zA-Z0-9-_]+$`)
499-
return re.MatchString(c.Backend)
500+
if !re.MatchString(c.Backend) {
501+
return false, fmt.Errorf("invalid backend name: %s", c.Backend)
502+
}
503+
return true, nil
500504
}
501505

502-
return true
506+
return true, nil
503507
}
504508

505509
func (c *ModelConfig) HasTemplate() bool {

core/config/model_config_loader.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ func (bcl *ModelConfigLoader) LoadMultipleModelConfigsSingleFile(file string, op
169169
}
170170

171171
for _, cc := range c {
172-
if cc.Validate() {
172+
if valid, _ := cc.Validate(); valid {
173173
bcl.configs[cc.Name] = *cc
174174
}
175175
}
@@ -184,7 +184,7 @@ func (bcl *ModelConfigLoader) ReadModelConfig(file string, opts ...ConfigLoaderO
184184
return fmt.Errorf("ReadModelConfig cannot read config file %q: %w", file, err)
185185
}
186186

187-
if c.Validate() {
187+
if valid, _ := c.Validate(); valid {
188188
bcl.configs[c.Name] = *c
189189
} else {
190190
return fmt.Errorf("config is not valid")
@@ -362,7 +362,7 @@ func (bcl *ModelConfigLoader) LoadModelConfigsFromPath(path string, opts ...Conf
362362
log.Error().Err(err).Str("File Name", file.Name()).Msgf("LoadModelConfigsFromPath cannot read config file")
363363
continue
364364
}
365-
if c.Validate() {
365+
if valid, _ := c.Validate(); valid {
366366
bcl.configs[c.Name] = *c
367367
} else {
368368
log.Error().Err(err).Str("Name", c.Name).Msgf("config is not valid")

core/config/model_config_test.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ known_usecases:
2828
config, err := readModelConfigFromFile(tmp.Name())
2929
Expect(err).To(BeNil())
3030
Expect(config).ToNot(BeNil())
31-
Expect(config.Validate()).To(BeFalse())
31+
valid, err := config.Validate()
32+
Expect(err).To(HaveOccurred())
33+
Expect(valid).To(BeFalse())
3234
Expect(config.KnownUsecases).ToNot(BeNil())
3335
})
3436
It("Test Validate", func() {
@@ -46,7 +48,9 @@ parameters:
4648
Expect(config).ToNot(BeNil())
4749
// two configs in config.yaml
4850
Expect(config.Name).To(Equal("bar-baz"))
49-
Expect(config.Validate()).To(BeTrue())
51+
valid, err := config.Validate()
52+
Expect(err).To(BeNil())
53+
Expect(valid).To(BeTrue())
5054

5155
// download https://raw.githubusercontent.com/mudler/LocalAI/v2.25.0/embedded/models/hermes-2-pro-mistral.yaml
5256
httpClient := http.Client{}
@@ -63,7 +67,9 @@ parameters:
6367
Expect(config).ToNot(BeNil())
6468
// two configs in config.yaml
6569
Expect(config.Name).To(Equal("hermes-2-pro-mistral"))
66-
Expect(config.Validate()).To(BeTrue())
70+
valid, err = config.Validate()
71+
Expect(err).To(BeNil())
72+
Expect(valid).To(BeTrue())
6773
})
6874
})
6975
It("Properly handles backend usecase matching", func() {

core/gallery/backends.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL
164164
return fmt.Errorf("failed copying: %w", err)
165165
}
166166
} else {
167-
uri := downloader.URI(config.URI)
167+
log.Debug().Str("uri", config.URI).Str("backendPath", backendPath).Msg("Downloading backend")
168168
if err := uri.DownloadFileWithContext(ctx, backendPath, "", 1, 1, downloadStatus); err != nil {
169169
success := false
170170
// Try to download from mirrors
@@ -177,16 +177,27 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL
177177
}
178178
if err := downloader.URI(mirror).DownloadFileWithContext(ctx, backendPath, "", 1, 1, downloadStatus); err == nil {
179179
success = true
180+
log.Debug().Str("uri", config.URI).Str("backendPath", backendPath).Msg("Downloaded backend")
180181
break
181182
}
182183
}
183184

184185
if !success {
186+
log.Error().Str("uri", config.URI).Str("backendPath", backendPath).Err(err).Msg("Failed to download backend")
185187
return fmt.Errorf("failed to download backend %q: %v", config.URI, err)
186188
}
189+
} else {
190+
log.Debug().Str("uri", config.URI).Str("backendPath", backendPath).Msg("Downloaded backend")
187191
}
188192
}
189193

194+
// sanity check - check if runfile is present
195+
runFile := filepath.Join(backendPath, runFile)
196+
if _, err := os.Stat(runFile); os.IsNotExist(err) {
197+
log.Error().Str("runFile", runFile).Msg("Run file not found")
198+
return fmt.Errorf("not a valid backend: run file not found %q", runFile)
199+
}
200+
190201
// Create metadata for the backend
191202
metadata := &BackendMetadata{
192203
Name: name,

core/gallery/backends_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,8 +563,8 @@ var _ = Describe("Gallery Backends", func() {
563563
)
564564
Expect(err).NotTo(HaveOccurred())
565565
err = InstallBackend(context.TODO(), systemState, ml, &backend, nil)
566-
Expect(err).To(HaveOccurred()) // Will fail due to invalid URI, but path should be created
567566
Expect(newPath).To(BeADirectory())
567+
Expect(err).To(HaveOccurred()) // Will fail due to invalid URI, but path should be created
568568
})
569569

570570
It("should overwrite existing backend", func() {

core/gallery/gallery.go

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import (
66
"os"
77
"path/filepath"
88
"strings"
9+
"time"
910

1011
"github.com/lithammer/fuzzysearch/fuzzy"
1112
"github.com/mudler/LocalAI/core/config"
1213
"github.com/mudler/LocalAI/pkg/downloader"
1314
"github.com/mudler/LocalAI/pkg/system"
15+
"github.com/mudler/LocalAI/pkg/xsync"
1416
"github.com/rs/zerolog/log"
1517

1618
"gopkg.in/yaml.v2"
@@ -19,7 +21,7 @@ import (
1921
func GetGalleryConfigFromURL[T any](url string, basePath string) (T, error) {
2022
var config T
2123
uri := downloader.URI(url)
22-
err := uri.DownloadWithCallback(basePath, func(url string, d []byte) error {
24+
err := uri.ReadWithCallback(basePath, func(url string, d []byte) error {
2325
return yaml.Unmarshal(d, &config)
2426
})
2527
if err != nil {
@@ -32,7 +34,7 @@ func GetGalleryConfigFromURL[T any](url string, basePath string) (T, error) {
3234
func GetGalleryConfigFromURLWithContext[T any](ctx context.Context, url string, basePath string) (T, error) {
3335
var config T
3436
uri := downloader.URI(url)
35-
err := uri.DownloadWithAuthorizationAndCallback(ctx, basePath, "", func(url string, d []byte) error {
37+
err := uri.ReadWithAuthorizationAndCallback(ctx, basePath, "", func(url string, d []byte) error {
3638
return yaml.Unmarshal(d, &config)
3739
})
3840
if err != nil {
@@ -141,7 +143,7 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst
141143

142144
// Get models from galleries
143145
for _, gallery := range galleries {
144-
galleryModels, err := getGalleryElements[*GalleryModel](gallery, systemState.Model.ModelsPath, func(model *GalleryModel) bool {
146+
galleryModels, err := getGalleryElements(gallery, systemState.Model.ModelsPath, func(model *GalleryModel) bool {
145147
if _, err := os.Stat(filepath.Join(systemState.Model.ModelsPath, fmt.Sprintf("%s.yaml", model.GetName()))); err == nil {
146148
return true
147149
}
@@ -182,7 +184,7 @@ func AvailableBackends(galleries []config.Gallery, systemState *system.SystemSta
182184
func findGalleryURLFromReferenceURL(url string, basePath string) (string, error) {
183185
var refFile string
184186
uri := downloader.URI(url)
185-
err := uri.DownloadWithCallback(basePath, func(url string, d []byte) error {
187+
err := uri.ReadWithCallback(basePath, func(url string, d []byte) error {
186188
refFile = string(d)
187189
if len(refFile) == 0 {
188190
return fmt.Errorf("invalid reference file at url %s: %s", url, d)
@@ -194,6 +196,17 @@ func findGalleryURLFromReferenceURL(url string, basePath string) (string, error)
194196
return refFile, err
195197
}
196198

199+
type galleryCacheEntry struct {
200+
yamlEntry []byte
201+
lastUpdated time.Time
202+
}
203+
204+
func (entry galleryCacheEntry) hasExpired() bool {
205+
return entry.lastUpdated.Before(time.Now().Add(-1 * time.Hour))
206+
}
207+
208+
var galleryCache = xsync.NewSyncedMap[string, galleryCacheEntry]()
209+
197210
func getGalleryElements[T GalleryElement](gallery config.Gallery, basePath string, isInstalledCallback func(T) bool) ([]T, error) {
198211
var models []T = []T{}
199212

@@ -204,16 +217,37 @@ func getGalleryElements[T GalleryElement](gallery config.Gallery, basePath strin
204217
return models, err
205218
}
206219
}
220+
221+
cacheKey := fmt.Sprintf("%s-%s", gallery.Name, gallery.URL)
222+
if galleryCache.Exists(cacheKey) {
223+
entry := galleryCache.Get(cacheKey)
224+
// refresh if last updated is more than 1 hour ago
225+
if !entry.hasExpired() {
226+
err := yaml.Unmarshal(entry.yamlEntry, &models)
227+
if err != nil {
228+
return models, err
229+
}
230+
} else {
231+
galleryCache.Delete(cacheKey)
232+
}
233+
}
234+
207235
uri := downloader.URI(gallery.URL)
208236

209-
err := uri.DownloadWithCallback(basePath, func(url string, d []byte) error {
210-
return yaml.Unmarshal(d, &models)
211-
})
212-
if err != nil {
213-
if yamlErr, ok := err.(*yaml.TypeError); ok {
214-
log.Debug().Msgf("YAML errors: %s\n\nwreckage of models: %+v", strings.Join(yamlErr.Errors, "\n"), models)
237+
if len(models) == 0 {
238+
err := uri.ReadWithCallback(basePath, func(url string, d []byte) error {
239+
galleryCache.Set(cacheKey, galleryCacheEntry{
240+
yamlEntry: d,
241+
lastUpdated: time.Now(),
242+
})
243+
return yaml.Unmarshal(d, &models)
244+
})
245+
if err != nil {
246+
if yamlErr, ok := err.(*yaml.TypeError); ok {
247+
log.Debug().Msgf("YAML errors: %s\n\nwreckage of models: %+v", strings.Join(yamlErr.Errors, "\n"), models)
248+
}
249+
return models, fmt.Errorf("failed to read gallery elements: %w", err)
215250
}
216-
return models, err
217251
}
218252

219253
// Add gallery to models

core/gallery/importers/importers.go

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@ package importers
22

33
import (
44
"encoding/json"
5+
"fmt"
6+
"os"
57
"strings"
68

79
"github.com/rs/zerolog/log"
10+
"gopkg.in/yaml.v3"
811

12+
"github.com/mudler/LocalAI/core/config"
913
"github.com/mudler/LocalAI/core/gallery"
14+
"github.com/mudler/LocalAI/pkg/downloader"
1015
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
1116
)
1217

@@ -28,6 +33,10 @@ type Importer interface {
2833
Import(details Details) (gallery.ModelConfig, error)
2934
}
3035

36+
func hasYAMLExtension(uri string) bool {
37+
return strings.HasSuffix(uri, ".yaml") || strings.HasSuffix(uri, ".yml")
38+
}
39+
3140
func DiscoverModelConfig(uri string, preferences json.RawMessage) (gallery.ModelConfig, error) {
3241
var err error
3342
var modelConfig gallery.ModelConfig
@@ -42,26 +51,70 @@ func DiscoverModelConfig(uri string, preferences json.RawMessage) (gallery.Model
4251
if err != nil {
4352
// maybe not a HF repository
4453
// TODO: maybe we can check if the URI is a valid HF repository
45-
log.Debug().Str("uri", uri).Msg("Failed to get model details, maybe not a HF repository")
54+
log.Debug().Str("uri", uri).Str("hfrepoID", hfrepoID).Msg("Failed to get model details, maybe not a HF repository")
4655
} else {
4756
log.Debug().Str("uri", uri).Msg("Got model details")
4857
log.Debug().Any("details", hfDetails).Msg("Model details")
4958
}
5059

60+
// handle local config files ("/my-model.yaml" or "file://my-model.yaml")
61+
localURI := uri
62+
if strings.HasPrefix(uri, downloader.LocalPrefix) {
63+
localURI = strings.TrimPrefix(uri, downloader.LocalPrefix)
64+
}
65+
66+
// if a file exists or it's an url that ends with .yaml or .yml, read the config file directly
67+
if _, e := os.Stat(localURI); hasYAMLExtension(localURI) && (e == nil || downloader.URI(localURI).LooksLikeURL()) {
68+
var modelYAML []byte
69+
if downloader.URI(localURI).LooksLikeURL() {
70+
err := downloader.URI(localURI).ReadWithCallback(localURI, func(url string, i []byte) error {
71+
modelYAML = i
72+
return nil
73+
})
74+
if err != nil {
75+
log.Error().Err(err).Str("filepath", localURI).Msg("error reading model definition")
76+
return gallery.ModelConfig{}, err
77+
}
78+
} else {
79+
modelYAML, err = os.ReadFile(localURI)
80+
if err != nil {
81+
log.Error().Err(err).Str("filepath", localURI).Msg("error reading model definition")
82+
return gallery.ModelConfig{}, err
83+
}
84+
}
85+
86+
var modelConfig config.ModelConfig
87+
if e := yaml.Unmarshal(modelYAML, &modelConfig); e != nil {
88+
return gallery.ModelConfig{}, e
89+
}
90+
91+
configFile, err := yaml.Marshal(modelConfig)
92+
return gallery.ModelConfig{
93+
Description: modelConfig.Description,
94+
Name: modelConfig.Name,
95+
ConfigFile: string(configFile),
96+
}, err
97+
}
98+
5199
details := Details{
52100
HuggingFace: hfDetails,
53101
URI: uri,
54102
Preferences: preferences,
55103
}
56104

105+
importerMatched := false
57106
for _, importer := range defaultImporters {
58107
if importer.Match(details) {
108+
importerMatched = true
59109
modelConfig, err = importer.Import(details)
60110
if err != nil {
61111
continue
62112
}
63113
break
64114
}
65115
}
66-
return modelConfig, err
116+
if !importerMatched {
117+
return gallery.ModelConfig{}, fmt.Errorf("no importer matched for %s", uri)
118+
}
119+
return modelConfig, nil
67120
}

0 commit comments

Comments
 (0)