diff --git a/.agents/adding-backends.md b/.agents/adding-backends.md index fb98c55f2..ac169b534 100644 --- a/.agents/adding-backends.md +++ b/.agents/adding-backends.md @@ -251,6 +251,7 @@ After adding a new backend, verify: - [ ] No YAML syntax errors (check with linter) - [ ] No Makefile syntax errors (check with linter) - [ ] Follows the same pattern as similar backends (e.g., if it's a transcription backend, follow `faster-whisper` pattern) +- [ ] **`Load` validates its input and refuses models it can't serve.** When a model config has no explicit `backend:`, the model loader greedily probes *every* installed backend with the model's name and binds to the first `Load` that succeeds — an accept-anything `Load` will capture arbitrary LLMs (issue #9287). Backends that load a real artefact get this for free (the load fails); backends with no artefact must gate on the name: `opus` accepts only its own name (or none), `local-store` requires the `store.NamespacePrefix` namespace marker sent by `core/backend/stores.go`. - [ ] Documented: added to the category list in `docs/content/features/backends.md` (and any new endpoint/realtime capability documented under `docs/content/`) - [ ] If it is an in-house native C/C++/GGML engine, added to the maintained-engines table in the top-level `README.md` diff --git a/backend/go/local-store/store.go b/backend/go/local-store/store.go index 2085f74a9..a46c52691 100644 --- a/backend/go/local-store/store.go +++ b/backend/go/local-store/store.go @@ -22,6 +22,7 @@ import ( "fmt" "math" "slices" + "strings" "github.com/mudler/LocalAI/pkg/grpc/base" pb "github.com/mudler/LocalAI/pkg/grpc/proto" @@ -57,12 +58,18 @@ func NewStore() *Store { } } -// Load is a no-op — local-store has no on-disk artefact. opts.Model is -// just a namespace identifier; isolation is already handled upstream -// (ModelLoader spawns a fresh local-store process per (backend, -// model) tuple, so each namespace is its own Store{} instance). +// Load only validates the namespace — local-store has no on-disk +// artefact. opts.Model is a namespace identifier, which core's +// StoreBackend always sends with store.NamespacePrefix; anything else +// is the model loader's greedy autoload probing with a real model name, +// which must be refused or the LLM binds to the vector store. Isolation +// is already handled upstream (ModelLoader spawns a fresh local-store +// process per (backend, model) tuple, so each namespace is its own +// Store{} instance). func (s *Store) Load(opts *pb.ModelOptions) error { - _ = opts + if !strings.HasPrefix(opts.GetModel(), store.NamespacePrefix) { + return fmt.Errorf("local-store: refusing to load %q: not a store namespace (expected %q prefix)", opts.GetModel(), store.NamespacePrefix) + } return nil } diff --git a/backend/go/local-store/store_test.go b/backend/go/local-store/store_test.go index 2043647c0..a7f4d7e2e 100644 --- a/backend/go/local-store/store_test.go +++ b/backend/go/local-store/store_test.go @@ -12,6 +12,7 @@ import ( "testing" pb "github.com/mudler/LocalAI/pkg/grpc/proto" + "github.com/mudler/LocalAI/pkg/store" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -186,8 +187,18 @@ var _ = Describe("StoresFind", func() { }) var _ = Describe("StoresLoad", func() { - It("is a no-op", func() { - Expect(NewStore().Load(&pb.ModelOptions{Model: "any-namespace"})).To(Succeed()) + It("accepts prefixed store namespaces", func() { + Expect(NewStore().Load(&pb.ModelOptions{Model: store.NamespacePrefix + "any-namespace"})).To(Succeed()) + }) + + It("accepts the prefix alone (default store)", func() { + Expect(NewStore().Load(&pb.ModelOptions{Model: store.NamespacePrefix})).To(Succeed()) + }) + + It("refuses model names without the namespace prefix", func() { + err := NewStore().Load(&pb.ModelOptions{Model: "some-llm.gguf"}) + Expect(err).To(MatchError(ContainSubstring("not a store namespace"))) + Expect(NewStore().Load(&pb.ModelOptions{})).NotTo(Succeed()) }) }) diff --git a/backend/go/opus/opus.go b/backend/go/opus/opus.go index 66478d0e2..9f0f58910 100644 --- a/backend/go/opus/opus.go +++ b/backend/go/opus/opus.go @@ -34,7 +34,15 @@ type Opus struct { decoders map[string]*cachedDecoder } +// Load accepts only the codec's own name (what the realtime WebRTC path +// sends) or no name at all — there is no model artefact here, so without +// this check the model loader's greedy autoload (which probes every +// installed backend with real model names) would happily bind an LLM to +// the audio codec. func (o *Opus) Load(opts *pb.ModelOptions) error { + if m := opts.GetModel(); m != "" && m != "opus" { + return fmt.Errorf("opus: refusing to load %q: opus is an audio codec, not a model backend", m) + } o.decoders = make(map[string]*cachedDecoder) go o.evictLoop() return Init() diff --git a/backend/go/opus/opus_test.go b/backend/go/opus/opus_test.go index 0a1a5fb97..27813e572 100644 --- a/backend/go/opus/opus_test.go +++ b/backend/go/opus/opus_test.go @@ -304,6 +304,17 @@ func computeTHD(samples []int16, fundamentalHz float64, sampleRate, numHarmonics // --- Opus specs --- +var _ = Describe("Opus Load", func() { + It("accepts its own name (the realtime WebRTC path)", func() { + Expect((&Opus{}).Load(&pb.ModelOptions{Model: "opus"})).To(Succeed()) + }) + + It("refuses foreign model names from the greedy autoload", func() { + err := (&Opus{}).Load(&pb.ModelOptions{Model: "some-llm.gguf"}) + Expect(err).To(MatchError(ContainSubstring("audio codec"))) + }) +}) + var _ = Describe("Opus", func() { var o *Opus diff --git a/core/backend/stores.go b/core/backend/stores.go index 8b73ee17c..480400f42 100644 --- a/core/backend/stores.go +++ b/core/backend/stores.go @@ -133,10 +133,16 @@ func StoreBackend(sl *model.ModelLoader, appConfig *config.ApplicationConfig, st // Try to add key with length N when existing length is M // Use the store namespace as modelID so each namespace gets its own // process instance and its own in-memory Store{}. + // + // The model name sent over gRPC carries store.NamespacePrefix so the + // backend can tell a genuine store load from the greedy autoload + // probing it with LLM model names; local-store refuses names without + // the prefix (core and backend ship from the same release, so the + // convention upgrades in lockstep). sc := []model.Option{ model.WithBackendString(backend), model.WithModelID(storeName), - model.WithModel(storeName), + model.WithModel(store.NamespacePrefix + storeName), } return sl.Load(sc...) diff --git a/pkg/store/client.go b/pkg/store/client.go index 4fa884b19..3d11ddf7a 100644 --- a/pkg/store/client.go +++ b/pkg/store/client.go @@ -10,6 +10,14 @@ import ( // Wrapper for the GRPC client so that simple use cases are handled without verbosity +// NamespacePrefix marks a load request as a store namespace rather than a +// model artefact. Core's StoreBackend prepends it to the namespace it sends +// as the gRPC model name, and the local-store backend refuses loads without +// it — otherwise the model loader's greedy autoload (which probes every +// installed backend with LLM model names) would bind arbitrary models to +// the vector store, since store loads have no artefact to validate. +const NamespacePrefix = "store://" + // SetCols sets multiple key-value pairs in the store // It's in columnar format so that keys[i] is associated with values[i] func SetCols(ctx context.Context, c grpc.Backend, keys [][]float32, values [][]byte) error { diff --git a/tests/integration/stores_test.go b/tests/integration/stores_test.go index 9b977d4c2..849b4b944 100644 --- a/tests/integration/stores_test.go +++ b/tests/integration/stores_test.go @@ -11,6 +11,7 @@ import ( . "github.com/onsi/gomega" "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/gallery" "github.com/mudler/LocalAI/pkg/grpc" "github.com/mudler/LocalAI/pkg/model" "github.com/mudler/LocalAI/pkg/store" @@ -52,15 +53,20 @@ var _ = Describe("Integration tests for the stores backend(s) and internal APIs" storeOpts := []model.Option{ model.WithBackendString(bc.Backend), - model.WithModel("test"), + model.WithModel(store.NamespacePrefix + "test"), } + // External backends are normally registered at app startup + // (gallery.RegisterBackends); a bare ModelLoader knows none, + // so wire up the BACKENDS_PATH the Makefile builds into. systemState, err := system.GetSystemState( system.WithModelPath(tmpdir), + system.WithBackendPath(os.Getenv("BACKENDS_PATH")), ) Expect(err).ToNot(HaveOccurred()) sl = model.NewModelLoader(systemState) + Expect(gallery.RegisterBackends(systemState, sl)).To(Succeed()) sc, err = sl.Load(storeOpts...) Expect(err).ToNot(HaveOccurred()) Expect(sc).ToNot(BeNil())