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]
This commit is contained in:
Ettore Di Giacinto
2026-07-17 12:12:59 +00:00
committed by localai-org-maint-bot
parent fddd5558a0
commit 3c129686a9
3 changed files with 69 additions and 24 deletions

View File

@@ -3,6 +3,8 @@ package config
import (
"slices"
"strings"
"github.com/mudler/LocalAI/pkg/model"
)
// Usecase name constants — the canonical string values used in gallery entries,
@@ -718,6 +720,42 @@ func GetBackendCapability(backend string) *BackendCapability {
return nil
}
// llmAutoLoadUsecases are the usecases that mark a backend able to serve a
// text/LLM GGUF model. A GGUF model that declares no explicit backend must only
// be auto-tried against backends carrying one of these usecases - never against
// audio/codec/image backends (e.g. opus) that happen to be installed alongside
// it (see issue #9287).
var llmAutoLoadUsecases = []string{
UsecaseChat,
UsecaseCompletion,
UsecaseEdit,
UsecaseEmbeddings,
}
// isLLMCapableForAutoLoad reports whether the named backend is known to serve
// text/LLM models, for pkg/model's GGUF backend auto-detection (#9287). Backends
// absent from the capability table are treated as not LLM-capable.
func isLLMCapableForAutoLoad(name string) bool {
capability := GetBackendCapability(name)
if capability == nil {
return false
}
for _, u := range capability.PossibleUsecases {
if slices.Contains(llmAutoLoadUsecases, u) {
return true
}
}
return false
}
func init() {
// Wire the LLM-capability filter into pkg/model's GGUF backend
// auto-detection. pkg/model is a lower-level package and must not import
// core/config (that would form a core/config -> pkg/model -> core/config
// import cycle), so core/config registers the predicate here instead (#9287).
model.RegisterLLMCapableBackendFunc(isLLMCapableForAutoLoad)
}
// VoiceCloningForModel returns the reference-audio contract only when the
// installed model variant can honor it. Several backends serve both Base
// (voice cloning) and CustomVoice/VoiceDesign models, so backend name alone is

View File

@@ -1,27 +1,28 @@
package model
import (
"slices"
"sort"
"strings"
"github.com/mudler/LocalAI/core/config"
)
// preferredGGUFBackend is tried first when auto-detecting the backend for a
// GGUF model, since GGUF is overwhelmingly llama.cpp's native format.
const preferredGGUFBackend = "llama-cpp"
// llmCapableUsecases are the BackendCapabilities usecases that signal a backend
// can serve a text/LLM GGUF model. A GGUF model that declares no explicit
// backend must only be auto-tried against backends carrying one of these
// usecases - never against audio/codec/image backends (e.g. opus) that happen
// to be installed alongside it (see issue #9287).
var llmCapableUsecases = []string{
config.UsecaseChat,
config.UsecaseCompletion,
config.UsecaseEdit,
config.UsecaseEmbeddings,
// llmCapableBackend reports whether the named backend can serve a text/LLM GGUF
// model. The backend capability table lives in core/config, which is a
// higher-level package that already imports pkg/model; importing it back here
// would form a core/config -> pkg/model -> core/config cycle. So core/config
// registers the predicate via RegisterLLMCapableBackendFunc instead (see #9287).
// When unset (e.g. a build that never imports core/config) GGUF capability
// filtering is skipped and auto-detect falls back to the deterministic set.
var llmCapableBackend func(name string) bool
// RegisterLLMCapableBackendFunc wires the LLM-capability predicate used by
// SelectAutoLoadBackends. It is called from core/config's init so pkg/model
// need not import core/config (see #9287).
func RegisterLLMCapableBackendFunc(fn func(name string) bool) {
llmCapableBackend = fn
}
// SelectAutoLoadBackends returns the ordered, deterministic list of backend
@@ -52,6 +53,12 @@ func SelectAutoLoadBackends(available []string, modelFile string) []string {
return sorted
}
// No capability predicate wired (core/config not linked in): skip filtering
// rather than risk dropping a valid candidate.
if llmCapableBackend == nil {
return sorted
}
filtered := make([]string, 0, len(sorted))
hasLlama := false
for _, b := range sorted {
@@ -84,16 +91,7 @@ func isGGUFModelFile(modelFile string) bool {
// models. Backends absent from the capability map (unknown) are treated as
// not LLM-capable here: for GGUF auto-detection we only want backends we can
// positively confirm handle LLMs, and the zero-candidate fallback keeps unknown
// setups working.
// setups working. Callers must ensure llmCapableBackend is non-nil.
func isLLMCapableBackend(name string) bool {
capability := config.GetBackendCapability(name)
if capability == nil {
return false
}
for _, u := range capability.PossibleUsecases {
if slices.Contains(llmCapableUsecases, u) {
return true
}
}
return false
return llmCapableBackend(name)
}

View File

@@ -7,6 +7,15 @@ import (
. "github.com/onsi/gomega"
)
func init() {
// The real LLM-capability table lives in core/config, which pkg/model must
// not import (cycle). Register a fake predicate so the selection algorithm
// is exercised here independent of the capability table (#9287).
model.RegisterLLMCapableBackendFunc(func(name string) bool {
return name == "llama-cpp" || name == "vllm"
})
}
var _ = Describe("SelectAutoLoadBackends (#9287)", func() {
Describe("GGUF model auto-detection", func() {
It("excludes incompatible audio/codec backends (e.g. opus) for a .gguf model", func() {