mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
fix(config): only inject llama.cpp serving options on the llama.cpp path (#10822)
SetDefaults injected the llama.cpp server options cache_reuse (ApplyServingDefaults) and parallel (ApplyHardwareDefaults, re-applied per selected node by the distributed router) onto every model config regardless of backend. Every other backend ignores options it does not understand, so this was harmless until longcat-video, which strictly validates its options and fails LoadModel with "unknown model option(s): cache_reuse, parallel". Gate both injections behind a new UsesLlamaCppServingOptions allow-list (llama-cpp plus the empty/auto-detect case that resolves to llama.cpp from a GGUF file, mirroring how llamaCppDefaults is registered). This follows the existing UsesLlamaSamplerDefaults precedent for llama-only defaults. The typed NBatch field is deliberately left alone: it is a proto field every backend simply ignores, which is why batch never triggered the error. Also harden the longcat-video backend to warn-and-ignore unknown model options and request params through a testable select_known_options helper, matching the other LocalAI Python backends, so a future server-injected option cannot break loading again. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
@@ -32,6 +32,7 @@ from longcat_utils import (
|
||||
require_bool,
|
||||
require_float,
|
||||
require_int,
|
||||
select_known_options,
|
||||
validate_dimensions,
|
||||
)
|
||||
|
||||
@@ -126,10 +127,17 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
)
|
||||
|
||||
try:
|
||||
options = parse_options(request.Options)
|
||||
unknown = sorted(set(options) - LOAD_OPTIONS)
|
||||
if unknown:
|
||||
raise ValueError(f"unknown model option(s): {', '.join(unknown)}")
|
||||
options, ignored = select_known_options(
|
||||
parse_options(request.Options), LOAD_OPTIONS
|
||||
)
|
||||
if ignored:
|
||||
# The server injects llama.cpp serving defaults (cache_reuse,
|
||||
# parallel) onto every config; ignore what we don't understand
|
||||
# rather than fail to load.
|
||||
print(
|
||||
f"longcat-video ignoring unknown model option(s): {', '.join(ignored)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
self._import_torch()
|
||||
if not self.torch.cuda.is_available():
|
||||
@@ -240,10 +248,14 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
self.pipeline._interrupt = True
|
||||
|
||||
try:
|
||||
params = dict(request.params)
|
||||
unknown = sorted(set(params) - REQUEST_PARAMS)
|
||||
if unknown:
|
||||
raise ValueError(f"unknown request param(s): {', '.join(unknown)}")
|
||||
params, ignored_params = select_known_options(
|
||||
dict(request.params), REQUEST_PARAMS
|
||||
)
|
||||
if ignored_params:
|
||||
print(
|
||||
f"longcat-video ignoring unknown request param(s): {', '.join(ignored_params)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
os.makedirs(os.path.dirname(request.dst) or ".", mode=0o750, exist_ok=True)
|
||||
if hasattr(context, "add_callback"):
|
||||
|
||||
@@ -65,6 +65,23 @@ def parse_options(values):
|
||||
return options
|
||||
|
||||
|
||||
def select_known_options(options, known):
|
||||
"""Split parsed options into the subset this backend understands and the
|
||||
unknown keys to ignore.
|
||||
|
||||
LocalAI injects serving defaults (e.g. the llama.cpp cache_reuse / parallel
|
||||
options) onto every model config regardless of backend. A backend should
|
||||
tolerate options it does not understand rather than refuse to load, matching
|
||||
the other LocalAI Python backends; the caller logs the ignored keys.
|
||||
|
||||
Returns (kept, ignored) where kept preserves the known entries and ignored is
|
||||
the sorted list of dropped keys.
|
||||
"""
|
||||
ignored = sorted(key for key in options if key not in known)
|
||||
kept = {key: value for key, value in options.items() if key in known}
|
||||
return kept, ignored
|
||||
|
||||
|
||||
def require_bool(value, name):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
|
||||
@@ -21,6 +21,7 @@ from longcat_utils import ( # noqa: E402
|
||||
normalize_model_source,
|
||||
normalize_num_frames,
|
||||
parse_options,
|
||||
select_known_options,
|
||||
validate_dimensions,
|
||||
)
|
||||
|
||||
@@ -61,6 +62,26 @@ class LongCatUtilsTest(unittest.TestCase):
|
||||
self.assertEqual(options["source"], "https://example.com/model")
|
||||
self.assertEqual(options["flag"], True)
|
||||
|
||||
def test_select_known_options_keeps_known_and_reports_unknown(self):
|
||||
# The server injects llama.cpp serving defaults (cache_reuse, parallel)
|
||||
# onto every model config. longcat-video must ignore what it does not
|
||||
# understand rather than refuse to load - matching every other backend.
|
||||
options = {
|
||||
"use_distill": True,
|
||||
"max_segments": 4,
|
||||
"cache_reuse": 256,
|
||||
"parallel": 1,
|
||||
}
|
||||
kept, ignored = select_known_options(options, {"use_distill", "max_segments"})
|
||||
|
||||
self.assertEqual(kept, {"use_distill": True, "max_segments": 4})
|
||||
self.assertEqual(ignored, ["cache_reuse", "parallel"])
|
||||
|
||||
def test_select_known_options_reports_nothing_when_all_known(self):
|
||||
kept, ignored = select_known_options({"use_distill": True}, {"use_distill"})
|
||||
self.assertEqual(kept, {"use_distill": True})
|
||||
self.assertEqual(ignored, [])
|
||||
|
||||
def test_classify_model_accepts_only_supported_longcat_models(self):
|
||||
cases = {
|
||||
"meituan-longcat/LongCat-Video": MODEL_KIND_BASE,
|
||||
|
||||
@@ -648,6 +648,27 @@ func UsesLlamaSamplerDefaults(backend string) bool {
|
||||
return !isNonLlama
|
||||
}
|
||||
|
||||
// UsesLlamaCppServingOptions reports whether a backend understands llama.cpp's
|
||||
// serving-tuning model options - the free-form option strings cache_reuse /
|
||||
// n_cache_reuse (cross-request KV-prefix reuse) and parallel / n_parallel
|
||||
// (concurrent slots). These are llama.cpp server flags; LocalAI injects them as
|
||||
// defaults, but a backend that strictly validates its options (e.g.
|
||||
// longcat-video) rejects an unknown one with "unknown model option(s)" at
|
||||
// LoadModel. Only the llama.cpp backend - and the empty/auto-detect case, which
|
||||
// resolves to llama.cpp from a GGUF file, mirroring how llamaCppDefaults is
|
||||
// registered - should receive them.
|
||||
//
|
||||
// This is an allow-list on purpose (unlike UsesLlamaSamplerDefaults's
|
||||
// deny-list): these options are meaningful to no other backend, so a new
|
||||
// backend defaults to NOT getting them rather than breaking the same way.
|
||||
func UsesLlamaCppServingOptions(backend string) bool {
|
||||
switch NormalizeBackendName(backend) {
|
||||
case "", "llama-cpp":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetBackendCapability returns the capability info for a backend, or nil if unknown.
|
||||
// Handles backend name normalization.
|
||||
func GetBackendCapability(backend string) *BackendCapability {
|
||||
|
||||
@@ -336,7 +336,12 @@ func ApplyHardwareDefaults(cfg *ModelConfig, gpu GPU) {
|
||||
// context budget, but a context large enough to fill a single device leaves
|
||||
// no room for the per-slot scratch, so the slot count is gated on per-device
|
||||
// headroom too (issue #10485). Explicit parallel/n_parallel always wins.
|
||||
if before := len(cfg.Options); true {
|
||||
//
|
||||
// parallel is a llama.cpp option string; skip it for backends that would
|
||||
// reject an unknown option (e.g. longcat-video). The typed batch above is a
|
||||
// proto field every backend simply ignores, so it needs no such gate.
|
||||
if UsesLlamaCppServingOptions(cfg.Backend) {
|
||||
before := len(cfg.Options)
|
||||
cfg.Options = EnsureParallelOptionForContext(cfg.Options, gpu, ctx)
|
||||
if len(cfg.Options) > before {
|
||||
xlog.Debug("[hardware_defaults] defaulting parallel slots for concurrent serving",
|
||||
|
||||
@@ -169,5 +169,14 @@ var _ = Describe("Hardware-driven config defaults", func() {
|
||||
ApplyHardwareDefaults(cfg, GPU{VRAM: 119 * gib})
|
||||
Expect(cfg.Options).To(Equal([]string{"parallel:2"}))
|
||||
})
|
||||
It("adds no parallel option for a non-llama backend", func() {
|
||||
// parallel is a llama.cpp server option (n_parallel); a backend that
|
||||
// strictly validates options (e.g. longcat-video) rejects it. Only the
|
||||
// llama.cpp path should receive it, even on a capable GPU.
|
||||
cfg := &ModelConfig{}
|
||||
cfg.Backend = "longcat-video"
|
||||
ApplyHardwareDefaults(cfg, GPU{ComputeCapability: "12.1", VRAM: 119 * gib})
|
||||
Expect(cfg.Options).ToNot(ContainElement(ContainSubstring("parallel")))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,11 @@ func ApplyServingDefaults(cfg *ModelConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
// cache_reuse is a llama.cpp server option; a backend that strictly
|
||||
// validates its options rejects it. Only inject it on the llama.cpp path.
|
||||
if !UsesLlamaCppServingOptions(cfg.Backend) {
|
||||
return
|
||||
}
|
||||
if !backendOptionSet(cfg.Options, "cache_reuse", "n_cache_reuse") {
|
||||
cfg.Options = append(cfg.Options, fmt.Sprintf("cache_reuse:%d", DefaultCacheReuse))
|
||||
xlog.Debug("[serving_defaults] enabling cross-request prefix cache",
|
||||
|
||||
@@ -26,5 +26,20 @@ var _ = Describe("Serving-policy config defaults", func() {
|
||||
It("no-ops on nil", func() {
|
||||
Expect(func() { ApplyServingDefaults(nil) }).ToNot(Panic())
|
||||
})
|
||||
It("does not enable cache_reuse for a non-llama backend", func() {
|
||||
// cache_reuse is a llama.cpp server option (n_cache_reuse). Backends
|
||||
// that strictly validate their options (e.g. longcat-video) reject it
|
||||
// with "unknown model option(s)". Only the llama.cpp path gets it.
|
||||
cfg := &ModelConfig{}
|
||||
cfg.Backend = "longcat-video"
|
||||
ApplyServingDefaults(cfg)
|
||||
Expect(cfg.Options).ToNot(ContainElement(ContainSubstring("cache_reuse")))
|
||||
})
|
||||
It("still enables cache_reuse for an explicit llama-cpp backend", func() {
|
||||
cfg := &ModelConfig{}
|
||||
cfg.Backend = "llama-cpp"
|
||||
ApplyServingDefaults(cfg)
|
||||
Expect(cfg.Options).To(ContainElement("cache_reuse:256"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -177,7 +177,7 @@ type scheduleLoadResult struct {
|
||||
// an upstream host (the GPU-less frontend in distributed mode) guessed higher.
|
||||
// Only values the heuristics themselves manage are touched, so an explicit user
|
||||
// batch (e.g. 1024) is never overridden.
|
||||
func applyNodeHardwareDefaults(opts *pb.ModelOptions, node *BackendNode) {
|
||||
func applyNodeHardwareDefaults(opts *pb.ModelOptions, node *BackendNode, backend string) {
|
||||
if opts == nil || node == nil || config.HardwareDefaultsDisabled() {
|
||||
return
|
||||
}
|
||||
@@ -196,8 +196,12 @@ func applyNodeHardwareDefaults(opts *pb.ModelOptions, node *BackendNode) {
|
||||
// the options may have no GPU). Gated on the node's per-device VRAM at this
|
||||
// model's context, so a large context that already fills the device can't
|
||||
// tip it into OOM by adding slot scratch (issue #10485). Only adds when no
|
||||
// parallel option is set.
|
||||
opts.Options = config.EnsureParallelOptionForContext(opts.Options, gpu, int(opts.ContextSize))
|
||||
// parallel option is set. parallel is a llama.cpp option string, so it is
|
||||
// also gated by backend: a strict backend (e.g. longcat-video) rejects an
|
||||
// unknown option at LoadModel. The typed NBatch above needs no such gate.
|
||||
if config.UsesLlamaCppServingOptions(backend) {
|
||||
opts.Options = config.EnsureParallelOptionForContext(opts.Options, gpu, int(opts.ContextSize))
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleAndLoad is the shared core for loading a model on a new node.
|
||||
@@ -218,7 +222,7 @@ func (r *SmartRouter) scheduleAndLoad(ctx context.Context, backendType, tracking
|
||||
// Tune node-agnostic options to the SELECTED node's GPU. Only now do we know
|
||||
// which node (and its compute capability) will run the model — the frontend
|
||||
// that built modelOpts may have no GPU at all in distributed mode.
|
||||
applyNodeHardwareDefaults(modelOpts, node)
|
||||
applyNodeHardwareDefaults(modelOpts, node, backendType)
|
||||
|
||||
// Pre-stage model files via FileStager before loading
|
||||
loadOpts := modelOpts
|
||||
|
||||
@@ -10,34 +10,34 @@ import (
|
||||
var _ = Describe("applyNodeHardwareDefaults", func() {
|
||||
It("raises a managed default batch on a Blackwell node with headroom", func() {
|
||||
opts := &pb.ModelOptions{NBatch: config.DefaultPhysicalBatch, ContextSize: 8192}
|
||||
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "12.1", TotalVRAM: 119 << 30})
|
||||
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "12.1", TotalVRAM: 119 << 30}, "llama-cpp")
|
||||
Expect(opts.NBatch).To(BeEquivalentTo(config.BlackwellPhysicalBatch))
|
||||
})
|
||||
|
||||
It("keeps the default batch when a large context would overflow the node", func() {
|
||||
// Regression guard for issue #10485 on the distributed path.
|
||||
opts := &pb.ModelOptions{NBatch: config.DefaultPhysicalBatch, ContextSize: 204800}
|
||||
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "12.0", TotalVRAM: 16 << 30})
|
||||
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "12.0", TotalVRAM: 16 << 30}, "llama-cpp")
|
||||
Expect(opts.NBatch).To(BeEquivalentTo(config.DefaultPhysicalBatch))
|
||||
})
|
||||
|
||||
It("resets a Blackwell guess on a non-Blackwell node", func() {
|
||||
// frontend (Blackwell) guessed high, but the selected node is not Blackwell
|
||||
opts := &pb.ModelOptions{NBatch: config.BlackwellPhysicalBatch}
|
||||
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "9.0"})
|
||||
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "9.0"}, "llama-cpp")
|
||||
Expect(opts.NBatch).To(BeEquivalentTo(config.DefaultPhysicalBatch))
|
||||
})
|
||||
|
||||
It("never overrides an explicit (non-managed) batch", func() {
|
||||
opts := &pb.ModelOptions{NBatch: 1024}
|
||||
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "12.1"})
|
||||
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "12.1"}, "llama-cpp")
|
||||
Expect(opts.NBatch).To(BeEquivalentTo(int32(1024)))
|
||||
})
|
||||
|
||||
It("adds a VRAM-scaled parallel option for the selected node", func() {
|
||||
// frontend may have had no GPU (no parallel option); the node has a big GPU
|
||||
opts := &pb.ModelOptions{NBatch: config.DefaultPhysicalBatch}
|
||||
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "12.1", TotalVRAM: 119 << 30})
|
||||
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "12.1", TotalVRAM: 119 << 30}, "llama-cpp")
|
||||
Expect(opts.Options).To(ContainElement("parallel:8"))
|
||||
})
|
||||
|
||||
@@ -45,17 +45,26 @@ var _ = Describe("applyNodeHardwareDefaults", func() {
|
||||
// Regression guard for issue #10485: a 16 GiB node with a ~200k context
|
||||
// is a tight single-model fit — the slot scratch would tip it into OOM.
|
||||
opts := &pb.ModelOptions{NBatch: config.DefaultPhysicalBatch, ContextSize: 204800}
|
||||
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "12.0", TotalVRAM: 16 << 30})
|
||||
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "12.0", TotalVRAM: 16 << 30}, "llama-cpp")
|
||||
Expect(opts.Options).ToNot(ContainElement(ContainSubstring("parallel")))
|
||||
})
|
||||
|
||||
It("never overrides an explicit parallel option on the node path", func() {
|
||||
opts := &pb.ModelOptions{NBatch: config.DefaultPhysicalBatch, Options: []string{"parallel:2"}}
|
||||
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "12.1", TotalVRAM: 119 << 30})
|
||||
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "12.1", TotalVRAM: 119 << 30}, "llama-cpp")
|
||||
Expect(opts.Options).To(Equal([]string{"parallel:2"}))
|
||||
})
|
||||
|
||||
It("adds no parallel option for a non-llama backend on the node path", func() {
|
||||
// parallel is a llama.cpp option string; a backend that strictly validates
|
||||
// options (e.g. longcat-video) rejects it. The node-tuning path must gate
|
||||
// it by backend just like the single-host config path does.
|
||||
opts := &pb.ModelOptions{NBatch: config.DefaultPhysicalBatch}
|
||||
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "12.1", TotalVRAM: 119 << 30}, "longcat-video")
|
||||
Expect(opts.Options).ToNot(ContainElement(ContainSubstring("parallel")))
|
||||
})
|
||||
|
||||
It("no-ops on nil inputs", func() {
|
||||
Expect(func() { applyNodeHardwareDefaults(nil, nil) }).ToNot(Panic())
|
||||
Expect(func() { applyNodeHardwareDefaults(nil, nil, "llama-cpp") }).ToNot(Panic())
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user