Skip to content

Commit 3c12968

Browse files
mudlerlocalai-org-maint-bot
authored andcommitted
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]
1 parent fddd555 commit 3c12968

3 files changed

Lines changed: 69 additions & 24 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,
@@ -718,6 +720,42 @@ func GetBackendCapability(backend string) *BackendCapability {
718720
return nil
719721
}
720722

723+
// llmAutoLoadUsecases are the usecases that mark a backend able to serve a
724+
// text/LLM GGUF model. A GGUF model that declares no explicit backend must only
725+
// be auto-tried against backends carrying one of these usecases - never against
726+
// audio/codec/image backends (e.g. opus) that happen to be installed alongside
727+
// it (see issue #9287).
728+
var llmAutoLoadUsecases = []string{
729+
UsecaseChat,
730+
UsecaseCompletion,
731+
UsecaseEdit,
732+
UsecaseEmbeddings,
733+
}
734+
735+
// isLLMCapableForAutoLoad reports whether the named backend is known to serve
736+
// text/LLM models, for pkg/model's GGUF backend auto-detection (#9287). Backends
737+
// absent from the capability table are treated as not LLM-capable.
738+
func isLLMCapableForAutoLoad(name string) bool {
739+
capability := GetBackendCapability(name)
740+
if capability == nil {
741+
return false
742+
}
743+
for _, u := range capability.PossibleUsecases {
744+
if slices.Contains(llmAutoLoadUsecases, u) {
745+
return true
746+
}
747+
}
748+
return false
749+
}
750+
751+
func init() {
752+
// Wire the LLM-capability filter into pkg/model's GGUF backend
753+
// auto-detection. pkg/model is a lower-level package and must not import
754+
// core/config (that would form a core/config -> pkg/model -> core/config
755+
// import cycle), so core/config registers the predicate here instead (#9287).
756+
model.RegisterLLMCapableBackendFunc(isLLMCapableForAutoLoad)
757+
}
758+
721759
// VoiceCloningForModel returns the reference-audio contract only when the
722760
// installed model variant can honor it. Several backends serve both Base
723761
// (voice cloning) and CustomVoice/VoiceDesign models, so backend name alone is

pkg/model/autoload.go

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,28 @@
11
package model
22

33
import (
4-
"slices"
54
"sort"
65
"strings"
7-
8-
"github.com/mudler/LocalAI/core/config"
96
)
107

118
// preferredGGUFBackend is tried first when auto-detecting the backend for a
129
// GGUF model, since GGUF is overwhelmingly llama.cpp's native format.
1310
const preferredGGUFBackend = "llama-cpp"
1411

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,
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
2526
}
2627

2728
// SelectAutoLoadBackends returns the ordered, deterministic list of backend
@@ -52,6 +53,12 @@ func SelectAutoLoadBackends(available []string, modelFile string) []string {
5253
return sorted
5354
}
5455

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+
5562
filtered := make([]string, 0, len(sorted))
5663
hasLlama := false
5764
for _, b := range sorted {
@@ -84,16 +91,7 @@ func isGGUFModelFile(modelFile string) bool {
8491
// models. Backends absent from the capability map (unknown) are treated as
8592
// not LLM-capable here: for GGUF auto-detection we only want backends we can
8693
// positively confirm handle LLMs, and the zero-candidate fallback keeps unknown
87-
// setups working.
94+
// setups working. Callers must ensure llmCapableBackend is non-nil.
8895
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
96+
return llmCapableBackend(name)
9997
}

pkg/model/autoload_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ import (
77
. "github.com/onsi/gomega"
88
)
99

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+
1019
var _ = Describe("SelectAutoLoadBackends (#9287)", func() {
1120
Describe("GGUF model auto-detection", func() {
1221
It("excludes incompatible audio/codec backends (e.g. opus) for a .gguf model", func() {

0 commit comments

Comments
 (0)