Files
LocalAI/core/http/endpoints/localai/systemone.go
T
mudler-agentandEttore Di Giacinto 42c58a5838 feat(vllm-cpp): unify decision pipeline through Score RPC with vllm_decide ABI v29 (#12247)
* feat(vllm-cpp): unify decision pipeline through Score RPC with vllm_decide ABI v29

Replace the model-specific SystemOne gRPC approach with a generic Score
RPC extension. The pre-existing Score RPC (previously unused by any
backend) now carries question_type and response_json fields:

- question_type="systemone" routes kev/laya decision-pipeline requests
  through the unified vllm_decide C ABI (v29), returning the full
  response JSON in response_json.
- question_type empty routes cua-s1-forms candidate scoring through the
  same vllm_decide ABI, returning CandidateScore probabilities.

The vllm-cpp backend's Score() method calls vllm_decide and dispatches
by architecture internally. The /v1/systemone HTTP endpoint checks
whether the model's backend supports Score; if so, it forwards the raw
request JSON and returns the backend response as-is. Other backends
fall through to the existing NER-based path.

This mirrors the vllm.cpp C ABI refactor (PR #3301) that replaced
vllm_systemone + vllm_score with a single vllm_decide function. The
purego bindings bump abiVersion from 27 to 29 and resolve vllm_decide
and vllm_decide_free symbols.

Also fixes validModelPath to accept cua-s1-forms.json and
rl_agent_config.json alongside config.json, matching the engine's
model_loader.cpp config-filename ordering.

AI-Assisted: true
Assisted-by: Maki:regolo/glm5.2 [maki]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore: ⬆️ Update mudler/vllm.cpp to e28ec46c6 (fix macOS -Werror build)

Bumps vllm.cpp to e28ec46c6 which fixes a -Wnull-conversion error in
qwen3_5.cpp:12483 that broke the macOS Metal CI build under -Werror.

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>
2026-09-25 23:27:27 +02:00

639 lines
19 KiB
Go

package localai
import (
"encoding/json"
"fmt"
"math"
"math/rand"
"net/http"
"slices"
"sort"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/schema"
)
// ---------------------------------------------------------------------------
// Helpers — ported from kev/api.py (render, r2, choice_confidence,
// score_confidence, softmax) and mirrored in vllm.cpp api_server.cpp.
// ---------------------------------------------------------------------------
// renderState flattens a JSON value into text, mirroring kev's render().
// Field names are kept as labels; arrays become "- item" bullets; objects
// become "key: value" lines.
func renderState(v interface{}, indent int) string {
switch val := v.(type) {
case nil:
return ""
case string:
return val
case bool:
if val {
return "true"
}
return "false"
case float64:
b, _ := json.Marshal(val)
return string(b)
case []interface{}:
pad := strings.Repeat(" ", indent)
var parts []string
for _, item := range val {
rendered := renderState(item, indent+1)
rendered = strings.TrimLeft(rendered, " \t\n")
parts = append(parts, pad+"- "+rendered)
}
return strings.Join(parts, "\n")
case map[string]interface{}:
pad := strings.Repeat(" ", indent)
keys := make([]string, 0, len(val))
for k := range val {
keys = append(keys, k)
}
sort.Strings(keys)
var parts []string
for i, k := range keys {
if i > 0 {
parts = append(parts, "\n")
}
switch vv := val[k].(type) {
case map[string]interface{}, []interface{}:
parts = append(parts, pad+k+":\n"+renderState(vv, indent+1))
default:
parts = append(parts, pad+k+": "+renderState(vv, indent))
}
}
return strings.Join(parts, "")
default:
b, _ := json.Marshal(v)
return string(b)
}
}
// r2 rounds to 2 decimal places.
func r2(x float64) float64 {
return math.Round(x*100) / 100
}
// choiceConfidence is the normalized margin (kev/api.py:choice_confidence).
func choiceConfidence(p []float64) float64 {
k := len(p)
if k <= 1 {
return 1.0
}
mx := p[0]
for _, v := range p[1:] {
if v > mx {
mx = v
}
}
return (mx - 1.0/float64(k)) / (1.0 - 1.0/float64(k))
}
// scoreConfidence is 1 - E|level - mode| / (L - 1)
// (kev/api.py:score_confidence).
func scoreConfidence(p []float64) float64 {
l := len(p)
if l <= 1 {
return 1.0
}
mode := 0
maxP := p[0]
for i := 1; i < l; i++ {
if p[i] > maxP {
maxP = p[i]
mode = i
}
}
s := 0.0
for i := 0; i < l; i++ {
s += p[i] * math.Abs(float64(i)-float64(mode))
}
return 1.0 - s/float64(l-1)
}
// softmax is a numerically stable softmax.
func softmax(scores []float64) []float64 {
if len(scores) == 0 {
return nil
}
mx := scores[0]
for _, s := range scores[1:] {
if s > mx {
mx = s
}
}
exps := make([]float64, len(scores))
sum := 0.0
for i, s := range scores {
exps[i] = math.Exp(s - mx)
sum += exps[i]
}
if sum <= 0 {
inv := 1.0 / float64(len(scores))
for i := range exps {
exps[i] = inv
}
return exps
}
for i := range exps {
exps[i] /= sum
}
return exps
}
// optionText mirrors kev/api.py:option_text. "name" if desc is null/empty,
// else "name: rendered_desc".
func optionText(name string, desc json.RawMessage) string {
if len(desc) == 0 || string(desc) == "null" {
return name
}
var v interface{}
if err := json.Unmarshal(desc, &v); err != nil {
return name
}
rendered := renderState(v, 0)
if rendered == "" {
return name
}
return name + ": " + rendered
}
// getQuestionInstructions checks instructions first, then instr (alias).
// The value is rendered to text, matching vllm.cpp GetInstructions.
func getQuestionInstructions(q schema.SystemOneQuestion) string {
if len(q.Instructions) > 0 {
var v interface{}
if err := json.Unmarshal(q.Instructions, &v); err == nil {
return renderState(v, 0)
}
}
return q.Instr
}
// ---------------------------------------------------------------------------
// Parsed question (internal).
// ---------------------------------------------------------------------------
type parsedQuestion struct {
id string
qtype string // "noul", "choice", "score"
keys []string
labels []string
}
type parsedSystemOne struct {
text string
model string
threshold float32
questions []parsedQuestion
allLabels []string
}
func parseSystemOneRequest(req *schema.SystemOneRequest) (*parsedSystemOne, error) {
p := &parsedSystemOne{
model: req.Model,
threshold: 0.5,
}
if req.Threshold != nil {
p.threshold = *req.Threshold
}
var stateVal interface{}
if err := json.Unmarshal(req.State, &stateVal); err != nil {
return nil, fmt.Errorf("state is not valid JSON: %w", err)
}
p.text = renderState(stateVal, 0)
if len(req.Questions) == 0 {
return nil, fmt.Errorf("questions is required and must contain at least one question")
}
qids := make([]string, 0, len(req.Questions))
for k := range req.Questions {
qids = append(qids, k)
}
sort.Strings(qids)
for _, qid := range qids {
q := req.Questions[qid]
pq := parsedQuestion{id: qid, qtype: q.Type}
switch q.Type {
case "noul":
instr := getQuestionInstructions(q)
pq.labels = []string{instr}
pq.keys = []string{"no", "yes"}
case "choice":
var criteria map[string]json.RawMessage
if err := json.Unmarshal(q.Criteria, &criteria); err != nil || len(criteria) == 0 {
return nil, fmt.Errorf("question %q (choice) requires a non-empty criteria object", qid)
}
ckeys := make([]string, 0, len(criteria))
for k := range criteria {
ckeys = append(ckeys, k)
}
sort.Strings(ckeys)
for _, ck := range ckeys {
pq.keys = append(pq.keys, ck)
pq.labels = append(pq.labels, optionText(ck, criteria[ck]))
}
case "score":
var criteria []json.RawMessage
if err := json.Unmarshal(q.Criteria, &criteria); err != nil || len(criteria) < 2 {
return nil, fmt.Errorf("question %q (score) requires a criteria array with >= 2 levels", qid)
}
for _, level := range criteria {
var lv interface{}
_ = json.Unmarshal(level, &lv)
rendered := renderState(lv, 0)
pq.keys = append(pq.keys, rendered)
pq.labels = append(pq.labels, rendered)
}
default:
return nil, fmt.Errorf("question %q has unknown type: %s", qid, q.Type)
}
p.questions = append(p.questions, pq)
p.allLabels = append(p.allLabels, pq.labels...)
}
return p, nil
}
// buildSystemOneAnswer produces one kev answer from NER entities.
func buildSystemOneAnswer(q *parsedQuestion, entities []backend.TokenEntity) schema.SystemOneAnswer {
scores := make([]float64, len(q.labels))
for i, label := range q.labels {
var maxConf float32
for _, e := range entities {
if e.Group == label && e.Score > maxConf {
maxConf = e.Score
}
}
scores[i] = float64(maxConf)
}
switch q.qtype {
case "noul":
probs := []float64{1.0 - scores[0], scores[0]}
var ents []schema.SystemOneEntity
for _, e := range entities {
if e.Group == q.labels[0] {
ents = append(ents, schema.SystemOneEntity{
Text: e.Text,
Start: e.Start,
End: e.End,
Confidence: e.Score,
})
}
}
noul := r2(probs[1])
return schema.SystemOneAnswer{
Type: "noul",
Noul: &noul,
Entities: ents,
}
case "choice":
probs := softmax(scores)
argmax := 0
for i := 1; i < len(probs); i++ {
if probs[i] > probs[argmax] {
argmax = i
}
}
dist := make(map[string]float64, len(q.keys))
for i, k := range q.keys {
dist[k] = r2(probs[i])
}
choice := q.keys[argmax]
conf := r2(choiceConfidence(probs))
return schema.SystemOneAnswer{
Type: "choice",
Choice: &choice,
Confidence: &conf,
Probabilities: dist,
}
default: // score
probs := softmax(scores)
var score float64
for i, pr := range probs {
score += float64(i) * pr
}
legend := make(map[string]string, len(q.keys))
dist := make(map[string]float64, len(q.keys))
for i, k := range q.keys {
legend[strconv.Itoa(i)] = k
dist[strconv.Itoa(i)] = r2(probs[i])
}
sc := r2(score)
conf := r2(scoreConfidence(probs))
return schema.SystemOneAnswer{
Type: "score",
Score: &sc,
Legend: legend,
Probabilities: dist,
Confidence: &conf,
}
}
}
// ---------------------------------------------------------------------------
// Model resolution.
// ---------------------------------------------------------------------------
func resolveClassifier(app *application.Application, modelName string, threshold float32) (backend.TokenClassifier, error) {
cl := app.ModelConfigLoader()
if cl == nil {
return nil, fmt.Errorf("model config loader unavailable")
}
cfg, ok := cl.GetModelConfig(modelName)
if !ok {
return nil, fmt.Errorf("model %q not found", modelName)
}
opts := backend.TokenClassifyOptions{
Threshold: threshold,
}
return backend.NewTokenClassifier(app.ModelLoader(), cfg, app.ApplicationConfig(), opts), nil
}
func systemOneError(c echo.Context, status int, msg string) error {
return c.JSON(status, map[string]any{
"error": map[string]string{
"message": msg,
"type": "invalid_request",
},
})
}
// backendSupportsScore reports whether the named backend implements the
// Score gRPC RPC. vllm-cpp does (kev/laya decision pipeline and cua-s1-forms
// scoring via the unified vllm_decide C ABI); other backends fall through to
// the NER-based path.
func backendSupportsScore(backendName string) bool {
cap := config.GetBackendCapability(backendName)
if cap == nil {
return false
}
return slices.Contains(cap.GRPCMethods, config.MethodScore)
}
// ---------------------------------------------------------------------------
// Endpoints.
// ---------------------------------------------------------------------------
// SystemOneEndpoint handles POST /v1/systemone.
// For vllm-cpp models (kev/laya), forwards the raw request to the backend's
// Score gRPC RPC with question_type set to "systemone" and returns the
// response JSON as-is. For other backends, runs one NER pass over the
// rendered state with all question labels, then builds a kev-compatible
// answer for each question.
// @Summary Answer structured-extraction questions over state text.
// @Description Runs zero-shot NER over the supplied state and answers each question. Question types: noul (binary entity presence), choice (pick one option), score (pick one level).
// @Tags systemone
// @Param request body schema.SystemOneRequest true "state + questions"
// @Success 200 {object} schema.SystemOneResponse
// @Router /v1/systemone [post]
func SystemOneEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
var req schema.SystemOneRequest
if err := c.Bind(&req); err != nil {
return systemOneError(c, http.StatusBadRequest, "invalid request body")
}
if req.Model == "" {
return systemOneError(c, http.StatusBadRequest, "model is required")
}
// vllm-cpp models (kev/laya) implement the decision pipeline natively
// via the vllm_decide C ABI. Forward the raw request JSON through the
// Score RPC and return the backend's response as-is.
cl := app.ModelConfigLoader()
if cl != nil {
if cfg, ok := cl.GetModelConfig(req.Model); ok && backendSupportsScore(cfg.Backend) {
reqJSON, err := json.Marshal(req)
if err != nil {
return systemOneError(c, http.StatusInternalServerError, "failed to marshal request: "+err.Error())
}
fn, err := backend.ModelSystemOne(string(reqJSON), app.ModelLoader(), cfg, app.ApplicationConfig())
if err != nil {
return systemOneError(c, http.StatusInternalServerError, err.Error())
}
respJSON, err := fn(c.Request().Context())
if err != nil {
return systemOneError(c, http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, json.RawMessage(respJSON))
}
}
// NER-based path (GLiNER2.5 zero-shot NER).
parsed, err := parseSystemOneRequest(&req)
if err != nil {
return systemOneError(c, http.StatusBadRequest, err.Error())
}
classifier, err := resolveClassifier(app, req.Model, parsed.threshold)
if err != nil {
return systemOneError(c, http.StatusNotFound, err.Error())
}
start := time.Now()
entities, err := classifier.TokenClassifyWithLabels(c.Request().Context(), parsed.text, parsed.allLabels)
if err != nil {
return systemOneError(c, http.StatusInternalServerError, err.Error())
}
latencyMs := float64(time.Since(start).Microseconds()) / 1000.0
answers := make(map[string]schema.SystemOneAnswer, len(parsed.questions))
for i := range parsed.questions {
answers[parsed.questions[i].id] = buildSystemOneAnswer(&parsed.questions[i], entities)
}
return c.JSON(http.StatusOK, schema.SystemOneResponse{
Model: req.Model,
Answers: answers,
Usage: schema.SystemOneUsage{InputTokens: 0, OutputTokens: 0},
LatencyMs: r2(latencyMs),
})
}
}
// SystemOnePermuteEndpoint handles POST /v1/systemone/permute.
// Re-runs one choice question under n_perm option orders with a seeded RNG.
// @Summary Re-run a choice question under multiple option orders.
// @Description Re-runs one choice question under n_perm option orders. Reports per-order probabilities, argmax stability, and spread.
// @Tags systemone
// @Param request body schema.SystemOnePermuteRequest true "request + question + n_perm + seed"
// @Success 200 {object} schema.SystemOnePermuteResponse
// @Router /v1/systemone/permute [post]
func SystemOnePermuteEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
var req schema.SystemOnePermuteRequest
if err := c.Bind(&req); err != nil {
return systemOneError(c, http.StatusBadRequest, "invalid request body")
}
if req.Request.Model == "" {
return systemOneError(c, http.StatusBadRequest, "model is required")
}
if req.Question == "" {
return systemOneError(c, http.StatusBadRequest, "question is required")
}
parsed, err := parseSystemOneRequest(&req.Request)
if err != nil {
return systemOneError(c, http.StatusBadRequest, err.Error())
}
var target *parsedQuestion
for i := range parsed.questions {
if parsed.questions[i].id == req.Question {
target = &parsed.questions[i]
break
}
}
if target == nil {
return systemOneError(c, http.StatusBadRequest, fmt.Sprintf("question %q not found", req.Question))
}
if target.qtype != "choice" {
return systemOneError(c, http.StatusBadRequest, "question must be a choice question")
}
classifier, err := resolveClassifier(app, req.Request.Model, parsed.threshold)
if err != nil {
return systemOneError(c, http.StatusNotFound, err.Error())
}
nPerm := req.NPerm
if nPerm <= 0 {
nPerm = 6
}
rng := rand.New(rand.NewSource(req.Seed)) // #nosec G404 -- seeded RNG for reproducible permutations, not crypto
runs := make([]schema.SystemOnePermuteRun, 0, nPerm)
minProb := make([]float64, len(target.keys))
maxProb := make([]float64, len(target.keys))
for i := range minProb {
minProb[i] = 1.0
maxProb[i] = 0.0
}
firstChoice := ""
argmaxStable := true
for i := 0; i < nPerm; i++ {
idx := make([]int, len(target.keys))
for j := range idx {
idx[j] = j
}
if i > 0 {
rng.Shuffle(len(idx), func(a, b int) { idx[a], idx[b] = idx[b], idx[a] })
}
orderKeys := make([]string, len(idx))
orderLabels := make([]string, len(idx))
for j, k := range idx {
orderKeys[j] = target.keys[k]
orderLabels[j] = target.labels[k]
}
start := time.Now()
entities, err := classifier.TokenClassifyWithLabels(c.Request().Context(), parsed.text, orderLabels)
if err != nil {
return systemOneError(c, http.StatusInternalServerError, err.Error())
}
latencyMs := float64(time.Since(start).Microseconds()) / 1000.0
scores := make([]float64, len(orderLabels))
for j, label := range orderLabels {
var maxConf float32
for _, e := range entities {
if e.Group == label && e.Score > maxConf {
maxConf = e.Score
}
}
scores[j] = float64(maxConf)
}
probs := softmax(scores)
argmax := 0
for j := 1; j < len(probs); j++ {
if probs[j] > probs[argmax] {
argmax = j
}
}
probDist := make(map[string]float64, len(orderKeys))
for j, key := range orderKeys {
probDist[key] = r2(probs[j])
for k, tk := range target.keys {
if key == tk {
if probs[j] < minProb[k] {
minProb[k] = probs[j]
}
if probs[j] > maxProb[k] {
maxProb[k] = probs[j]
}
break
}
}
}
choice := orderKeys[argmax]
if i == 0 {
firstChoice = choice
} else if choice != firstChoice {
argmaxStable = false
}
runs = append(runs, schema.SystemOnePermuteRun{
Order: orderKeys,
Probabilities: probDist,
Choice: choice,
LatencyMs: r2(latencyMs),
})
}
spread := make(map[string]float64, len(target.keys))
for k, key := range target.keys {
spread[key] = r2(maxProb[k] - minProb[k])
}
return c.JSON(http.StatusOK, schema.SystemOnePermuteResponse{
Runs: runs,
ArgmaxStable: argmaxStable,
Spread: spread,
})
}
}
// SystemOneSeparateEndpoint handles POST /v1/systemone/separate.
// Answers each question in its own NER call (N passes). Response shape
// matches /v1/systemone.
// @Summary Answer each question in a separate NER pass.
// @Description Runs N independent NER passes, one per question, against the same state. Response shape matches /v1/systemone.
// @Tags systemone
// @Param request body schema.SystemOneRequest true "state + questions"
// @Success 200 {object} schema.SystemOneResponse
// @Router /v1/systemone/separate [post]
func SystemOneSeparateEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
var req schema.SystemOneRequest
if err := c.Bind(&req); err != nil {
return systemOneError(c, http.StatusBadRequest, "invalid request body")
}
if req.Model == "" {
return systemOneError(c, http.StatusBadRequest, "model is required")
}
parsed, err := parseSystemOneRequest(&req)
if err != nil {
return systemOneError(c, http.StatusBadRequest, err.Error())
}
classifier, err := resolveClassifier(app, req.Model, parsed.threshold)
if err != nil {
return systemOneError(c, http.StatusNotFound, err.Error())
}
start := time.Now()
answers := make(map[string]schema.SystemOneAnswer, len(parsed.questions))
for i := range parsed.questions {
entities, err := classifier.TokenClassifyWithLabels(c.Request().Context(), parsed.text, parsed.questions[i].labels)
if err != nil {
return systemOneError(c, http.StatusInternalServerError, err.Error())
}
answers[parsed.questions[i].id] = buildSystemOneAnswer(&parsed.questions[i], entities)
}
latencyMs := float64(time.Since(start).Microseconds()) / 1000.0
return c.JSON(http.StatusOK, schema.SystemOneResponse{
Model: req.Model,
Answers: answers,
Usage: schema.SystemOneUsage{InputTokens: 0, OutputTokens: 0},
LatencyMs: r2(latencyMs),
})
}
}