mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
fix(distributed): reject wrong-model requests at the backend (#10952) In distributed mode the controller caches a NodeModel row naming a backend's host:port. A worker can recycle a stopped backend's gRPC port for a different model's backend, and probeHealth verifies liveness rather than identity, so the probe succeeds against whatever now occupies the port and the request is dispatched to the wrong backend. The caller gets a silent wrong-model answer. Nothing in the request could catch this: PredictOptions had no model field, so model identity crossed the wire only in ModelOptions.Model at LoadModel time, and the cached-hit path issues no LoadModel. Every backend's "model not loaded" guard checks a nil handle, which a process holding a different model passes, so the stale row was never dropped either. Add PredictOptions.ModelIdentity and enforce it at the point of use: - The controller populates it in gRPCPredictOpts from ModelConfig.Model, the same expression ModelOptions feeds to model.WithModel and therefore the same value the backend received as ModelOptions.Model. Both are read from one config value in one function, so they are equal by construction and the comparison cannot false-reject. - Backends compare it against what they loaded and return NOT_FOUND with a fixed sentinel. Enforced in pkg/grpc/server.go (27 Go backends), an interceptor in backend/python/common (all 36 Python backends, no per-backend change), and the llama-cpp / ik-llama-cpp / ds4 C++ servers. That is every backend with real exposure: kokoros answers all four RPCs with unimplemented and privacy-filter implements none of them. - The router's reconcile drops the stale replica row on a mismatch, so the next request reloads somewhere correct. Empty means "skip the check" on both sides: a controller that predates the field sends nothing, a backend loaded by such a controller has nothing to compare, and the C++ server synthesizes PredictOptions internally for ASR. That keeps upgrades working in both directions. Scoped to the four PredictOptions RPCs. TTSRequest.model and SoundGenerationRequest.model are deliberately NOT validated: FileStagingClient already rewrites them to worker-local absolute paths, so in distributed mode they already differ from the load-time value and comparing them would reject valid requests. IsModelMismatch requires both the NOT_FOUND code and the sentinel, unlike the neighbouring helpers which accept either. insightface's Embedding returns NOT_FOUND "no face detected" on a PredictOptions RPC, and a code-only check would drop a healthy replica row on every faceless image. Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
128 lines
5.8 KiB
Go
128 lines
5.8 KiB
Go
// Package grpcerrors defines well-known error signals shared between backends
|
|
// (which produce them) and the router (which consumes them). Go error types do
|
|
// not survive the gRPC boundary, so these conditions are carried as gRPC status
|
|
// codes and detected via the code rather than by matching the error message.
|
|
package grpcerrors
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// ModelNotLoaded returns the canonical error a backend returns when it has no
|
|
// model loaded for the request. It carries codes.FailedPrecondition so callers
|
|
// can detect it across the gRPC boundary without matching the message string.
|
|
func ModelNotLoaded(backend string) error {
|
|
return status.Errorf(codes.FailedPrecondition, "%s: model not loaded", backend)
|
|
}
|
|
|
|
// IsModelNotLoaded reports whether err signals that the backend has no model
|
|
// loaded. It prefers the typed gRPC status code (FailedPrecondition) and falls
|
|
// back to the message for backends that have not yet adopted ModelNotLoaded.
|
|
//
|
|
// Acting on a false positive is harmless: the only consequence upstream is that
|
|
// the model is reloaded, which is idempotent.
|
|
func IsModelNotLoaded(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if status.Code(err) == codes.FailedPrecondition {
|
|
return true
|
|
}
|
|
return strings.Contains(strings.ToLower(err.Error()), "model not loaded")
|
|
}
|
|
|
|
// ModelMismatchSentinel is the fixed marker every backend puts in a
|
|
// model-identity mismatch error, in every language. IsModelMismatch requires
|
|
// it, so it is part of the cross-language wire contract: changing it breaks
|
|
// detection for backends that have not been rebuilt.
|
|
const ModelMismatchSentinel = "model identity mismatch"
|
|
|
|
// ModelMismatch returns the canonical error a backend returns when the request
|
|
// names a model other than the one it has loaded. That means the router's
|
|
// cached row is stale and points at a recycled port, so the caller should drop
|
|
// the row rather than retry against the same replica.
|
|
func ModelMismatch(backend, loaded, requested string) error {
|
|
return status.Errorf(codes.NotFound, "%s: %s: loaded %q, requested %q",
|
|
backend, ModelMismatchSentinel, loaded, requested)
|
|
}
|
|
|
|
// IsModelMismatch reports whether err signals that the backend has a DIFFERENT
|
|
// model loaded than the request asked for.
|
|
//
|
|
// It requires BOTH the code and the sentinel, which inverts the "code OR
|
|
// message" style of the helpers above. That is deliberate, and must not be
|
|
// "simplified" to a code check: codes.NotFound is not exclusively ours on the
|
|
// PredictOptions RPCs. backend/python/insightface/backend.py:127 returns
|
|
// NOT_FOUND "no face detected" from Embedding, and a code-only check would
|
|
// make the router drop a healthy replica row on every faceless image.
|
|
//
|
|
// Unlike IsModelNotLoaded, a false positive here is NOT harmless in the same
|
|
// way, which is the other half of why the sentinel is mandatory.
|
|
func IsModelMismatch(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if status.Code(err) != codes.NotFound {
|
|
return false
|
|
}
|
|
return strings.Contains(strings.ToLower(err.Error()), ModelMismatchSentinel)
|
|
}
|
|
|
|
// LiveTranscriptionUnsupported returns the canonical error a backend returns
|
|
// when it (or the loaded model) cannot serve the bidirectional
|
|
// AudioTranscriptionLive RPC. It carries codes.Unimplemented deliberately:
|
|
// that is also what gRPC itself returns for backends whose stubs predate the
|
|
// RPC, so callers get one uniform "degrade to non-live transcription" signal.
|
|
// (codes.FailedPrecondition is not used here — IsModelNotLoaded claims it.)
|
|
func LiveTranscriptionUnsupported(backend, reason string) error {
|
|
return status.Errorf(codes.Unimplemented, "%s: live transcription unsupported: %s", backend, reason)
|
|
}
|
|
|
|
// IsLiveTranscriptionUnsupported reports whether err signals that live
|
|
// transcription is not available for this backend/model. It prefers the typed
|
|
// gRPC status code (Unimplemented) and falls back to the message for paths
|
|
// that lose the status (e.g. errors wrapped across non-gRPC boundaries).
|
|
func IsLiveTranscriptionUnsupported(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if status.Code(err) == codes.Unimplemented {
|
|
return true
|
|
}
|
|
return strings.Contains(strings.ToLower(err.Error()), "unimplemented")
|
|
}
|
|
|
|
// IsUnimplemented reports whether err is a gRPC Unimplemented status — the
|
|
// signal a backend gives for an RPC it does not implement. The generated
|
|
// UnimplementedBackendServer stub returns exactly this for any RPC a backend
|
|
// (e.g. a Python or external backend) has not overridden, so callers can treat
|
|
// an optional RPC as a no-op rather than a failure. Prefers the typed status
|
|
// code and falls back to the message for paths that lose the status (e.g. errors
|
|
// wrapped across non-gRPC boundaries).
|
|
func IsUnimplemented(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if status.Code(err) == codes.Unimplemented {
|
|
return true
|
|
}
|
|
return strings.Contains(strings.ToLower(err.Error()), "unimplemented")
|
|
}
|
|
|
|
// StreamTranscriptionUnsupported returns the canonical error a backend returns
|
|
// when it (or the loaded model) cannot serve the server-streaming
|
|
// AudioTranscriptionStream RPC. It carries codes.Unimplemented like the live
|
|
// signal, but its intent is the opposite: it is meant to be SURFACED to the
|
|
// caller, not silently degraded. A backend must not decode the audio offline
|
|
// and emit it as a single "delta" + final to fake a stream — a client that
|
|
// asked for streaming has to learn the model cannot stream (qualitatively
|
|
// identical output would otherwise hide a missing, possibly required,
|
|
// capability). Callers wanting a plain transcript use the unary
|
|
// AudioTranscription / non-streaming endpoint instead.
|
|
func StreamTranscriptionUnsupported(backend, reason string) error {
|
|
return status.Errorf(codes.Unimplemented, "%s: streaming transcription unsupported: %s", backend, reason)
|
|
}
|