mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-04 12:22:22 -04:00
The engine resolves speculative_config.model against a directory containing config.json, or against ~/.cache/huggingface/hub/models--<org>--<repo>/ snapshots/*, and it never downloads. LocalAI keeps models in its own directory, so the repo-id spelling the vLLM docs teach - "z-lab/Qwen3.6-27B-DFlash" - misses the HF cache and dies deep inside the load with "draft checkpoint not found", which reads like a broken checkpoint rather than a model nobody fetched. Resolve it before the load call: the reference as given, then its last path segment under LocalAI's models dir (what LocalAI's own downloader produces), then the whole reference under the models dir. When none resolve, fail there naming both what was asked for and every location tried, so the message says what to do about it. mtp and ngram pass through untouched - neither has a separate draft checkpoint. A speculative_config that does not parse also passes through, because the engine owns config validation and produces the better error. Docs also gain the two limits that were missing and are easy to lose an afternoon to: speculation is Qwen3.5/3.6-only at this engine pin regardless of format, and mtp/dflash need a safetensors target. The latter is a gap in the engine's GGUF loader rather than a property of GGUF - the format carries MTP weights fine, llama.cpp reads them as nextn.* tensors plus a <arch>.nextn_predict_layers key - so the docs say that rather than implying GGUF cannot express it. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]
282 lines
8.5 KiB
Go
282 lines
8.5 KiB
Go
package main
|
|
|
|
// LocalAI gRPC backend over the vllm.cpp C ABI.
|
|
//
|
|
// Predict maps to the blocking vllm_complete; PredictStream maps to
|
|
// vllm_complete_stream, whose per-delta C callback bridges into the gRPC
|
|
// stream channel. Concurrent calls are intentional: every completion entry
|
|
// point submits into the engine's shared AsyncLLM scheduler, so parallel
|
|
// LocalAI requests batch continuously inside the engine (the reason this
|
|
// backend embeds base.Base and not base.SingleThread).
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"unsafe"
|
|
|
|
"github.com/ebitengine/purego"
|
|
"github.com/mudler/LocalAI/pkg/grpc/base"
|
|
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
type VllmCpp struct {
|
|
base.Base
|
|
|
|
engine uintptr
|
|
opts loadOptions
|
|
}
|
|
|
|
// Stream registry: the per-request bridge between the C token callback and
|
|
// the gRPC stream channel, keyed by an integer handle round-tripped through
|
|
// the C user_data pointer (never a Go pointer across the ABI). The host gRPC
|
|
// server drains the channel even after a client disconnect, so sends here
|
|
// cannot wedge the engine's delivery loop.
|
|
var (
|
|
streamsMu sync.Mutex
|
|
streams = map[uintptr]chan string{}
|
|
streamNext uintptr
|
|
tokenCbOnce sync.Once
|
|
tokenCbPtr uintptr
|
|
)
|
|
|
|
// tokenCallback is the single C-shared callback for every stream; it
|
|
// dispatches on the user_data handle. Returning 0 aborts the in-flight
|
|
// request (vllm_token_callback contract).
|
|
func tokenCallback(delta uintptr, finished uintptr, userData uintptr) uintptr {
|
|
streamsMu.Lock()
|
|
results := streams[userData]
|
|
streamsMu.Unlock()
|
|
if results == nil {
|
|
return 0 // unknown request: stop generation.
|
|
}
|
|
if text := goString(delta); text != "" {
|
|
results <- text
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func registerStream(results chan string) uintptr {
|
|
streamsMu.Lock()
|
|
defer streamsMu.Unlock()
|
|
streamNext++
|
|
streams[streamNext] = results
|
|
return streamNext
|
|
}
|
|
|
|
func unregisterStream(h uintptr) {
|
|
streamsMu.Lock()
|
|
defer streamsMu.Unlock()
|
|
delete(streams, h)
|
|
}
|
|
|
|
// validModelPath enforces the greedy-probe rule: when a model config has no
|
|
// explicit backend, the loader probes every backend with the model name, so
|
|
// Load must refuse anything vllm.cpp cannot serve (a GGUF file, or a HF-style
|
|
// directory with config.json + safetensors).
|
|
func validModelPath(model string) error {
|
|
info, err := os.Stat(model)
|
|
if err != nil {
|
|
return fmt.Errorf("vllm-cpp: model path %q not found: %w", model, err)
|
|
}
|
|
if info.IsDir() {
|
|
if _, err := os.Stat(filepath.Join(model, "config.json")); err != nil {
|
|
return fmt.Errorf("vllm-cpp: model dir %q has no config.json", model)
|
|
}
|
|
return nil
|
|
}
|
|
if strings.EqualFold(filepath.Ext(model), ".gguf") {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("vllm-cpp: model %q is neither a .gguf file nor a config.json model dir", model)
|
|
}
|
|
|
|
func (v *VllmCpp) Load(opts *pb.ModelOptions) error {
|
|
model := opts.ModelFile
|
|
if model == "" {
|
|
model = opts.ModelPath
|
|
}
|
|
if !filepath.IsAbs(model) && opts.ModelPath != "" {
|
|
model = filepath.Join(opts.ModelPath, model)
|
|
}
|
|
if err := validModelPath(model); err != nil {
|
|
return err
|
|
}
|
|
|
|
v.opts = parseOptions(opts)
|
|
|
|
// A DFlash draft is a second checkpoint the engine opens by path, and the
|
|
// engine never downloads one. Resolve it against LocalAI's models directory
|
|
// now so a repo-id spelling works, and so a missing draft fails here with an
|
|
// actionable message rather than as an HF-cache miss inside the load.
|
|
resolvedSpec, err := resolveDraftModelPath(v.opts.speculativeConfig, opts.ModelPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v.opts.speculativeConfig = resolvedSpec
|
|
|
|
mp := defaultModelParams()
|
|
if v.opts.blockSize > 0 {
|
|
mp.BlockSize = v.opts.blockSize
|
|
}
|
|
if v.opts.numBlocks > 0 {
|
|
mp.NumBlocks = v.opts.numBlocks
|
|
}
|
|
// Sequence-length precedence, narrowest source last: context_size is the
|
|
// generic LocalAI knob every backend honours, max_model_len is the
|
|
// vLLM-specific one, and engine_args.max_model_len is the explicit
|
|
// vllm-cpp override.
|
|
if opts.ContextSize > 0 {
|
|
mp.MaxModelLen = opts.ContextSize
|
|
}
|
|
if opts.MaxModelLen > 0 {
|
|
mp.MaxModelLen = opts.MaxModelLen
|
|
}
|
|
if v.opts.maxModelLen > 0 {
|
|
mp.MaxModelLen = v.opts.maxModelLen
|
|
}
|
|
if v.opts.maxNumSeqs > 0 {
|
|
mp.MaxNumSeqs = v.opts.maxNumSeqs
|
|
}
|
|
if v.opts.maxNumBatchedTokens > 0 {
|
|
mp.MaxNumBatchedTokens = v.opts.maxNumBatchedTokens
|
|
}
|
|
mp.EnablePrefixCaching = v.opts.enablePrefixCaching
|
|
|
|
// Every string below is borrowed by C for the duration of the load call
|
|
// only (the library copies what it keeps), so the backing slices just have
|
|
// to outlive vllmEngineLoad - hence the single KeepAlive after it.
|
|
modelC := cString(model)
|
|
mp.ModelPath = uintptr(unsafe.Pointer(&modelC[0])) // #nosec G103 -- borrowed by C for the load call only
|
|
keep := [][]byte{modelC}
|
|
setStr := func(dst *uintptr, s string) {
|
|
if s == "" {
|
|
return
|
|
}
|
|
b := cString(s)
|
|
keep = append(keep, b)
|
|
*dst = uintptr(unsafe.Pointer(&b[0])) // #nosec G103 -- borrowed by C for the load call only
|
|
}
|
|
setStr(&mp.ToolParser, v.opts.toolParser)
|
|
setStr(&mp.ReasoningParser, v.opts.reasoningParser)
|
|
setStr(&mp.SpeculativeConfig, v.opts.speculativeConfig)
|
|
setStr(&mp.KVTransferConfig, v.opts.kvTransferConfig)
|
|
setStr(&mp.SchedulingPolicy, v.opts.schedulingPolicy)
|
|
setStr(&mp.TokenizerConfigPath, v.opts.tokenizerConfigPath)
|
|
|
|
xlog.Info("[vllm-cpp] Load", "model", model, "engine", vllmVersion(),
|
|
"blockSize", mp.BlockSize, "numBlocks", mp.NumBlocks,
|
|
"maxModelLen", mp.MaxModelLen, "maxNumSeqs", mp.MaxNumSeqs,
|
|
"maxNumBatchedTokens", mp.MaxNumBatchedTokens,
|
|
"prefixCaching", prefixCachingName(mp.EnablePrefixCaching),
|
|
"schedulingPolicy", v.opts.schedulingPolicy,
|
|
"speculativeConfig", v.opts.speculativeConfig,
|
|
"kvTransferConfig", v.opts.kvTransferConfig)
|
|
|
|
var engine uintptr
|
|
rc := vllmEngineLoad(unsafe.Pointer(&mp), unsafe.Pointer(&engine)) // #nosec G103 -- POD out-params
|
|
runtime.KeepAlive(keep)
|
|
if rc != vllmOK {
|
|
return fmt.Errorf("vllm-cpp: engine load failed: %s", vllmLastError())
|
|
}
|
|
v.engine = engine
|
|
return nil
|
|
}
|
|
|
|
func (v *VllmCpp) Free() error {
|
|
if v.engine != 0 {
|
|
vllmEngineFree(v.engine)
|
|
v.engine = 0
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// samplingFromPredict lowers PredictOptions into the C sampling POD plus the
|
|
// backing buffers that must stay alive for the duration of the C call.
|
|
func samplingFromPredict(opts *pb.PredictOptions) (sp cSamplingParams, keep []any) {
|
|
sp = defaultSamplingParams()
|
|
sp.Temperature = opts.Temperature
|
|
if opts.TopP > 0 {
|
|
sp.TopP = opts.TopP
|
|
}
|
|
if opts.TopK > 0 {
|
|
sp.TopK = opts.TopK
|
|
}
|
|
if opts.MinP > 0 {
|
|
sp.MinP = opts.MinP
|
|
}
|
|
if opts.Tokens > 0 {
|
|
sp.MaxTokens = opts.Tokens
|
|
} else {
|
|
sp.MaxTokens = 0 // unbounded; the engine caps at max_model_len.
|
|
}
|
|
if opts.Seed > 0 {
|
|
sp.Seed = uint64(opts.Seed)
|
|
sp.HasSeed = 1
|
|
}
|
|
sp.PresencePenalty = opts.PresencePenalty
|
|
sp.FrequencyPenalty = opts.FrequencyPenalty
|
|
if opts.Penalty > 0 {
|
|
sp.RepetitionPenalty = opts.Penalty
|
|
}
|
|
if opts.IgnoreEOS {
|
|
sp.IgnoreEOS = 1
|
|
}
|
|
if len(opts.StopPrompts) > 0 {
|
|
ptrs, backing := cStringArray(opts.StopPrompts)
|
|
sp.Stop = uintptr(unsafe.Pointer(&ptrs[0])) // #nosec G103 -- borrowed by C for the call only
|
|
sp.NStop = int32(len(ptrs))
|
|
keep = append(keep, ptrs, backing)
|
|
}
|
|
if opts.Grammar != "" {
|
|
g := cString(opts.Grammar)
|
|
sp.StructuredGrammar = uintptr(unsafe.Pointer(&g[0])) // #nosec G103 -- borrowed by C for the call only
|
|
keep = append(keep, g)
|
|
}
|
|
return sp, keep
|
|
}
|
|
|
|
func (v *VllmCpp) Predict(opts *pb.PredictOptions) (string, error) {
|
|
if v.engine == 0 {
|
|
return "", fmt.Errorf("vllm-cpp: model not loaded")
|
|
}
|
|
sp, keep := samplingFromPredict(opts)
|
|
var out cCompletion
|
|
rc := vllmComplete(v.engine, opts.Prompt, unsafe.Pointer(&sp), unsafe.Pointer(&out)) // #nosec G103 -- POD in/out params
|
|
runtime.KeepAlive(keep)
|
|
if rc != vllmOK {
|
|
return "", fmt.Errorf("vllm-cpp: completion failed: %s", vllmLastError())
|
|
}
|
|
text := goString(out.Text)
|
|
vllmCompletionFree(unsafe.Pointer(&out)) // #nosec G103 -- frees out.Text
|
|
return text, nil
|
|
}
|
|
|
|
func (v *VllmCpp) PredictStream(opts *pb.PredictOptions, results chan string) error {
|
|
if v.engine == 0 {
|
|
close(results)
|
|
return fmt.Errorf("vllm-cpp: model not loaded")
|
|
}
|
|
tokenCbOnce.Do(func() {
|
|
tokenCbPtr = purego.NewCallback(tokenCallback)
|
|
})
|
|
|
|
sp, keep := samplingFromPredict(opts)
|
|
handle := registerStream(results)
|
|
|
|
go func() {
|
|
defer close(results)
|
|
defer unregisterStream(handle)
|
|
rc := vllmCompleteStream(v.engine, opts.Prompt, unsafe.Pointer(&sp), tokenCbPtr, handle) // #nosec G103 -- POD in-params
|
|
runtime.KeepAlive(keep)
|
|
if rc != vllmOK {
|
|
xlog.Error("[vllm-cpp] stream failed", "error", vllmLastError())
|
|
}
|
|
}()
|
|
return nil
|
|
}
|