233 lines
6.9 KiB
Go
233 lines
6.9 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/pkg/model"
|
|
"github.com/mudler/LocalAI/pkg/system"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// fakeInstaller records every call and lets each test choose the behaviour
|
|
// for a given model name. Modelled on the real modelInstaller signature so
|
|
// the prefetch loop can't accidentally widen its dependency on the gallery
|
|
// package without breaking the contract here too.
|
|
type fakeInstaller struct {
|
|
mu sync.Mutex
|
|
calls []string
|
|
behavior map[string]error // model name -> error to return; missing key = nil (success)
|
|
}
|
|
|
|
func newFakeInstaller() *fakeInstaller {
|
|
return &fakeInstaller{behavior: map[string]error{}}
|
|
}
|
|
|
|
func (f *fakeInstaller) install(
|
|
_ context.Context,
|
|
_ []config.Gallery,
|
|
_ []config.Gallery,
|
|
_ *system.SystemState,
|
|
_ *model.ModelLoader,
|
|
name string,
|
|
) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls = append(f.calls, name)
|
|
return f.behavior[name]
|
|
}
|
|
|
|
func (f *fakeInstaller) recorded() []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]string, len(f.calls))
|
|
copy(out, f.calls)
|
|
return out
|
|
}
|
|
|
|
// minimalSystemState yields a SystemState pointed at a fresh tempdir. The
|
|
// prefetch loop never touches it directly (the fake installer ignores it),
|
|
// but the production signature requires a non-nil value and we want the
|
|
// tests to mirror the real wiring as closely as possible.
|
|
func minimalSystemState(modelsDir string) *system.SystemState {
|
|
ss, err := system.GetSystemState(system.WithModelPath(modelsDir))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
return ss
|
|
}
|
|
|
|
var _ = Describe("prefetchModels", func() {
|
|
var (
|
|
tmp string
|
|
ss *system.SystemState
|
|
galleries = `[{"name":"localai","url":"file:///dev/null"}]`
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
tmp, err = os.MkdirTemp("", "worker-prefetch-test")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
ss = minimalSystemState(tmp)
|
|
})
|
|
|
|
AfterEach(func() {
|
|
_ = os.RemoveAll(tmp)
|
|
})
|
|
|
|
It("installs every configured model before returning (happy path)", func() {
|
|
f := newFakeInstaller()
|
|
cfg := &Config{
|
|
PrefetchModels: []string{"llama-3.2-1b", "phi-3-mini"},
|
|
Galleries: galleries,
|
|
ModelsPath: tmp,
|
|
}
|
|
|
|
prefetchModels(context.Background(), cfg, ss, nil, nil, f.install)
|
|
|
|
Expect(f.recorded()).To(Equal([]string{"llama-3.2-1b", "phi-3-mini"}))
|
|
})
|
|
|
|
It("is a no-op when the file is already present on disk (idempotency contract)", func() {
|
|
// Drop a fake artifact where the gallery would have placed it; the
|
|
// real installer's downloader stats the path and short-circuits when
|
|
// the SHA matches (or any time SHA is unset). We simulate that here
|
|
// by having the fake installer assert the file exists before claiming
|
|
// success — exactly the invariant we rely on for restart-against-PVC.
|
|
modelFile := filepath.Join(tmp, "llama-3.2-1b.gguf")
|
|
Expect(os.WriteFile(modelFile, []byte("already-here"), 0o600)).To(Succeed())
|
|
|
|
var installerCalls int
|
|
var sawFile bool
|
|
fake := func(
|
|
_ context.Context,
|
|
_, _ []config.Gallery,
|
|
_ *system.SystemState,
|
|
_ *model.ModelLoader,
|
|
_ string,
|
|
) error {
|
|
installerCalls++
|
|
if _, err := os.Stat(modelFile); err == nil {
|
|
sawFile = true
|
|
}
|
|
// Real installer returns nil on cache hit. Mirror that.
|
|
return nil
|
|
}
|
|
|
|
cfg := &Config{
|
|
PrefetchModels: []string{"llama-3.2-1b"},
|
|
Galleries: galleries,
|
|
ModelsPath: tmp,
|
|
}
|
|
prefetchModels(context.Background(), cfg, ss, nil, nil, fake)
|
|
|
|
Expect(installerCalls).To(Equal(1), "installer is always invoked; its downloader handles the skip")
|
|
Expect(sawFile).To(BeTrue(), "fake installer saw the pre-existing artifact, matching the restart-on-warm-PVC path")
|
|
|
|
// File untouched.
|
|
data, err := os.ReadFile(modelFile)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(data)).To(Equal("already-here"))
|
|
})
|
|
|
|
It("logs and continues when an individual install fails (non-fatal contract)", func() {
|
|
f := newFakeInstaller()
|
|
f.behavior["broken"] = errors.New("gallery unreachable")
|
|
|
|
cfg := &Config{
|
|
PrefetchModels: []string{"good-1", "broken", "good-2"},
|
|
Galleries: galleries,
|
|
ModelsPath: tmp,
|
|
}
|
|
|
|
// Function must return normally despite the middle install failing.
|
|
Expect(func() {
|
|
prefetchModels(context.Background(), cfg, ss, nil, nil, f.install)
|
|
}).ToNot(Panic())
|
|
|
|
// Subsequent models still get attempted — failures don't short-circuit.
|
|
Expect(f.recorded()).To(Equal([]string{"good-1", "broken", "good-2"}))
|
|
})
|
|
|
|
It("does nothing when PrefetchModels is empty", func() {
|
|
f := newFakeInstaller()
|
|
cfg := &Config{ModelsPath: tmp, Galleries: galleries}
|
|
prefetchModels(context.Background(), cfg, ss, nil, nil, f.install)
|
|
Expect(f.recorded()).To(BeEmpty())
|
|
})
|
|
|
|
It("trims whitespace and skips empty entries", func() {
|
|
f := newFakeInstaller()
|
|
cfg := &Config{
|
|
PrefetchModels: []string{" llama ", "", " ", "phi"},
|
|
Galleries: galleries,
|
|
ModelsPath: tmp,
|
|
}
|
|
prefetchModels(context.Background(), cfg, ss, nil, nil, f.install)
|
|
Expect(f.recorded()).To(Equal([]string{"llama", "phi"}))
|
|
})
|
|
|
|
It("skips prefetch when LOCALAI_GALLERIES is malformed (non-fatal)", func() {
|
|
f := newFakeInstaller()
|
|
cfg := &Config{
|
|
PrefetchModels: []string{"llama"},
|
|
Galleries: `not-json`,
|
|
ModelsPath: tmp,
|
|
}
|
|
prefetchModels(context.Background(), cfg, ss, nil, nil, f.install)
|
|
Expect(f.recorded()).To(BeEmpty(), "no installer call when galleries can't be parsed")
|
|
})
|
|
|
|
It("skips prefetch when no galleries are configured (non-fatal)", func() {
|
|
f := newFakeInstaller()
|
|
cfg := &Config{
|
|
PrefetchModels: []string{"llama"},
|
|
Galleries: `[]`,
|
|
ModelsPath: tmp,
|
|
}
|
|
prefetchModels(context.Background(), cfg, ss, nil, nil, f.install)
|
|
Expect(f.recorded()).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("parseModelGalleries", func() {
|
|
It("returns empty slice on empty input", func() {
|
|
g, err := parseModelGalleries("")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(g).To(BeEmpty())
|
|
})
|
|
|
|
It("returns empty slice on whitespace-only input", func() {
|
|
g, err := parseModelGalleries(" \n\t ")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(g).To(BeEmpty())
|
|
})
|
|
|
|
It("parses a valid JSON list", func() {
|
|
g, err := parseModelGalleries(`[{"name":"localai","url":"github:mudler/LocalAI/gallery/index.yaml@master"}]`)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(g).To(HaveLen(1))
|
|
Expect(g[0].Name).To(Equal("localai"))
|
|
})
|
|
|
|
It("returns an error on malformed JSON", func() {
|
|
_, err := parseModelGalleries(`{this is not json`)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("normalizePrefetchList", func() {
|
|
It("drops empty and whitespace-only entries", func() {
|
|
got := normalizePrefetchList([]string{"a", "", " ", " b ", "\tc\n"})
|
|
Expect(got).To(Equal([]string{"a", "b", "c"}))
|
|
})
|
|
|
|
It("returns empty slice on empty input", func() {
|
|
Expect(normalizePrefetchList(nil)).To(BeEmpty())
|
|
Expect(normalizePrefetchList([]string{})).To(BeEmpty())
|
|
})
|
|
})
|