Files
LocalAI/core/backend/score.go
mudler's LocalAI [bot] 1cd7d63c7b fix(distributed): reject wrong-model requests on the remaining modalities (#10990)
#10970 gave the four PredictOptions RPCs a model-identity check so a
backend reached through a stale distributed route rejects the request
instead of answering from whatever model it holds (#10952). Every other
modality shares that exposure: the route is cached by host:port, a worker
can recycle a stopped backend's port for another model's backend, and a
liveness-only probe cannot tell a stale row from a valid one.

Extends the same mechanism to the 21 remaining request messages that reach
a backend through the router, using the pattern #10970 established rather
than a parallel one:

- proto: ModelIdentity on each modality request message.
- controller: populated from ModelConfig.Model at the call site that also
  builds ModelOptions, so load-time and request-time values are equal by
  construction.
- backends: one generic guard in pkg/grpc/server.go (27 Go backends), the
  method set in backend/python/common (36 Python backends), llama-cpp
  (AudioTranscription/Stream, Rerank, Score) and privacy-filter
  (TokenClassify).
- reconcile already drops the stale row on IsModelMismatch; no change.

TTSRequest and SoundGenerationRequest get a SEPARATE ModelIdentity field
rather than reusing their existing `model`: FileStagingClient rewrites
`model` to a worker-local path, so comparing it would reject valid
requests in exactly the configuration this guards.

AudioEncode/AudioDecode are deliberately left unguarded: the opus codec
backend is loaded from a literal rather than a ModelConfig, so no value
carries the equality guarantee the comparison depends on. The four
bidirectional stream RPCs are out of scope; they bypass reconcile.

Empty means skip on both sides, so an old controller, an old backend, and
the bare request structs in tests/e2e-backends all keep working.


Assisted-by: Claude Code:claude-opus-4-8 [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 21:58:19 +02:00

161 lines
5.7 KiB
Go

package backend
import (
"context"
"fmt"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/trace"
"github.com/mudler/LocalAI/pkg/grpc"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
model "github.com/mudler/LocalAI/pkg/model"
)
// ScoreOptions controls a single Score request.
type ScoreOptions struct {
// IncludeTokenLogprobs returns per-token log-probability detail for
// each candidate. Off by default — the joint LogProb is enough for
// ranking; callers that need calibration / entropy over the token
// stream opt in.
IncludeTokenLogprobs bool
// LengthNormalize divides the joint log-prob by the candidate's
// token count. Useful when comparing candidates of different
// lengths — without it, longer candidates score lower by default.
LengthNormalize bool
}
// CandidateScore is the per-candidate result. Mirrors pb.CandidateScore
// but avoids leaking the proto type to consumers.
type CandidateScore struct {
LogProb float64
LengthNormalizedLogProb float64
NumTokens int
Tokens []TokenLogProb
}
type TokenLogProb struct {
Token string
LogProb float64
}
// Scorer evaluates a model's joint log-probability of each candidate
// continuation given a shared prompt. Implemented by NewScorer over a
// model-loaded backend; the router's score classifier consumes this
// for multi-label policy selection.
type Scorer interface {
Score(ctx context.Context, prompt string, candidates []string) ([]CandidateScore, error)
}
// NewScorer binds (loader, modelConfig, appConfig) into a Scorer. The
// underlying backend is resolved lazily on the first Score call.
// Returns nil only as a contract violation — callers that need to
// detect "model not loadable" should look up the config first.
func NewScorer(loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) Scorer {
return &modelScorer{loader: loader, modelConfig: modelConfig, appConfig: appConfig}
}
type modelScorer struct {
loader *model.ModelLoader
modelConfig config.ModelConfig
appConfig *config.ApplicationConfig
}
func (m *modelScorer) Score(ctx context.Context, prompt string, candidates []string) ([]CandidateScore, error) {
fn, err := ModelScore(prompt, candidates, ScoreOptions{LengthNormalize: true}, m.loader, m.modelConfig, m.appConfig)
if err != nil {
return nil, err
}
return fn(ctx)
}
// ModelScore loads the backend for modelConfig and returns a closure
// that scores `candidates` against `prompt`. The closure is bound to
// the loaded model so callers can keep it around for repeat scoring
// within the same request without re-resolving the backend.
func ModelScore(prompt string, candidates []string, opts ScoreOptions, loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) (func(ctx context.Context) ([]CandidateScore, error), error) {
modelOpts := ModelOptions(modelConfig, appConfig)
inferenceModel, err := loader.Load(modelOpts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return nil, err
}
b, ok := inferenceModel.(grpc.Backend)
if !ok {
return nil, fmt.Errorf("scoring not supported by backend %q", modelConfig.Backend)
}
if len(candidates) == 0 {
return nil, fmt.Errorf("Score: candidates must be non-empty")
}
return func(ctx context.Context) ([]CandidateScore, error) {
// Surface score calls in the Traces UI alongside the LLM calls
// they typically gate (router classifier, eval scoring). Without
// this, a router-classified request shows only the downstream LLM
// trace with no record of the classification that picked it.
var startTime time.Time
if appConfig.EnableTracing {
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
startTime = time.Now()
}
resp, err := b.Score(ctx, &pb.ScoreRequest{
ModelIdentity: modelConfig.Model,
Prompt: prompt,
Candidates: candidates,
IncludeTokenLogprobs: opts.IncludeTokenLogprobs,
LengthNormalize: opts.LengthNormalize,
})
results := scoreResponseToCandidates(resp, opts.IncludeTokenLogprobs)
if appConfig.EnableTracing {
errStr := ""
if err != nil {
errStr = err.Error()
}
trace.RecordBackendTrace(trace.BackendTrace{
Timestamp: startTime,
Duration: time.Since(startTime),
Type: trace.BackendTraceScore,
ModelName: modelConfig.Name,
Backend: modelConfig.Backend,
Summary: trace.TruncateString(prompt, 200),
Error: errStr,
Data: map[string]any{
// Copy candidates so the trace buffer doesn't pin a
// caller-owned slice for the lifetime of the ring.
"candidates": append([]string(nil), candidates...),
"results": results,
},
})
}
if err != nil {
return nil, err
}
return results, nil
}, nil
}
// scoreResponseToCandidates converts the wire-format pb response into
// the value type consumed by callers. Extracted to keep ModelScore's
// closure trivial and so the conversion can be unit-tested without a
// real backend.
func scoreResponseToCandidates(resp *pb.ScoreResponse, includeTokens bool) []CandidateScore {
if resp == nil {
return nil
}
out := make([]CandidateScore, len(resp.Candidates))
for i, c := range resp.Candidates {
cs := CandidateScore{
LogProb: c.LogProb,
LengthNormalizedLogProb: c.LengthNormalizedLogProb,
NumTokens: int(c.NumTokens),
}
if includeTokens && len(c.Tokens) > 0 {
cs.Tokens = make([]TokenLogProb, len(c.Tokens))
for j, t := range c.Tokens {
cs.Tokens[j] = TokenLogProb{Token: t.Token, LogProb: t.LogProb}
}
}
out[i] = cs
}
return out
}