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:
mudler's LocalAI [bot]
2026-07-20 21:58:19 +02:00
committed by GitHub
parent a784cf669f
commit 1cd7d63c7b
29 changed files with 1104 additions and 85 deletions

View File

@@ -83,6 +83,7 @@ func ModelAudioTransform(
}
res, err := transformModel.AudioTransform(ctx, &proto.AudioTransformRequest{
ModelIdentity: modelConfig.Model,
AudioPath: audioPath,
ReferencePath: referencePath,
Dst: dst,

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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),

View 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")
})
})

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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())
})
})

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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 {

View File

@@ -39,7 +39,8 @@ func VoiceEmbed(
}
res, err := voiceModel.VoiceEmbed(ctx, &proto.VoiceEmbedRequest{
Audio: audioPath,
ModelIdentity: modelConfig.Model,
Audio: audioPath,
})
if appConfig.EnableTracing {

View File

@@ -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 {