mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
fix(backends): refuse foreign model loads in opus and local-store (#10769)
When a model config has no explicit backend, the model loader greedily probes every installed backend and binds to the first Load that succeeds. opus and local-store were the only in-tree backends with no model artefact to validate, so they accepted anything — an LLM installed after them could silently bind to the audio codec or the vector store and then fail at inference with "unimplemented" (see #9287). opus now accepts only its own name (what the realtime WebRTC path sends) or none. local-store namespaces are arbitrary (router caches, biometrics, user-named stores), so core's StoreBackend now marks genuine store loads with a store:// prefix on the gRPC model name and the backend refuses names without it; core and backend ship from the same release, so the convention upgrades in lockstep. Also repair the bit-rotted 'make test-stores' bootstrap (the suite never registered external backends, so BACKENDS_PATH was dead weight) and add the Load-validation rule to the adding-backends checklist. Related: #9287 Assisted-by: Claude:claude-fable-5 golangci-lint Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
committed by
GitHub
parent
23a044ee0b
commit
1f9fda7138
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user