mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
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>
This commit is contained in:
committed by
GitHub
parent
a784cf669f
commit
1cd7d63c7b
@@ -83,6 +83,7 @@ func ModelAudioTransform(
|
||||
}
|
||||
|
||||
res, err := transformModel.AudioTransform(ctx, &proto.AudioTransformRequest{
|
||||
ModelIdentity: modelConfig.Model,
|
||||
AudioPath: audioPath,
|
||||
ReferencePath: referencePath,
|
||||
Dst: dst,
|
||||
|
||||
@@ -40,6 +40,10 @@ func Depth(
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
// Stamped here for the same reason as in rerank.go: the caller builds the
|
||||
// request without a ModelConfig, this function has the one that loaded.
|
||||
in.ModelIdentity = modelConfig.Model
|
||||
|
||||
res, err := depthModel.Depth(ctx, in)
|
||||
|
||||
if appConfig.EnableTracing {
|
||||
|
||||
@@ -40,11 +40,12 @@ func Detection(
|
||||
}
|
||||
|
||||
res, err := detectionModel.Detect(ctx, &proto.DetectOptions{
|
||||
Src: sourceFile,
|
||||
Prompt: prompt,
|
||||
Points: points,
|
||||
Boxes: boxes,
|
||||
Threshold: threshold,
|
||||
ModelIdentity: modelConfig.Model,
|
||||
Src: sourceFile,
|
||||
Prompt: prompt,
|
||||
Points: points,
|
||||
Boxes: boxes,
|
||||
Threshold: threshold,
|
||||
})
|
||||
|
||||
if appConfig.EnableTracing {
|
||||
|
||||
@@ -19,19 +19,21 @@ import (
|
||||
// don't act on. IncludeText only matters for backends that emit
|
||||
// per-segment transcripts as a by-product (e.g. vibevoice.cpp).
|
||||
type DiarizationRequest struct {
|
||||
Audio string
|
||||
Language string
|
||||
NumSpeakers int32
|
||||
MinSpeakers int32
|
||||
MaxSpeakers int32
|
||||
ClusteringThreshold float32
|
||||
MinDurationOn float32
|
||||
MinDurationOff float32
|
||||
IncludeText bool
|
||||
Audio string
|
||||
Language string
|
||||
NumSpeakers int32
|
||||
MinSpeakers int32
|
||||
MaxSpeakers int32
|
||||
ClusteringThreshold float32
|
||||
MinDurationOn float32
|
||||
MinDurationOff float32
|
||||
IncludeText bool
|
||||
}
|
||||
|
||||
func (r *DiarizationRequest) toProto(threads uint32) *proto.DiarizeRequest {
|
||||
// modelIdentity: see the note on TranscriptionRequest.toProto.
|
||||
func (r *DiarizationRequest) toProto(threads uint32, modelIdentity string) *proto.DiarizeRequest {
|
||||
return &proto.DiarizeRequest{
|
||||
ModelIdentity: modelIdentity,
|
||||
Dst: r.Audio,
|
||||
Threads: threads,
|
||||
Language: r.Language,
|
||||
@@ -74,7 +76,7 @@ func ModelDiarization(ctx context.Context, req DiarizationRequest, ml *model.Mod
|
||||
threads = uint32(*modelConfig.Threads)
|
||||
}
|
||||
|
||||
r, err := m.Diarize(ctx, req.toProto(threads))
|
||||
r, err := m.Diarize(ctx, req.toProto(threads, modelConfig.Model))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -37,9 +37,10 @@ func FaceAnalyze(
|
||||
}
|
||||
|
||||
res, err := faceModel.FaceAnalyze(ctx, &proto.FaceAnalyzeRequest{
|
||||
Img: img,
|
||||
Actions: actions,
|
||||
AntiSpoofing: antiSpoofing,
|
||||
ModelIdentity: modelConfig.Model,
|
||||
Img: img,
|
||||
Actions: actions,
|
||||
AntiSpoofing: antiSpoofing,
|
||||
})
|
||||
|
||||
if appConfig.EnableTracing {
|
||||
|
||||
@@ -37,10 +37,11 @@ func FaceVerify(
|
||||
}
|
||||
|
||||
res, err := faceModel.FaceVerify(ctx, &proto.FaceVerifyRequest{
|
||||
Img1: img1,
|
||||
Img2: img2,
|
||||
Threshold: threshold,
|
||||
AntiSpoofing: antiSpoofing,
|
||||
ModelIdentity: modelConfig.Model,
|
||||
Img1: img1,
|
||||
Img2: img2,
|
||||
Threshold: threshold,
|
||||
AntiSpoofing: antiSpoofing,
|
||||
})
|
||||
|
||||
if appConfig.EnableTracing {
|
||||
|
||||
@@ -29,6 +29,11 @@ func ImageGeneration(ctx context.Context, height, width, step, seed int, positiv
|
||||
_, err := inferenceModel.GenerateImage(
|
||||
ctx,
|
||||
&proto.GenerateImageRequest{
|
||||
// ModelIdentity is ModelConfig.Model — the SAME expression
|
||||
// ModelOptions feeds to model.WithModel above, which the
|
||||
// backend receives as ModelOptions.Model at LoadModel. Equal
|
||||
// by construction, so the check cannot false-reject (#10952).
|
||||
ModelIdentity: modelConfig.Model,
|
||||
Height: int32(height),
|
||||
Width: int32(width),
|
||||
Step: int32(step),
|
||||
|
||||
346
core/backend/model_identity_modalities_test.go
Normal file
346
core/backend/model_identity_modalities_test.go
Normal file
@@ -0,0 +1,346 @@
|
||||
package backend_test
|
||||
|
||||
// Functional specs for the model identity every modality request must carry
|
||||
// (#10952, extending #10970 beyond the four PredictOptions RPCs).
|
||||
//
|
||||
// The whole mechanism is only safe because the predict-time identity is the
|
||||
// SAME expression as the load-time one: ModelOptions passes
|
||||
// model.WithModel(c.Model), which becomes ModelOptions.Model at LoadModel, and
|
||||
// each helper below builds its request in the same function from the same
|
||||
// config value. If a helper ever sends ModelID()/Name or a resolved file path
|
||||
// instead, every request to a correctly-routed backend gets rejected — so the
|
||||
// config here deliberately gives Model, Name and ModelID() three different
|
||||
// values, and these specs assert on the recorded wire value rather than on the
|
||||
// construction site.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
grpcPkg "github.com/mudler/LocalAI/pkg/grpc"
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
"github.com/mudler/LocalAI/pkg/system"
|
||||
ggrpc "google.golang.org/grpc"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const (
|
||||
identityModelFile = "qwen/actual-weights.gguf"
|
||||
identityModelName = "friendly-name"
|
||||
)
|
||||
|
||||
// recordingBackend captures the ModelIdentity each modality request arrives
|
||||
// with. It embeds grpc.Backend so only the methods under test need declaring;
|
||||
// anything else would nil-panic, which is the desired signal if a spec starts
|
||||
// calling an unexpected RPC.
|
||||
type recordingBackend struct {
|
||||
grpcPkg.Backend
|
||||
|
||||
seen map[string]string
|
||||
|
||||
// SoundGenerationRequest carries both `model` (staged path) and
|
||||
// `ModelIdentity`; the spec below asserts they are populated independently.
|
||||
soundGenModelField string
|
||||
}
|
||||
|
||||
func newRecordingBackend() *recordingBackend {
|
||||
return &recordingBackend{seen: map[string]string{}}
|
||||
}
|
||||
|
||||
// The loader probes liveness before handing the client back, so these two must
|
||||
// answer even though no spec asserts on them.
|
||||
func (r *recordingBackend) HealthCheck(context.Context) (bool, error) { return true, nil }
|
||||
func (r *recordingBackend) IsBusy() bool { return false }
|
||||
|
||||
func (r *recordingBackend) record(rpc string, in interface{ GetModelIdentity() string }) {
|
||||
r.seen[rpc] = in.GetModelIdentity()
|
||||
}
|
||||
|
||||
func (r *recordingBackend) GenerateImage(_ context.Context, in *pb.GenerateImageRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
|
||||
r.record("GenerateImage", in)
|
||||
return &pb.Result{Success: true}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) SoundGeneration(_ context.Context, in *pb.SoundGenerationRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
|
||||
r.record("SoundGeneration", in)
|
||||
r.soundGenModelField = in.GetModel()
|
||||
return &pb.Result{Success: true}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) GenerateVideo(_ context.Context, in *pb.GenerateVideoRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
|
||||
r.record("GenerateVideo", in)
|
||||
return &pb.Result{Success: true}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) Detect(_ context.Context, in *pb.DetectOptions, _ ...ggrpc.CallOption) (*pb.DetectResponse, error) {
|
||||
r.record("Detect", in)
|
||||
return &pb.DetectResponse{}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) Depth(_ context.Context, in *pb.DepthRequest, _ ...ggrpc.CallOption) (*pb.DepthResponse, error) {
|
||||
r.record("Depth", in)
|
||||
return &pb.DepthResponse{}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) VAD(_ context.Context, in *pb.VADRequest, _ ...ggrpc.CallOption) (*pb.VADResponse, error) {
|
||||
r.record("VAD", in)
|
||||
return &pb.VADResponse{}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) Diarize(_ context.Context, in *pb.DiarizeRequest, _ ...ggrpc.CallOption) (*pb.DiarizeResponse, error) {
|
||||
r.record("Diarize", in)
|
||||
return &pb.DiarizeResponse{}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) SoundDetection(_ context.Context, in *pb.SoundDetectionRequest, _ ...ggrpc.CallOption) (*pb.SoundDetectionResponse, error) {
|
||||
r.record("SoundDetection", in)
|
||||
return &pb.SoundDetectionResponse{}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) AudioTranscription(_ context.Context, in *pb.TranscriptRequest, _ ...ggrpc.CallOption) (*pb.TranscriptResult, error) {
|
||||
r.record("AudioTranscription", in)
|
||||
return &pb.TranscriptResult{}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) AudioTranscriptionStream(_ context.Context, in *pb.TranscriptRequest, _ func(*pb.TranscriptStreamResponse), _ ...ggrpc.CallOption) error {
|
||||
r.record("AudioTranscriptionStream", in)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) Rerank(_ context.Context, in *pb.RerankRequest, _ ...ggrpc.CallOption) (*pb.RerankResult, error) {
|
||||
r.record("Rerank", in)
|
||||
return &pb.RerankResult{Usage: &pb.Usage{}}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) Score(_ context.Context, in *pb.ScoreRequest, _ ...ggrpc.CallOption) (*pb.ScoreResponse, error) {
|
||||
r.record("Score", in)
|
||||
return &pb.ScoreResponse{}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) TokenClassify(_ context.Context, in *pb.TokenClassifyRequest, _ ...ggrpc.CallOption) (*pb.TokenClassifyResponse, error) {
|
||||
r.record("TokenClassify", in)
|
||||
return &pb.TokenClassifyResponse{}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) FaceVerify(_ context.Context, in *pb.FaceVerifyRequest, _ ...ggrpc.CallOption) (*pb.FaceVerifyResponse, error) {
|
||||
r.record("FaceVerify", in)
|
||||
return &pb.FaceVerifyResponse{}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) FaceAnalyze(_ context.Context, in *pb.FaceAnalyzeRequest, _ ...ggrpc.CallOption) (*pb.FaceAnalyzeResponse, error) {
|
||||
r.record("FaceAnalyze", in)
|
||||
return &pb.FaceAnalyzeResponse{}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) VoiceVerify(_ context.Context, in *pb.VoiceVerifyRequest, _ ...ggrpc.CallOption) (*pb.VoiceVerifyResponse, error) {
|
||||
r.record("VoiceVerify", in)
|
||||
return &pb.VoiceVerifyResponse{}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) VoiceAnalyze(_ context.Context, in *pb.VoiceAnalyzeRequest, _ ...ggrpc.CallOption) (*pb.VoiceAnalyzeResponse, error) {
|
||||
r.record("VoiceAnalyze", in)
|
||||
return &pb.VoiceAnalyzeResponse{}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) AudioTransform(_ context.Context, in *pb.AudioTransformRequest, _ ...ggrpc.CallOption) (*pb.AudioTransformResult, error) {
|
||||
r.record("AudioTransform", in)
|
||||
return &pb.AudioTransformResult{}, nil
|
||||
}
|
||||
|
||||
func (r *recordingBackend) VoiceEmbed(_ context.Context, in *pb.VoiceEmbedRequest, _ ...ggrpc.CallOption) (*pb.VoiceEmbedResponse, error) {
|
||||
r.record("VoiceEmbed", in)
|
||||
return &pb.VoiceEmbedResponse{Embedding: []float32{1}}, nil
|
||||
}
|
||||
|
||||
// newRecordingLoader wires a ModelLoader whose router hands back the recording
|
||||
// backend, so every helper below reaches it through the real Load path.
|
||||
func newRecordingLoader(rec *recordingBackend) *model.ModelLoader {
|
||||
loader := model.NewModelLoader(&system.SystemState{})
|
||||
loader.SetModelRouter(func(_ context.Context, id string, _, _, _ string, _ *pb.ModelOptions, _ bool) (*model.Model, error) {
|
||||
return model.NewModelWithClient(id, "test://recording", rec), nil
|
||||
})
|
||||
return loader
|
||||
}
|
||||
|
||||
func identityModelCfg() config.ModelConfig {
|
||||
threads := 1
|
||||
cfg := config.ModelConfig{
|
||||
Name: identityModelName,
|
||||
Backend: "stub-backend",
|
||||
Threads: &threads,
|
||||
}
|
||||
cfg.SetDefaults()
|
||||
cfg.Name = identityModelName
|
||||
cfg.Model = identityModelFile
|
||||
return cfg
|
||||
}
|
||||
|
||||
var _ = Describe("modality requests carry the model identity", func() {
|
||||
var (
|
||||
rec *recordingBackend
|
||||
loader *model.ModelLoader
|
||||
appCfg *config.ApplicationConfig
|
||||
cfg config.ModelConfig
|
||||
ctx context.Context
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
rec = newRecordingBackend()
|
||||
loader = newRecordingLoader(rec)
|
||||
appCfg = config.NewApplicationConfig(config.WithSystemState(&system.SystemState{}))
|
||||
cfg = identityModelCfg()
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
// Sanity: the three candidate values really are distinct here, so an
|
||||
// assertion on ModelConfig.Model cannot pass by coincidence.
|
||||
It("uses a config whose Model, Name and ModelID differ", func() {
|
||||
Expect(cfg.Model).To(Equal(identityModelFile))
|
||||
Expect(cfg.Name).To(Equal(identityModelName))
|
||||
Expect(cfg.ModelID()).ToNot(Equal(cfg.Model))
|
||||
})
|
||||
|
||||
expectIdentity := func(rpc string) {
|
||||
Expect(rec.seen).To(HaveKey(rpc), "%s never reached the backend", rpc)
|
||||
Expect(rec.seen[rpc]).To(Equal(identityModelFile),
|
||||
"%s must send ModelConfig.Model, the value LoadModel receives", rpc)
|
||||
}
|
||||
|
||||
It("ImageGeneration", func() {
|
||||
fn, err := backend.ImageGeneration(ctx, 1, 1, 1, 1, "p", "n", "", "", loader, cfg, appCfg, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fn()).To(Succeed())
|
||||
expectIdentity("GenerateImage")
|
||||
})
|
||||
|
||||
It("VideoGeneration", func() {
|
||||
fn, err := backend.VideoGeneration(backend.VideoGenerationOptions{Prompt: "p"}, loader, cfg, appCfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fn()).To(Succeed())
|
||||
expectIdentity("GenerateVideo")
|
||||
})
|
||||
|
||||
It("Detection", func() {
|
||||
_, err := backend.Detection(ctx, "src.png", "", nil, nil, 0, loader, appCfg, cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("Detect")
|
||||
})
|
||||
|
||||
It("Depth", func() {
|
||||
_, err := backend.Depth(ctx, &pb.DepthRequest{Src: "src.png"}, loader, appCfg, cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("Depth")
|
||||
})
|
||||
|
||||
It("VAD", func() {
|
||||
_, err := backend.VAD(&schema.VADRequest{Audio: []float32{0}}, ctx, loader, appCfg, cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("VAD")
|
||||
})
|
||||
|
||||
It("ModelDiarization", func() {
|
||||
_, err := backend.ModelDiarization(ctx, backend.DiarizationRequest{Audio: "a.wav"}, loader, cfg, appCfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("Diarize")
|
||||
})
|
||||
|
||||
It("ModelSoundDetection", func() {
|
||||
_, err := backend.ModelSoundDetection(ctx, backend.SoundDetectionRequest{Audio: "a.wav"}, loader, cfg, appCfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("SoundDetection")
|
||||
})
|
||||
|
||||
It("ModelTranscription", func() {
|
||||
_, err := backend.ModelTranscription(ctx, "a.wav", "en", false, false, "", loader, cfg, appCfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("AudioTranscription")
|
||||
})
|
||||
|
||||
It("ModelTranscriptionStream", func() {
|
||||
err := backend.ModelTranscriptionStream(ctx, backend.TranscriptionRequest{Audio: "a.wav"}, loader, cfg, appCfg, func(backend.TranscriptionStreamChunk) {})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("AudioTranscriptionStream")
|
||||
})
|
||||
|
||||
It("Rerank", func() {
|
||||
_, err := backend.Rerank(ctx, &pb.RerankRequest{Query: "q", Documents: []string{"d"}}, loader, appCfg, cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("Rerank")
|
||||
})
|
||||
|
||||
It("ModelScore", func() {
|
||||
fn, err := backend.ModelScore("p", []string{"c"}, backend.ScoreOptions{}, loader, cfg, appCfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = fn(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("Score")
|
||||
})
|
||||
|
||||
It("ModelTokenClassify", func() {
|
||||
fn, err := backend.ModelTokenClassify("text", backend.TokenClassifyOptions{}, loader, cfg, appCfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = fn(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("TokenClassify")
|
||||
})
|
||||
|
||||
It("FaceVerify", func() {
|
||||
_, err := backend.FaceVerify(ctx, "img1", "img2", 0, false, loader, appCfg, cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("FaceVerify")
|
||||
})
|
||||
|
||||
It("FaceAnalyze", func() {
|
||||
_, err := backend.FaceAnalyze(ctx, "img", nil, false, loader, appCfg, cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("FaceAnalyze")
|
||||
})
|
||||
|
||||
It("VoiceVerify", func() {
|
||||
_, err := backend.VoiceVerify(ctx, "a1.wav", "a2.wav", 0, false, loader, appCfg, cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("VoiceVerify")
|
||||
})
|
||||
|
||||
It("VoiceAnalyze", func() {
|
||||
_, err := backend.VoiceAnalyze(ctx, "a.wav", nil, loader, appCfg, cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("VoiceAnalyze")
|
||||
})
|
||||
|
||||
// SoundGeneration is the second RPC (with TTS) whose request already had a
|
||||
// `model` field before this change. Identity must be a SEPARATE field: the
|
||||
// distributed file stager rewrites `model` to a worker-local path, so
|
||||
// comparing it would reject valid requests. Both must arrive populated.
|
||||
It("SoundGeneration", func() {
|
||||
appCfg.GeneratedContentDir = GinkgoT().TempDir()
|
||||
_, _, err := backend.SoundGeneration(ctx, "hello", nil, nil, nil, nil, nil, nil, "", "", nil, "", "", "", nil, loader, appCfg, cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("SoundGeneration")
|
||||
Expect(rec.soundGenModelField).To(Equal(identityModelFile),
|
||||
"the staged `model` field must still be sent, separately from the identity")
|
||||
})
|
||||
|
||||
It("ModelAudioTransform", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
appCfg.GeneratedContentDir = dir
|
||||
src := filepath.Join(dir, "in.wav")
|
||||
Expect(os.WriteFile(src, []byte("RIFF"), 0o600)).To(Succeed())
|
||||
_, _, err := backend.ModelAudioTransform(ctx, src, "", backend.AudioTransformOptions{}, loader, appCfg, cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("AudioTransform")
|
||||
})
|
||||
|
||||
It("VoiceEmbed", func() {
|
||||
_, err := backend.VoiceEmbed(ctx, "a.wav", loader, appCfg, cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
expectIdentity("VoiceEmbed")
|
||||
})
|
||||
})
|
||||
@@ -77,6 +77,11 @@ func Rerank(ctx context.Context, request *proto.RerankRequest, loader *model.Mod
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
// Stamped here, not at the HTTP handler: this is the function that also
|
||||
// builds ModelOptions from the same config, so the two values are equal by
|
||||
// construction (#10952).
|
||||
request.ModelIdentity = modelConfig.Model
|
||||
|
||||
res, err := rerankModel.Rerank(ctx, request)
|
||||
|
||||
if appConfig.EnableTracing {
|
||||
|
||||
@@ -98,6 +98,7 @@ func ModelScore(prompt string, candidates []string, opts ScoreOptions, loader *m
|
||||
startTime = time.Now()
|
||||
}
|
||||
resp, err := b.Score(ctx, &pb.ScoreRequest{
|
||||
ModelIdentity: modelConfig.Model,
|
||||
Prompt: prompt,
|
||||
Candidates: candidates,
|
||||
IncludeTokenLogprobs: opts.IncludeTokenLogprobs,
|
||||
|
||||
@@ -22,11 +22,13 @@ type SoundDetectionRequest struct {
|
||||
Threshold float32
|
||||
}
|
||||
|
||||
func (r *SoundDetectionRequest) toProto() *proto.SoundDetectionRequest {
|
||||
// modelIdentity: see the note on TranscriptionRequest.toProto.
|
||||
func (r *SoundDetectionRequest) toProto(modelIdentity string) *proto.SoundDetectionRequest {
|
||||
return &proto.SoundDetectionRequest{
|
||||
Src: r.Audio,
|
||||
TopK: r.TopK,
|
||||
Threshold: r.Threshold,
|
||||
ModelIdentity: modelIdentity,
|
||||
Src: r.Audio,
|
||||
TopK: r.TopK,
|
||||
Threshold: r.Threshold,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +56,7 @@ func ModelSoundDetection(ctx context.Context, req SoundDetectionRequest, ml *mod
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r, err := m.SoundDetection(ctx, req.toProto())
|
||||
r, err := m.SoundDetection(ctx, req.toProto(modelConfig.Model))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -62,14 +62,19 @@ func SoundGeneration(
|
||||
}
|
||||
|
||||
req := &proto.SoundGenerationRequest{
|
||||
Text: text,
|
||||
Model: modelConfig.Model,
|
||||
Dst: filePath,
|
||||
Sample: doSample,
|
||||
Duration: duration,
|
||||
Temperature: temperature,
|
||||
Src: sourceFile,
|
||||
SrcDivisor: sourceDivisor,
|
||||
Text: text,
|
||||
// Model is rewritten to a worker-local path by the distributed file
|
||||
// stager; ModelIdentity carries the untranslated value the backend
|
||||
// compares against what it loaded (#10952). They start out equal here
|
||||
// and must stay two separate fields.
|
||||
ModelIdentity: modelConfig.Model,
|
||||
Model: modelConfig.Model,
|
||||
Dst: filePath,
|
||||
Sample: doSample,
|
||||
Duration: duration,
|
||||
Temperature: temperature,
|
||||
Src: sourceFile,
|
||||
SrcDivisor: sourceDivisor,
|
||||
}
|
||||
if think != nil {
|
||||
req.Think = think
|
||||
|
||||
@@ -87,8 +87,9 @@ func ModelTokenClassify(text string, opts TokenClassifyOptions, loader *model.Mo
|
||||
startTime = time.Now()
|
||||
}
|
||||
resp, err := inferenceModel.TokenClassify(ctx, &pb.TokenClassifyRequest{
|
||||
Text: text,
|
||||
Threshold: opts.Threshold,
|
||||
ModelIdentity: modelConfig.Model,
|
||||
Text: text,
|
||||
Threshold: opts.Threshold,
|
||||
})
|
||||
entities := tokenClassifyResponseToEntities(resp)
|
||||
if appConfig.EnableTracing {
|
||||
|
||||
@@ -28,8 +28,14 @@ type TranscriptionRequest struct {
|
||||
TimestampGranularities []string
|
||||
}
|
||||
|
||||
func (r *TranscriptionRequest) toProto(threads uint32) *proto.TranscriptRequest {
|
||||
// modelIdentity is ModelConfig.Model, the value the backend received as
|
||||
// ModelOptions.Model at LoadModel, so it can reject a request that reached it
|
||||
// through a stale distributed route (#10952). It is a parameter rather than a
|
||||
// TranscriptionRequest field because the request is built by HTTP handlers that
|
||||
// have no ModelConfig, while every caller of this method does.
|
||||
func (r *TranscriptionRequest) toProto(threads uint32, modelIdentity string) *proto.TranscriptRequest {
|
||||
return &proto.TranscriptRequest{
|
||||
ModelIdentity: modelIdentity,
|
||||
Dst: r.Audio,
|
||||
Language: r.Language,
|
||||
Translate: r.Translate,
|
||||
@@ -85,7 +91,7 @@ func ModelTranscriptionWithOptions(ctx context.Context, req TranscriptionRequest
|
||||
audioSnippet = trace.AudioSnippet(req.Audio, appConfig.TracingMaxBodyBytes)
|
||||
}
|
||||
|
||||
r, err := transcriptionModel.AudioTranscription(ctx, req.toProto(uint32(*modelConfig.Threads)))
|
||||
r, err := transcriptionModel.AudioTranscription(ctx, req.toProto(uint32(*modelConfig.Threads), modelConfig.Model))
|
||||
if err != nil {
|
||||
if appConfig.EnableTracing {
|
||||
errData := map[string]any{
|
||||
@@ -158,7 +164,7 @@ func ModelTranscriptionStream(ctx context.Context, req TranscriptionRequest, ml
|
||||
return err
|
||||
}
|
||||
|
||||
pbReq := req.toProto(uint32(*modelConfig.Threads))
|
||||
pbReq := req.toProto(uint32(*modelConfig.Threads), modelConfig.Model)
|
||||
pbReq.Stream = true
|
||||
|
||||
return transcriptionModel.AudioTranscriptionStream(ctx, pbReq, func(chunk *proto.TranscriptStreamResponse) {
|
||||
|
||||
@@ -23,15 +23,22 @@ import (
|
||||
// newTTSRequest assembles the gRPC TTSRequest from the per-request inputs. The
|
||||
// optional instructions string is only attached when non-empty so backends can
|
||||
// distinguish "no per-request instruction" (fall back to YAML) from an explicit
|
||||
// empty one. params is forwarded as-is (nil when unset).
|
||||
func newTTSRequest(text, modelPath, voice, dst, language, instructions string, params map[string]string) *proto.TTSRequest {
|
||||
// empty one. params is forwarded as-is (nil when unset). modelIdentity is
|
||||
// ModelConfig.Model - deliberately not modelPath, see the field comment below.
|
||||
func newTTSRequest(text, modelPath, voice, dst, language, instructions string, params map[string]string, modelIdentity string) *proto.TTSRequest {
|
||||
req := &proto.TTSRequest{
|
||||
Text: text,
|
||||
Model: modelPath,
|
||||
Voice: voice,
|
||||
Dst: dst,
|
||||
Language: &language,
|
||||
Params: params,
|
||||
Text: text,
|
||||
// Model is the path the backend synthesises from, and the distributed
|
||||
// file stager rewrites it to a worker-local absolute path. Identity
|
||||
// must therefore be a SEPARATE, untranslated field: comparing Model
|
||||
// would reject valid requests in distributed mode, which is exactly
|
||||
// the configuration this guards (#10952).
|
||||
Model: modelPath,
|
||||
ModelIdentity: modelIdentity,
|
||||
Voice: voice,
|
||||
Dst: dst,
|
||||
Language: &language,
|
||||
Params: params,
|
||||
}
|
||||
if instructions != "" {
|
||||
req.Instructions = &instructions
|
||||
@@ -95,7 +102,7 @@ func ModelTTS(
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
ttsRequest := newTTSRequest(text, modelPath, voice, filePath, language, instructions, params)
|
||||
ttsRequest := newTTSRequest(text, modelPath, voice, filePath, language, instructions, params, modelConfig.Model)
|
||||
|
||||
res, err := ttsModel.TTS(ctx, ttsRequest)
|
||||
|
||||
@@ -197,7 +204,7 @@ func ModelTTSStream(
|
||||
snippetCapped := false
|
||||
|
||||
// Streaming TTS writes to the HTTP response, not a file, so dst is empty.
|
||||
ttsRequest := newTTSRequest(text, modelPath, voice, "", language, instructions, params)
|
||||
ttsRequest := newTTSRequest(text, modelPath, voice, "", language, instructions, params, modelConfig.Model)
|
||||
|
||||
err = ttsModel.TTSStream(ctx, ttsRequest, func(reply *proto.Reply) {
|
||||
// First message contains sample rate info
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
var _ = Describe("newTTSRequest", func() {
|
||||
It("attaches the instructions when a per-request value is set", func() {
|
||||
req := newTTSRequest("hi", "/m", "alloy", "/out.wav", "en", "cheerful narrator", nil)
|
||||
req := newTTSRequest("hi", "/m", "alloy", "/out.wav", "en", "cheerful narrator", nil, "m")
|
||||
Expect(req.Instructions).ToNot(BeNil())
|
||||
Expect(req.GetInstructions()).To(Equal("cheerful narrator"))
|
||||
Expect(req.GetText()).To(Equal("hi"))
|
||||
@@ -23,20 +23,39 @@ var _ = Describe("newTTSRequest", func() {
|
||||
})
|
||||
|
||||
It("leaves instructions unset when empty so backends fall back to YAML", func() {
|
||||
req := newTTSRequest("hi", "/m", "", "/out.wav", "", "", nil)
|
||||
req := newTTSRequest("hi", "/m", "", "/out.wav", "", "", nil, "m")
|
||||
Expect(req.Instructions).To(BeNil())
|
||||
Expect(req.GetInstructions()).To(Equal(""))
|
||||
})
|
||||
|
||||
It("forwards per-request params through to the backend", func() {
|
||||
params := map[string]string{"exaggeration": "0.7", "cfg_weight": "0.3"}
|
||||
req := newTTSRequest("hi", "/m", "", "/out.wav", "", "", params)
|
||||
req := newTTSRequest("hi", "/m", "", "/out.wav", "", "", params, "m")
|
||||
Expect(req.GetParams()).To(HaveKeyWithValue("exaggeration", "0.7"))
|
||||
Expect(req.GetParams()).To(HaveKeyWithValue("cfg_weight", "0.3"))
|
||||
})
|
||||
|
||||
It("leaves params nil when none are supplied", func() {
|
||||
req := newTTSRequest("hi", "/m", "", "/out.wav", "", "", nil)
|
||||
req := newTTSRequest("hi", "/m", "", "/out.wav", "", "", nil, "m")
|
||||
Expect(req.GetParams()).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
// TTSRequest carries TWO model strings and they are not interchangeable.
|
||||
// `Model` is a path that FileStagingClient.TTS rewrites into a worker-local
|
||||
// absolute path in distributed mode; `ModelIdentity` is the untranslated
|
||||
// ModelConfig.Model the backend compares against what it loaded (#10952).
|
||||
// Comparing `Model` instead would reject valid requests in exactly the
|
||||
// configuration this guards, so these specs pin them as separate inputs.
|
||||
var _ = Describe("newTTSRequest model identity", func() {
|
||||
It("carries the untranslated identity alongside the staged model path", func() {
|
||||
req := newTTSRequest("hi", "/worker/local/staged.onnx", "", "/out.wav", "", "", nil, "voices/piper-en.onnx")
|
||||
Expect(req.GetModelIdentity()).To(Equal("voices/piper-en.onnx"))
|
||||
Expect(req.GetModel()).To(Equal("/worker/local/staged.onnx"))
|
||||
})
|
||||
|
||||
It("keeps the identity empty when the config names no model, so the backend skips", func() {
|
||||
req := newTTSRequest("hi", "/m", "", "/out.wav", "", "", nil, "")
|
||||
Expect(req.GetModelIdentity()).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +25,8 @@ func VAD(request *schema.VADRequest,
|
||||
}
|
||||
|
||||
req := proto.VADRequest{
|
||||
Audio: request.Audio,
|
||||
ModelIdentity: modelConfig.Model,
|
||||
Audio: request.Audio,
|
||||
}
|
||||
resp, err := vadModel.VAD(ctx, &req)
|
||||
if err != nil {
|
||||
|
||||
@@ -41,6 +41,9 @@ func VideoGeneration(options VideoGenerationOptions, loader *model.ModelLoader,
|
||||
_, err := inferenceModel.GenerateVideo(
|
||||
appConfig.Context,
|
||||
&proto.GenerateVideoRequest{
|
||||
// See the ModelIdentity note in image.go: this is the value
|
||||
// ModelOptions passed to LoadModel a few lines above.
|
||||
ModelIdentity: modelConfig.Model,
|
||||
Height: options.Height,
|
||||
Width: options.Width,
|
||||
Prompt: options.Prompt,
|
||||
|
||||
@@ -36,8 +36,9 @@ func VoiceAnalyze(
|
||||
}
|
||||
|
||||
res, err := voiceModel.VoiceAnalyze(ctx, &proto.VoiceAnalyzeRequest{
|
||||
Audio: audio,
|
||||
Actions: actions,
|
||||
ModelIdentity: modelConfig.Model,
|
||||
Audio: audio,
|
||||
Actions: actions,
|
||||
})
|
||||
|
||||
if appConfig.EnableTracing {
|
||||
|
||||
@@ -39,7 +39,8 @@ func VoiceEmbed(
|
||||
}
|
||||
|
||||
res, err := voiceModel.VoiceEmbed(ctx, &proto.VoiceEmbedRequest{
|
||||
Audio: audioPath,
|
||||
ModelIdentity: modelConfig.Model,
|
||||
Audio: audioPath,
|
||||
})
|
||||
|
||||
if appConfig.EnableTracing {
|
||||
|
||||
@@ -37,10 +37,11 @@ func VoiceVerify(
|
||||
}
|
||||
|
||||
res, err := voiceModel.VoiceVerify(ctx, &proto.VoiceVerifyRequest{
|
||||
Audio1: audio1,
|
||||
Audio2: audio2,
|
||||
Threshold: threshold,
|
||||
AntiSpoofing: antiSpoofing,
|
||||
ModelIdentity: modelConfig.Model,
|
||||
Audio1: audio1,
|
||||
Audio2: audio2,
|
||||
Threshold: threshold,
|
||||
AntiSpoofing: antiSpoofing,
|
||||
})
|
||||
|
||||
if appConfig.EnableTracing {
|
||||
|
||||
Reference in New Issue
Block a user