mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
#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>
227 lines
9.1 KiB
Go
227 lines
9.1 KiB
Go
package grpc
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/mudler/LocalAI/pkg/grpc/base"
|
|
"github.com/mudler/LocalAI/pkg/grpc/grpcerrors"
|
|
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// modalityBackend answers every non-PredictOptions inference RPC successfully
|
|
// and counts the calls that reached it. `served` is therefore the signal for
|
|
// "the identity guard let this through" — the same role it plays in
|
|
// model_identity_test.go for the four PredictOptions RPCs.
|
|
type modalityBackend struct {
|
|
base.SingleThread
|
|
|
|
loaded string
|
|
served int
|
|
}
|
|
|
|
func (b *modalityBackend) Load(opts *pb.ModelOptions) error {
|
|
b.loaded = opts.Model
|
|
return nil
|
|
}
|
|
|
|
func (b *modalityBackend) GenerateImage(*pb.GenerateImageRequest) error { b.served++; return nil }
|
|
func (b *modalityBackend) GenerateVideo(*pb.GenerateVideoRequest) error { b.served++; return nil }
|
|
func (b *modalityBackend) TTS(*pb.TTSRequest) error { b.served++; return nil }
|
|
|
|
func (b *modalityBackend) TTSStream(_ *pb.TTSRequest, ch chan []byte) error {
|
|
b.served++
|
|
ch <- []byte{0}
|
|
close(ch)
|
|
return nil
|
|
}
|
|
|
|
func (b *modalityBackend) SoundGeneration(*pb.SoundGenerationRequest) error { b.served++; return nil }
|
|
|
|
func (b *modalityBackend) AudioTranscription(context.Context, *pb.TranscriptRequest) (pb.TranscriptResult, error) {
|
|
b.served++
|
|
return pb.TranscriptResult{Text: "ok"}, nil
|
|
}
|
|
|
|
func (b *modalityBackend) AudioTranscriptionStream(_ context.Context, _ *pb.TranscriptRequest, ch chan *pb.TranscriptStreamResponse) error {
|
|
b.served++
|
|
ch <- &pb.TranscriptStreamResponse{Delta: "ok"}
|
|
close(ch)
|
|
return nil
|
|
}
|
|
|
|
func (b *modalityBackend) Detect(*pb.DetectOptions) (pb.DetectResponse, error) {
|
|
b.served++
|
|
return pb.DetectResponse{}, nil
|
|
}
|
|
|
|
func (b *modalityBackend) Depth(*pb.DepthRequest) (pb.DepthResponse, error) {
|
|
b.served++
|
|
return pb.DepthResponse{}, nil
|
|
}
|
|
|
|
func (b *modalityBackend) FaceVerify(*pb.FaceVerifyRequest) (pb.FaceVerifyResponse, error) {
|
|
b.served++
|
|
return pb.FaceVerifyResponse{}, nil
|
|
}
|
|
|
|
func (b *modalityBackend) FaceAnalyze(*pb.FaceAnalyzeRequest) (pb.FaceAnalyzeResponse, error) {
|
|
b.served++
|
|
return pb.FaceAnalyzeResponse{}, nil
|
|
}
|
|
|
|
func (b *modalityBackend) VoiceVerify(*pb.VoiceVerifyRequest) (pb.VoiceVerifyResponse, error) {
|
|
b.served++
|
|
return pb.VoiceVerifyResponse{}, nil
|
|
}
|
|
|
|
func (b *modalityBackend) VoiceAnalyze(*pb.VoiceAnalyzeRequest) (pb.VoiceAnalyzeResponse, error) {
|
|
b.served++
|
|
return pb.VoiceAnalyzeResponse{}, nil
|
|
}
|
|
|
|
func (b *modalityBackend) VoiceEmbed(*pb.VoiceEmbedRequest) (pb.VoiceEmbedResponse, error) {
|
|
b.served++
|
|
return pb.VoiceEmbedResponse{}, nil
|
|
}
|
|
|
|
func (b *modalityBackend) VAD(*pb.VADRequest) (pb.VADResponse, error) {
|
|
b.served++
|
|
return pb.VADResponse{}, nil
|
|
}
|
|
|
|
func (b *modalityBackend) Diarize(*pb.DiarizeRequest) (pb.DiarizeResponse, error) {
|
|
b.served++
|
|
return pb.DiarizeResponse{}, nil
|
|
}
|
|
|
|
func (b *modalityBackend) SoundDetection(context.Context, *pb.SoundDetectionRequest) (*pb.SoundDetectionResponse, error) {
|
|
b.served++
|
|
return &pb.SoundDetectionResponse{}, nil
|
|
}
|
|
|
|
func (b *modalityBackend) AudioTransform(*pb.AudioTransformRequest) (*pb.AudioTransformResult, error) {
|
|
b.served++
|
|
return &pb.AudioTransformResult{}, nil
|
|
}
|
|
|
|
var _ AIModel = (*modalityBackend)(nil)
|
|
|
|
// callAllModalities exercises every remaining inference RPC that the generic Go
|
|
// server implements, sending `identity` in each request's ModelIdentity field.
|
|
// One call per RPC, so the returned error count is also the RPC count.
|
|
//
|
|
// Rerank, Score and TokenClassify are absent on purpose: the generic Go server
|
|
// does not implement them (they fall through to UnimplementedBackendServer), so
|
|
// only the C++ and Python backends can enforce them.
|
|
func callAllModalities(c Backend, identity string) map[string]error {
|
|
ctx := context.Background()
|
|
errs := map[string]error{}
|
|
|
|
_, errs["GenerateImage"] = c.GenerateImage(ctx, &pb.GenerateImageRequest{ModelIdentity: identity})
|
|
_, errs["GenerateVideo"] = c.GenerateVideo(ctx, &pb.GenerateVideoRequest{ModelIdentity: identity})
|
|
_, errs["TTS"] = c.TTS(ctx, &pb.TTSRequest{ModelIdentity: identity})
|
|
errs["TTSStream"] = c.TTSStream(ctx, &pb.TTSRequest{ModelIdentity: identity}, func(*pb.Reply) {})
|
|
_, errs["SoundGeneration"] = c.SoundGeneration(ctx, &pb.SoundGenerationRequest{ModelIdentity: identity})
|
|
_, errs["AudioTranscription"] = c.AudioTranscription(ctx, &pb.TranscriptRequest{ModelIdentity: identity})
|
|
errs["AudioTranscriptionStream"] = c.AudioTranscriptionStream(ctx, &pb.TranscriptRequest{ModelIdentity: identity}, func(*pb.TranscriptStreamResponse) {})
|
|
_, errs["Detect"] = c.Detect(ctx, &pb.DetectOptions{ModelIdentity: identity})
|
|
_, errs["Depth"] = c.Depth(ctx, &pb.DepthRequest{ModelIdentity: identity})
|
|
_, errs["FaceVerify"] = c.FaceVerify(ctx, &pb.FaceVerifyRequest{ModelIdentity: identity})
|
|
_, errs["FaceAnalyze"] = c.FaceAnalyze(ctx, &pb.FaceAnalyzeRequest{ModelIdentity: identity})
|
|
_, errs["VoiceVerify"] = c.VoiceVerify(ctx, &pb.VoiceVerifyRequest{ModelIdentity: identity})
|
|
_, errs["VoiceAnalyze"] = c.VoiceAnalyze(ctx, &pb.VoiceAnalyzeRequest{ModelIdentity: identity})
|
|
_, errs["VoiceEmbed"] = c.VoiceEmbed(ctx, &pb.VoiceEmbedRequest{ModelIdentity: identity})
|
|
_, errs["VAD"] = c.VAD(ctx, &pb.VADRequest{ModelIdentity: identity})
|
|
_, errs["Diarize"] = c.Diarize(ctx, &pb.DiarizeRequest{ModelIdentity: identity})
|
|
_, errs["SoundDetection"] = c.SoundDetection(ctx, &pb.SoundDetectionRequest{ModelIdentity: identity})
|
|
_, errs["AudioTransform"] = c.AudioTransform(ctx, &pb.AudioTransformRequest{ModelIdentity: identity})
|
|
|
|
return errs
|
|
}
|
|
|
|
// #10970 protected only the four PredictOptions RPCs. Every other modality
|
|
// shares the exposure — the controller's cached row names a host:port, the port
|
|
// can be recycled by another model's backend, and a liveness-only probe cannot
|
|
// tell the difference — so each one must reject a request naming another model
|
|
// rather than answer from whatever it holds.
|
|
var _ = Describe("modality RPC model identity guard", func() {
|
|
newServed := func(addr, loadedModel string) (Backend, *modalityBackend) {
|
|
b := &modalityBackend{}
|
|
Provide(addr, b)
|
|
c := NewClient(addr, true, nil, false)
|
|
_, err := c.LoadModel(context.Background(), &pb.ModelOptions{Model: loadedModel})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(b.loaded).To(Equal(loadedModel))
|
|
return c, b
|
|
}
|
|
|
|
It("rejects every modality RPC when the identity names another model", func() {
|
|
c, b := newServed("test://modality-mismatch", "a.gguf")
|
|
|
|
for rpc, err := range callAllModalities(c, "b.gguf") {
|
|
Expect(err).To(HaveOccurred(), "%s must reject a wrong-model request", rpc)
|
|
Expect(grpcerrors.IsModelMismatch(err)).To(BeTrue(), "%s: want a mismatch error, got %v", rpc, err)
|
|
// The router reacts differently to the two signals, so a mismatch
|
|
// must never be mistaken for a not-loaded.
|
|
Expect(grpcerrors.IsModelNotLoaded(err)).To(BeFalse(), "%s", rpc)
|
|
}
|
|
Expect(b.served).To(Equal(0), "no request may reach the model on a mismatch")
|
|
})
|
|
|
|
It("serves when the identity matches the loaded model", func() {
|
|
c, b := newServed("test://modality-match", "a.gguf")
|
|
|
|
errs := callAllModalities(c, "a.gguf")
|
|
for rpc, err := range errs {
|
|
Expect(err).ToNot(HaveOccurred(), "%s", rpc)
|
|
}
|
|
Expect(b.served).To(Equal(len(errs)))
|
|
})
|
|
|
|
// Compatibility, old controller -> new backend. Every existing deployment
|
|
// sends no identity, and tests/e2e-backends/backend_test.go drives real
|
|
// backends with bare request structs at many call sites. Tightening this
|
|
// breaks all of them, so it must fail here first.
|
|
It("serves when the request carries no identity", func() {
|
|
c, b := newServed("test://modality-empty-request", "a.gguf")
|
|
|
|
errs := callAllModalities(c, "")
|
|
for rpc, err := range errs {
|
|
Expect(err).ToNot(HaveOccurred(), "%s", rpc)
|
|
}
|
|
Expect(b.served).To(Equal(len(errs)))
|
|
})
|
|
|
|
// The backend side of the same rule: a model loaded without an identity
|
|
// (an old controller did the load) cannot judge anything, so it must serve.
|
|
It("serves when the backend has no recorded identity", func() {
|
|
c, b := newServed("test://modality-empty-loaded", "")
|
|
|
|
errs := callAllModalities(c, "b.gguf")
|
|
for rpc, err := range errs {
|
|
Expect(err).ToNot(HaveOccurred(), "%s", rpc)
|
|
}
|
|
Expect(b.served).To(Equal(len(errs)))
|
|
})
|
|
|
|
// AudioEncode/AudioDecode are deliberately NOT guarded: the opus codec
|
|
// backend they target is loaded with a literal model.WithModel("opus") and
|
|
// has no ModelConfig, so there is no value with the structural guarantee
|
|
// the rest of this mechanism depends on. Sending an identity that merely
|
|
// looks right would be convention, not construction — the exact
|
|
// false-rejection risk #10970 refused to take. Pinned here so a future
|
|
// "completeness" pass has to make that decision deliberately.
|
|
It("leaves the codec RPCs unguarded", func() {
|
|
c, _ := newServed("test://modality-codec", "a.gguf")
|
|
|
|
// Whatever these answer, it must never be a model mismatch: no
|
|
// identity is carried, so nothing is compared.
|
|
_, err := c.AudioEncode(context.Background(), &pb.AudioEncodeRequest{})
|
|
Expect(grpcerrors.IsModelMismatch(err)).To(BeFalse())
|
|
_, err = c.AudioDecode(context.Background(), &pb.AudioDecodeRequest{})
|
|
Expect(grpcerrors.IsModelMismatch(err)).To(BeFalse())
|
|
})
|
|
})
|