Skip to content

Commit 69e482b

Browse files
committed
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>
1 parent 51f4f67 commit 69e482b

3 files changed

Lines changed: 153 additions & 5 deletions

File tree

pkg/model/autoload.go

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

pkg/model/autoload_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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+
var _ = Describe("SelectAutoLoadBackends (#9287)", func() {
11+
Describe("GGUF model auto-detection", func() {
12+
It("excludes incompatible audio/codec backends (e.g. opus) for a .gguf model", func() {
13+
// Regression for #9287: installing an unrelated audio backend like
14+
// "opus" must never win the GGUF auto-detect trial loop.
15+
got := model.SelectAutoLoadBackends([]string{"opus", "llama-cpp"}, "Qwen3.5-9b.gguf")
16+
Expect(got).NotTo(ContainElement("opus"))
17+
Expect(got).To(ContainElement("llama-cpp"))
18+
})
19+
20+
It("places llama-cpp first for a .gguf model", func() {
21+
got := model.SelectAutoLoadBackends([]string{"vllm", "opus", "llama-cpp"}, "model.gguf")
22+
Expect(got).NotTo(BeEmpty())
23+
Expect(got[0]).To(Equal("llama-cpp"))
24+
})
25+
26+
It("is deterministic regardless of input ordering", func() {
27+
a := model.SelectAutoLoadBackends([]string{"opus", "vllm", "llama-cpp", "whisper"}, "m.gguf")
28+
b := model.SelectAutoLoadBackends([]string{"whisper", "llama-cpp", "vllm", "opus"}, "m.gguf")
29+
Expect(a).To(Equal(b))
30+
})
31+
32+
It("falls back to the full sorted set when filtering leaves no candidate", func() {
33+
// No LLM-capable backend installed: never make a previously-loadable
34+
// model unloadable, return the original set (sorted).
35+
got := model.SelectAutoLoadBackends([]string{"opus"}, "model.gguf")
36+
Expect(got).To(Equal([]string{"opus"}))
37+
})
38+
})
39+
40+
Describe("non-GGUF model auto-detection", func() {
41+
It("returns a deterministic (sorted) set without filtering", func() {
42+
got := model.SelectAutoLoadBackends([]string{"opus", "llama-cpp", "diffusers"}, "model-dir")
43+
Expect(got).To(Equal([]string{"diffusers", "llama-cpp", "opus"}))
44+
})
45+
})
46+
})

pkg/model/initializers.go

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

353-
// get backends embedded in the binary
354-
autoLoadBackends := []string{}
355-
356-
// append externalBackends supplied by the user via the CLI
353+
// Collect the installed/external backends (the map is unordered).
354+
available := []string{}
357355
for b := range ml.GetAllExternalBackends(o) {
358-
autoLoadBackends = append(autoLoadBackends, b)
356+
available = append(available, b)
359357
}
360358

359+
// Build a deterministic, file-type-filtered candidate list so an
360+
// incompatible backend (e.g. an audio codec like opus) can never win the
361+
// trial loop for a GGUF/LLM model. See SelectAutoLoadBackends / #9287.
362+
autoLoadBackends := SelectAutoLoadBackends(available, o.model)
363+
361364
if len(autoLoadBackends) == 0 {
362365
xlog.Error("No backends found")
363366
return nil, fmt.Errorf("no backends found")

0 commit comments

Comments
 (0)