Skip to content

Commit 7d8e0ba

Browse files
localai-botmudlerlocalai-org-maint-bot
authored
fix(model): deterministic, type-filtered backend auto-detection (#9287) (#10286)
* fix(model): deterministic, file-type-filtered backend auto-detect (#9287) When a model config declares no explicit `backend:`, Load() fell into a trial loop built by ranging the external-backends Go map (random order) with no filtering, returning the first backend whose gRPC LoadModel succeeded. An unrelated installed backend - e.g. the "opus" audio codec - could therefore win a GGUF/LLM model load, so a model that should run on llama.cpp wrongly tried to use opus. Extract the candidate selection into a pure, testable function SelectAutoLoadBackends that: - sorts the candidate list deterministically (no more map-order nondeterminism), and - for a `.gguf` model, filters to LLM-capable backends (via core/config.BackendCapabilities) and puts llama-cpp first, so an incompatible audio/codec/image backend can never win the trial loop. If filtering would leave zero candidates, the full sorted set is returned unchanged, so a previously-loadable model is never made unloadable. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(model): break core/config <-> pkg/model import cycle in backend auto-detect The #9287 auto-detect change made pkg/model/autoload.go import core/config for the backend capability table. core/config already imports pkg/model (runtime_settings_registry.go uses model.DefaultWatchdogInterval), so this closed a core/config -> pkg/model -> core/config import cycle and broke the build and golangci-lint. Invert the dependency so the lower-level pkg/model no longer imports the higher-level core/config. pkg/model exposes RegisterLLMCapableBackendFunc and uses the registered predicate; core/config (which owns the capability table) registers it from an init(). The deterministic, GGUF-type-filtered selection behaviour is unchanged. When the predicate is unwired the GGUF filter is skipped, preserving the existing zero-candidate fallback. The unit test now injects a fake capability predicate so SelectAutoLoadBackends is exercised independently of the core/config table. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:opus-4.8 [Claude Code] --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
1 parent 37f2087 commit 7d8e0ba

4 files changed

Lines changed: 198 additions & 5 deletions

File tree

core/config/backend_capabilities.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package config
33
import (
44
"slices"
55
"strings"
6+
7+
"github.com/mudler/LocalAI/pkg/model"
68
)
79

810
// Usecase name constants — the canonical string values used in gallery entries,
@@ -740,6 +742,42 @@ func GetBackendCapability(backend string) *BackendCapability {
740742
return nil
741743
}
742744

745+
// llmAutoLoadUsecases are the usecases that mark a backend able to serve a
746+
// text/LLM GGUF model. A GGUF model that declares no explicit backend must only
747+
// be auto-tried against backends carrying one of these usecases - never against
748+
// audio/codec/image backends (e.g. opus) that happen to be installed alongside
749+
// it (see issue #9287).
750+
var llmAutoLoadUsecases = []string{
751+
UsecaseChat,
752+
UsecaseCompletion,
753+
UsecaseEdit,
754+
UsecaseEmbeddings,
755+
}
756+
757+
// isLLMCapableForAutoLoad reports whether the named backend is known to serve
758+
// text/LLM models, for pkg/model's GGUF backend auto-detection (#9287). Backends
759+
// absent from the capability table are treated as not LLM-capable.
760+
func isLLMCapableForAutoLoad(name string) bool {
761+
capability := GetBackendCapability(name)
762+
if capability == nil {
763+
return false
764+
}
765+
for _, u := range capability.PossibleUsecases {
766+
if slices.Contains(llmAutoLoadUsecases, u) {
767+
return true
768+
}
769+
}
770+
return false
771+
}
772+
773+
func init() {
774+
// Wire the LLM-capability filter into pkg/model's GGUF backend
775+
// auto-detection. pkg/model is a lower-level package and must not import
776+
// core/config (that would form a core/config -> pkg/model -> core/config
777+
// import cycle), so core/config registers the predicate here instead (#9287).
778+
model.RegisterLLMCapableBackendFunc(isLLMCapableForAutoLoad)
779+
}
780+
743781
// VoiceCloningForModel returns the reference-audio contract only when the
744782
// installed model variant can honor it. Several backends serve both Base
745783
// (voice cloning) and CustomVoice/VoiceDesign models, so backend name alone is

pkg/model/autoload.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package model
2+
3+
import (
4+
"sort"
5+
"strings"
6+
)
7+
8+
// preferredGGUFBackend is tried first when auto-detecting the backend for a
9+
// GGUF model, since GGUF is overwhelmingly llama.cpp's native format.
10+
const preferredGGUFBackend = "llama-cpp"
11+
12+
// llmCapableBackend reports whether the named backend can serve a text/LLM GGUF
13+
// model. The backend capability table lives in core/config, which is a
14+
// higher-level package that already imports pkg/model; importing it back here
15+
// would form a core/config -> pkg/model -> core/config cycle. So core/config
16+
// registers the predicate via RegisterLLMCapableBackendFunc instead (see #9287).
17+
// When unset (e.g. a build that never imports core/config) GGUF capability
18+
// filtering is skipped and auto-detect falls back to the deterministic set.
19+
var llmCapableBackend func(name string) bool
20+
21+
// RegisterLLMCapableBackendFunc wires the LLM-capability predicate used by
22+
// SelectAutoLoadBackends. It is called from core/config's init so pkg/model
23+
// need not import core/config (see #9287).
24+
func RegisterLLMCapableBackendFunc(fn func(name string) bool) {
25+
llmCapableBackend = fn
26+
}
27+
28+
// SelectAutoLoadBackends returns the ordered, deterministic list of backend
29+
// names to try when loading a model that declares no explicit backend.
30+
//
31+
// available is the set of installed backend names (unordered, as it comes from a
32+
// Go map). modelFile is the model file name/path (may be empty).
33+
//
34+
// The trial loop in (*ModelLoader).Load picks the first backend whose gRPC
35+
// LoadModel succeeds, so the order and membership of this list directly decide
36+
// which backend wins. The previous implementation ranged a Go map (random
37+
// order) with no filtering, so an unrelated installed backend such as the
38+
// "opus" audio codec could win a GGUF/LLM model load (#9287).
39+
//
40+
// Behaviour:
41+
// - The result is always deterministically ordered, so auto-detect no longer
42+
// depends on map iteration order.
43+
// - For a GGUF model file the list is filtered to LLM-capable backends and
44+
// llama-cpp is placed first, so an incompatible audio/codec/image backend
45+
// can never win the trial loop.
46+
// - If filtering would leave no candidate, the full sorted set is returned
47+
// instead, so a model that previously loaded never becomes unloadable.
48+
func SelectAutoLoadBackends(available []string, modelFile string) []string {
49+
sorted := append([]string(nil), available...)
50+
sort.Strings(sorted)
51+
52+
if !isGGUFModelFile(modelFile) {
53+
return sorted
54+
}
55+
56+
// No capability predicate wired (core/config not linked in): skip filtering
57+
// rather than risk dropping a valid candidate.
58+
if llmCapableBackend == nil {
59+
return sorted
60+
}
61+
62+
filtered := make([]string, 0, len(sorted))
63+
hasLlama := false
64+
for _, b := range sorted {
65+
if b == preferredGGUFBackend {
66+
hasLlama = true
67+
continue // added explicitly first below
68+
}
69+
if isLLMCapableBackend(b) {
70+
filtered = append(filtered, b)
71+
}
72+
}
73+
if hasLlama {
74+
filtered = append([]string{preferredGGUFBackend}, filtered...)
75+
}
76+
77+
if len(filtered) == 0 {
78+
// Conservative fallback: no known LLM-capable backend is installed, so
79+
// rather than refuse to load, fall back to the previous behaviour of
80+
// trying every installed backend (now at least in a deterministic order).
81+
return sorted
82+
}
83+
return filtered
84+
}
85+
86+
func isGGUFModelFile(modelFile string) bool {
87+
return strings.HasSuffix(strings.ToLower(modelFile), ".gguf")
88+
}
89+
90+
// isLLMCapableBackend reports whether a backend is known to serve text/LLM
91+
// models. Backends absent from the capability map (unknown) are treated as
92+
// not LLM-capable here: for GGUF auto-detection we only want backends we can
93+
// positively confirm handle LLMs, and the zero-candidate fallback keeps unknown
94+
// setups working. Callers must ensure llmCapableBackend is non-nil.
95+
func isLLMCapableBackend(name string) bool {
96+
return llmCapableBackend(name)
97+
}

pkg/model/autoload_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package model_test
2+
3+
import (
4+
"github.com/mudler/LocalAI/pkg/model"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
)
9+
10+
func init() {
11+
// The real LLM-capability table lives in core/config, which pkg/model must
12+
// not import (cycle). Register a fake predicate so the selection algorithm
13+
// is exercised here independent of the capability table (#9287).
14+
model.RegisterLLMCapableBackendFunc(func(name string) bool {
15+
return name == "llama-cpp" || name == "vllm"
16+
})
17+
}
18+
19+
var _ = Describe("SelectAutoLoadBackends (#9287)", func() {
20+
Describe("GGUF model auto-detection", func() {
21+
It("excludes incompatible audio/codec backends (e.g. opus) for a .gguf model", func() {
22+
// Regression for #9287: installing an unrelated audio backend like
23+
// "opus" must never win the GGUF auto-detect trial loop.
24+
got := model.SelectAutoLoadBackends([]string{"opus", "llama-cpp"}, "Qwen3.5-9b.gguf")
25+
Expect(got).NotTo(ContainElement("opus"))
26+
Expect(got).To(ContainElement("llama-cpp"))
27+
})
28+
29+
It("places llama-cpp first for a .gguf model", func() {
30+
got := model.SelectAutoLoadBackends([]string{"vllm", "opus", "llama-cpp"}, "model.gguf")
31+
Expect(got).NotTo(BeEmpty())
32+
Expect(got[0]).To(Equal("llama-cpp"))
33+
})
34+
35+
It("is deterministic regardless of input ordering", func() {
36+
a := model.SelectAutoLoadBackends([]string{"opus", "vllm", "llama-cpp", "whisper"}, "m.gguf")
37+
b := model.SelectAutoLoadBackends([]string{"whisper", "llama-cpp", "vllm", "opus"}, "m.gguf")
38+
Expect(a).To(Equal(b))
39+
})
40+
41+
It("falls back to the full sorted set when filtering leaves no candidate", func() {
42+
// No LLM-capable backend installed: never make a previously-loadable
43+
// model unloadable, return the original set (sorted).
44+
got := model.SelectAutoLoadBackends([]string{"opus"}, "model.gguf")
45+
Expect(got).To(Equal([]string{"opus"}))
46+
})
47+
})
48+
49+
Describe("non-GGUF model auto-detection", func() {
50+
It("returns a deterministic (sorted) set without filtering", func() {
51+
got := model.SelectAutoLoadBackends([]string{"opus", "llama-cpp", "diffusers"}, "model-dir")
52+
Expect(got).To(Equal([]string{"diffusers", "llama-cpp", "opus"}))
53+
})
54+
})
55+
})

pkg/model/initializers.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -441,14 +441,17 @@ func (ml *ModelLoader) Load(opts ...Option) (grpc.Backend, error) {
441441
// Otherwise scan for backends in the asset directory
442442
var err error
443443

444-
// get backends embedded in the binary
445-
autoLoadBackends := []string{}
446-
447-
// append externalBackends supplied by the user via the CLI
444+
// Collect the installed/external backends (the map is unordered).
445+
available := []string{}
448446
for b := range ml.GetAllExternalBackends(o) {
449-
autoLoadBackends = append(autoLoadBackends, b)
447+
available = append(available, b)
450448
}
451449

450+
// Build a deterministic, file-type-filtered candidate list so an
451+
// incompatible backend (e.g. an audio codec like opus) can never win the
452+
// trial loop for a GGUF/LLM model. See SelectAutoLoadBackends / #9287.
453+
autoLoadBackends := SelectAutoLoadBackends(available, o.model)
454+
452455
if len(autoLoadBackends) == 0 {
453456
xlog.Error("No backends found")
454457
return nil, fmt.Errorf("no backends found")

0 commit comments

Comments
 (0)