Files
LocalAI/backend/go/vllm-cpp/backend.go
T
mudler-agentandEttore Di Giacinto b540c3e1fd chore(vllm-cpp): bump to 967883486 (ABI v30), add hf_overrides and Tev1 entries, fix vllm-cpp gallery installs (#12379)
* chore(vllm-cpp): bump vllm.cpp to 967883486 (ABI v30)

Moves the pin from c3bebc357 to 967883486. On top of the Nimble decision
adapter and the Qwen3.5 vision-loader fix, this brings Tev1 on
/v1/systemone and vllm_decide (opt-in through a "Tev1Model" architecture
in config.json), a tokenizer/ subdirectory fallback so the Laya HF
snapshot loads as downloaded, a stop-token fix, a logprobs fix under async
scheduling and a pinned parakeet.cpp fetch for the diarization build.

ABI v30 only adds the diarization and speaker-attributed ASR entry
points; no existing struct or signature changed, so the purego mirrors
keep their layout and only abiVersion moves to 30. Between 4479dc99f and
967883486 vllm.h changed only in a comment.

v30 turns VLLM_CPP_WITH_DIARIZATION on by default. The fetch is pinned
now, but ON still downloads parakeet.cpp at configure time and links a
second ggml into libvllm for calls this backend never makes, so build
with the option off: the symbols stay present as refusing stubs.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-sonnet-5-5

* feat(vllm-cpp): add the hf_overrides engine arg

vLLM parity: engine_args.hf_overrides is a JSON object of top-level
config.json keys merged over the model directory's own config.json. The
main use is opting a published checkpoint into an engine adapter its
config does not name, such as {"architectures": ["Tev1Model"]} on the
Tev1 snapshots, which declare Qwen3_5ForConditionalGeneration.

The C ABI has no override input and the engine reads config.json from
the directory it is given, so Load builds a private overlay directory:
the merged config.json plus a symlink to every other entry of the model
directory, and passes that to the engine. The download is never written.
Free, a failed load and the next Load remove the overlay.
validModelPath and the DFlash draft resolution still see the real
directory.

A value that is not a JSON object, a .gguf model or a directory without
config.json fails the load instead of being skipped like an unknown
engine_args key, because loading the unmodified config would serve a
different architecture than the one configured.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-sonnet-5-5

* fix(gallery): nest vllm-cpp artifacts under overrides

artifacts: is a model-config key, and the installer reads model-config
keys only from overrides:. Five vllm-cpp entries (laya, gliner25-decide,
qwen3-vl-4b, cua-s1-forms and gliner2.5) declared it at the entry top
level, where it is silently dropped: the install reports success, writes
a config whose model is the bare HF repo id and downloads nothing, and
vllm-cpp (which does not infer artifacts) then fails the first load with
"model path not found".

Move each block under overrides:, and add a guard test that refuses a
top-level artifacts: key in the index.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-sonnet-5-5

* feat(gallery): add Tev1 4B and 0.8B on vllm-cpp

Two decisions entries for Together AI's Tev1 checkpoints, pinned to the
current HF revisions. Tev1 is autoregressive: vllm.cpp answers
/v1/systemone by scoring the option letters, and the same engine still
serves chat completions. The published config.json names
Qwen3_5ForConditionalGeneration, so each entry sets
hf_overrides: {architectures: [Tev1Model]} to enable the decision route
without editing the download. known_usecases is [decisions] only, since
a declared decisions list is authoritative for reservation.

The descriptions state what was checked: agreement with transformers on
CPU over seven questions (4B 7/7, max probability difference 0.0004;
0.8B 6/7 with one near tie), CPU-only for the decision route, and a
fine-tune license the model card says is still being finalized, so no
license key is set.

The Decisions API page lists both entries, drops the note that Tev1
does not serve /v1/systemone and documents the 24-option limit (Ollama
allows 26).

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-sonnet-5-5

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-30 20:22:49 +02:00

465 lines
15 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 (
"context"
"encoding/json"
"fmt"
"math"
"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
// videoEngine is the MiniMax-H3 handle (ABI v12). It is deliberately a
// SECOND handle, not a mode of the first: H3 is a checkpoint set rather
// than a model directory, and vllm.cpp has the two loaders refuse each
// other's checkpoints. Exactly one of the two is ever non-zero.
videoEngine uintptr
opts loadOptions
// overlayDir is the hf_overrides overlay handed to the engine in place of
// the model directory. It must outlive the engine handle (the engine may
// reopen files through it), so it is removed in Free, not after Load.
overlayDir string
}
// 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() {
// vllm.cpp accepts three config filenames: config.json (standard),
// cua-s1-forms.json (cua-s1-forms scoring model), and
// rl_agent_config.json (laya decision model). The engine's
// model_loader.cpp checks them in that order.
for _, cfg := range []string{"config.json", "cua-s1-forms.json", "rl_agent_config.json"} {
if _, err := os.Stat(filepath.Join(model, cfg)); err == nil {
return nil
}
}
return fmt.Errorf("vllm-cpp: model dir %q has no config.json, cua-s1-forms.json, or rl_agent_config.json", model)
}
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)
// MiniMax-H3 is a checkpoint SET behind its own engine handle, so the
// branch is taken before any text-engine knob is resolved. The two loaders
// refuse each other's checkpoints, which is why this is decided from the
// config rather than probed.
if v.opts.video.engaged() {
return v.loadVideo(opts, model)
}
// 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
// A reload reuses this struct: drop any overlay from the previous model
// before building a new one so it is not leaked.
if err := removeConfigOverlay(v.overlayDir); err != nil {
xlog.Warn("[vllm-cpp] stale overlay", "error", err)
}
v.overlayDir = ""
enginePath := model
if hasHFOverrides(v.opts.hfOverrides) {
overlay, err := newConfigOverlay(model, v.opts.hfOverrides)
if err != nil {
return err
}
v.overlayDir = overlay
enginePath = overlay
xlog.Info("[vllm-cpp] hf_overrides applied through overlay", "model", model, "overlay", overlay, "overrides", v.opts.hfOverrides)
}
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
mp.EnableJumpForward = v.opts.enableJumpForward
// 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(enginePath)
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", triStateName(mp.EnablePrefixCaching),
"jumpForward", triStateName(mp.EnableJumpForward),
"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 {
loadErr := fmt.Errorf("vllm-cpp: engine load failed: %s", vllmLastError())
if err := removeConfigOverlay(v.overlayDir); err != nil {
xlog.Warn("[vllm-cpp] overlay cleanup after failed load", "error", err)
}
v.overlayDir = ""
return loadErr
}
v.engine = engine
return nil
}
func (v *VllmCpp) Free() error {
if v.engine != 0 {
vllmEngineFree(v.engine)
v.engine = 0
}
if v.videoEngine != 0 {
vllmVideoEngineFree(v.videoEngine)
v.videoEngine = 0
}
// After the engine is gone, so nothing still reads through the links.
err := removeConfigOverlay(v.overlayDir)
v.overlayDir = ""
return err
}
// 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
}
// defaultNerLabels is the general-purpose entity type set used when the model
// config does not supply ner_labels. These cover the most common NER use cases
// and match the categories the GLiNER2.5 model card demonstrates.
var defaultNerLabels = []string{
"person", "organization", "location",
"date", "time", "money", "quantity",
}
// TokenClassify runs zero-shot NER on the loaded GLiNER2.5 engine via the
// vllm_gliner_ner C ABI (ABI v27). The engine refuses non-BoundaryExtractor
// architectures, so a model loaded for chat or embeddings returns an error
// here rather than silent garbage.
func (v *VllmCpp) TokenClassify(_ context.Context, in *pb.TokenClassifyRequest) (*pb.TokenClassifyResponse, error) {
if v.engine == 0 {
return nil, fmt.Errorf("vllm-cpp: model not loaded")
}
labels := v.opts.nerLabels
if len(in.Labels) > 0 {
labels = in.Labels
}
if len(labels) == 0 {
labels = defaultNerLabels
}
threshold := v.opts.nerThreshold
if in.Threshold > 0 {
threshold = in.Threshold
}
maxWidth := v.opts.nerMaxWidth
labelPtrs, labelBacking := cStringArray(labels)
if len(labelPtrs) == 0 {
return nil, fmt.Errorf("vllm-cpp: no NER labels configured")
}
labelsPtr := uintptr(unsafe.Pointer(&labelPtrs[0])) // #nosec G103 -- borrowed by C for the call only
var out cNerResult
rc := vllmGlinerNer(v.engine, in.Text, labelsPtr, int32(len(labelPtrs)), threshold, maxWidth, unsafe.Pointer(&out)) // #nosec G103 -- POD in/out params
runtime.KeepAlive(labelBacking)
if rc != vllmOK {
return nil, fmt.Errorf("vllm-cpp: NER failed: %s", vllmLastError())
}
defer vllmNerResultFree(unsafe.Pointer(&out)) // #nosec G103 -- frees C-owned members
entities := make([]*pb.TokenClassifyEntity, 0, out.nEntities)
if out.nEntities > 0 && out.entities != 0 {
//nolint:govet // C-owned array, valid for this call before vllmNerResultFree
cents := unsafe.Slice((*cNerEntity)(unsafe.Pointer(out.entities)), int(out.nEntities)) // #nosec G103 -- C-owned, copied out immediately
for i := range cents {
e := &cents[i]
entities = append(entities, &pb.TokenClassifyEntity{
EntityGroup: goString(e.label),
Start: e.charStart,
End: e.charEnd,
Score: e.confidence,
Text: goString(e.text),
})
}
}
return &pb.TokenClassifyResponse{Entities: entities}, 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
}
// Score runs the unified decision pipeline via the vllm_decide C ABI
// (ABI v29). When question_type is "systemone", the prompt carries the
// raw /v1/systemone request JSON and the response is returned in
// response_json. When question_type is empty, the prompt and candidates
// are scored as candidate continuations.
func (v *VllmCpp) Score(_ context.Context, in *pb.ScoreRequest) (*pb.ScoreResponse, error) {
if v.engine == 0 {
return nil, fmt.Errorf("vllm-cpp: model not loaded")
}
if in.QuestionType == "systemone" {
// Decision pipeline (kev/laya): forward the raw request JSON.
var out uintptr
rc := vllmDecide(v.engine, in.Prompt, unsafe.Pointer(&out)) // #nosec G103 -- char** out-param
if rc != vllmOK {
return nil, fmt.Errorf("vllm-cpp: decide failed: %s", vllmLastError())
}
payload := goString(out)
vllmDecideFree(out)
return &pb.ScoreResponse{ResponseJson: payload}, nil
}
// Candidate scoring (cua-s1-forms): build a scoring request JSON.
if len(in.Candidates) == 0 {
return nil, fmt.Errorf("vllm-cpp: score requires at least one candidate")
}
reqJSON, err := json.Marshal(map[string]any{
"context": in.Prompt,
"options": in.Candidates,
})
if err != nil {
return nil, fmt.Errorf("vllm-cpp: score request encode: %w", err)
}
var out uintptr
rc := vllmDecide(v.engine, string(reqJSON), unsafe.Pointer(&out)) // #nosec G103 -- char** out-param
if rc != vllmOK {
return nil, fmt.Errorf("vllm-cpp: decide failed: %s", vllmLastError())
}
payload := goString(out)
vllmDecideFree(out)
var resp struct {
Probabilities []float64 `json:"probabilities"`
}
if err := json.Unmarshal([]byte(payload), &resp); err != nil {
return nil, fmt.Errorf("vllm-cpp: unparseable score response: %w", err)
}
candidates := make([]*pb.CandidateScore, len(in.Candidates))
for i, c := range in.Candidates {
var p float64
if i < len(resp.Probabilities) {
p = resp.Probabilities[i]
}
lp := math.Log(p)
if p <= 0 {
lp = -999.0 // JSON cannot encode -Inf; use a large negative sentinel
}
nTok := max((len(c)+3)/4, 1)
candidates[i] = &pb.CandidateScore{
LogProb: lp,
NumTokens: int32(nTok),
LengthNormalizedLogProb: lp / float64(nTok),
}
}
return &pb.ScoreResponse{Candidates: candidates}, nil
}