mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-27 00:24:58 -04:00
* feat(vllm-cpp): add GLiNER2.5 NER via TokenClassify Wire the vllm-cpp backend to the C ABI NER surface (vllm_gliner_ner, ABI v27) so LocalAI can serve zero-shot named entity recognition through the existing TokenClassify gRPC method. backend.go: TokenClassify method on *VllmCpp calls vllm_gliner_ner with the text and labels, copies the C-owned entity array into protobuf TokenClassifyEntity messages, and frees the result. govllmcpp.go: cNerEntity and cNerResult Go POD mirrors matching the C structs; vllmGlinerNer and vllmNerResultFree purego bindings; abiVersion bumped 26 -> 27. options.go: ner_labels, ner_threshold, ner_max_width parsed from engine_args. pkg/grpc: ClassifyModel interface and TokenClassify server handler (follows the Embedding locking pattern). core/config: vllm-cpp backend declares MethodTokenClassify and UsecaseTokenClassify. docs/content/features/vllm-cpp.md: NER section documenting the engine_args keys and the host-forward contract. Assisted-by: MAKI:regolo/glm5.2 [maki] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vllm-cpp): correct NER pointer lint directive Use the govet directive for the C-owned NER array, matching the other purego pointer conversions. The array remains valid until its deferred free; the misspelled directive caused CI to flag this conversion. Assisted-by: Codex:gpt-6 golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vllm-cpp): add kev-compatible SystemOne API endpoints Add POST /v1/systemone, /v1/systemone/permute, and /v1/systemone/separate to LocalAI, mirroring the kev project's structured-extraction API. Each endpoint runs zero-shot NER over the rendered state text and builds kev-compatible answers for three question types: noul (binary entity presence), choice (pick one option), and score (pick one level). The TokenClassifyRequest proto gains a `repeated string labels` field so each question can supply its own labels at inference time, and TokenClassifier gains TokenClassifyWithLabels for per-call label selection. The vllm-cpp backend uses request labels when non-empty, falling back to configured ner_labels then the built-in defaults. Helpers (renderState, softmax, choiceConfidence, scoreConfidence, r2) are ported from kev/api.py and mirrored in vllm.cpp's api_server.cpp so both servers produce the same answer shape. Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:regolo/glm5.2 [maki] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vllm-cpp): suppress gosec G404 on seeded permutation RNG The SystemOne permute endpoint uses math/rand with a caller-supplied seed for reproducible option permutations, matching kev's random.seed. gosec flags this as G404 (weak RNG). Add #nosec with a comment naming the intent: this is reproducibility, not cryptography. Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:regolo/glm5.2 [maki] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(vllm-cpp): bump vllm.cpp pin to GLiNER2.5 merge commit Advance VLLM_CPP_VERSION from f3cd97e to 5058268d, the commit that landed GLiNER2.5 zero-shot NER support (PR #3224) in vllm.cpp. This brings the DeBERTa v2 encoder, GLiNER2 boundary head, C ABI NER functions, and server endpoints into the LocalAI vllm-cpp backend. The ABI version (27) and Go struct mirrors already match. Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:regolo/glm5.2 [maki] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vllm-cpp): use instruction text as NER label in SystemOne handler The SystemOne handler was passing question IDs as NER labels for noul questions and bare key names for choice questions, so the model never matched any entities. Port the label mapping from vllm.cpp's ParseSystemOneBody: - noul: use the rendered instructions field (with instr alias) as the NER label, not the question ID - choice: use optionText(name, desc) — "name: description" or "name" when the description is null/empty — not the bare key - score: already correct (rendered criteria text) - permute: shuffle indices and build parallel key/label arrays so the NER call uses the optionText labels while the response is keyed by the original option names Also add the instructions field to the SystemOneQuestion schema struct (accepted alongside the instr backward-compat alias). Verified end-to-end against the real GLiNER2.5 model: noul questions now find "Apple Inc. is" (organization, 0.999) and "Tim Cook is" (person, 0.852) where they previously returned zero entities. Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:regolo/glm5.2 [maki] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
363 lines
12 KiB
Go
363 lines
12 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"
|
|
"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
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
|
|
// 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
|
|
|
|
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(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", 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 {
|
|
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
|
|
}
|
|
if v.videoEngine != 0 {
|
|
vllmVideoEngineFree(v.videoEngine)
|
|
v.videoEngine = 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
|
|
}
|
|
|
|
// 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 := ¢s[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
|
|
}
|