Skip to content

Commit 1cd7d63

Browse files
localai-botmudler
andauthored
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>
1 parent a784cf6 commit 1cd7d63

29 files changed

Lines changed: 1104 additions & 85 deletions

backend/backend.proto

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ message MetricsResponse {
136136
message TokenClassifyRequest {
137137
string text = 1;
138138
float threshold = 2;
139+
// ModelIdentity names the model this request is for; see
140+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
141+
// identity supplied" and backends MUST skip the check.
142+
string ModelIdentity = 3;
139143
}
140144

141145
// TokenClassifyEntity is one detected entity span. Byte offsets are
@@ -173,6 +177,10 @@ message ScoreRequest {
173177
// candidates differ in length and the consumer wants a per-token
174178
// measure comparable across them (PMI-style scoring).
175179
bool length_normalize = 4;
180+
// ModelIdentity names the model this request is for; see
181+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
182+
// identity supplied" and backends MUST skip the check.
183+
string ModelIdentity = 5;
176184
}
177185

178186
// CandidateScore is one row in the ScoreResponse, matching by index
@@ -204,6 +212,10 @@ message RerankRequest {
204212
string query = 1;
205213
repeated string documents = 2;
206214
int32 top_n = 3;
215+
// ModelIdentity names the model this request is for; see
216+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
217+
// identity supplied" and backends MUST skip the check.
218+
string ModelIdentity = 4;
207219
}
208220

209221
message RerankResult {
@@ -336,7 +348,14 @@ message PredictOptions {
336348
// paths (core/services/nodes/file_staging_client.go), so in distributed mode
337349
// they already differ from the load-time value and comparing them would
338350
// reject valid requests. Extending identity to those RPCs needs a separate
339-
// field carrying the untranslated value.
351+
// field carrying the untranslated value - which is exactly what
352+
// TTSRequest.ModelIdentity and SoundGenerationRequest.ModelIdentity are.
353+
//
354+
// Every other request message that reaches a backend through the distributed
355+
// router now carries the same ModelIdentity field, populated from the same
356+
// ModelConfig.Model. FileStagingClient rewrites Src/Dst/Voice/Model/
357+
// StartImage/EndImage/Audio and never ModelIdentity, so what the backend
358+
// compares is always what the controller sent.
340359
string ModelIdentity = 54;
341360

342361
// 24 was never assigned; reserve it so it is not silently reused.
@@ -510,6 +529,10 @@ message TranscriptRequest {
510529
float temperature = 8;
511530
repeated string timestamp_granularities = 9;
512531
bool stream = 10;
532+
// ModelIdentity names the model this request is for; see
533+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
534+
// identity supplied" and backends MUST skip the check.
535+
string ModelIdentity = 11;
513536
}
514537

515538
message TranscriptResult {
@@ -588,6 +611,10 @@ message GenerateImageRequest {
588611

589612
// Reference images for models that support them (e.g., Flux Kontext)
590613
repeated string ref_images = 12;
614+
// ModelIdentity names the model this request is for; see
615+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
616+
// identity supplied" and backends MUST skip the check.
617+
string ModelIdentity = 13;
591618
}
592619

593620
message GenerateVideoRequest {
@@ -607,6 +634,10 @@ message GenerateVideoRequest {
607634
// Backend-specific per-request generation parameters. Values are strings
608635
// and are validated/coerced by the selected backend.
609636
map<string, string> params = 14;
637+
// ModelIdentity names the model this request is for; see
638+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
639+
// identity supplied" and backends MUST skip the check.
640+
string ModelIdentity = 15;
610641
}
611642

612643
message TTSRequest {
@@ -624,10 +655,26 @@ message TTSRequest {
624655
// (e.g. Chatterbox exaggeration/cfg_weight/temperature). Values are strings and
625656
// coerced by the backend; unset leaves the backend's configured defaults.
626657
map<string, string> params = 7;
658+
// ModelIdentity is a SEPARATE field from `model` above and carries the
659+
// UNTRANSLATED controller-side ModelConfig.Model, so a backend can reject a
660+
// request that reached it through a stale distributed route (#10952).
661+
//
662+
// `model` cannot be reused for this: FileStagingClient.TTS/.TTSStream and the
663+
// SoundGeneration path rewrite it into a worker-local absolute path
664+
// (core/services/nodes/file_staging_client.go), while the load-time value is
665+
// untranslated. In distributed mode - exactly the configuration this guards -
666+
// the two already differ, so comparing them would reject valid requests.
667+
//
668+
// Empty means "no identity supplied" and backends MUST skip the check.
669+
string ModelIdentity = 8;
627670
}
628671

629672
message VADRequest {
630673
repeated float audio = 1;
674+
// ModelIdentity names the model this request is for; see
675+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
676+
// identity supplied" and backends MUST skip the check.
677+
string ModelIdentity = 2;
631678
}
632679

633680
message VADSegment {
@@ -659,6 +706,10 @@ message DiarizeRequest {
659706
float min_duration_on = 8; // discard segments shorter than this (seconds); 0 = backend default
660707
float min_duration_off = 9; // merge gaps shorter than this (seconds); 0 = backend default
661708
bool include_text = 10; // when the backend can emit per-segment transcript for free, ask it to populate `text`
709+
// ModelIdentity names the model this request is for; see
710+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
711+
// identity supplied" and backends MUST skip the check.
712+
string ModelIdentity = 11;
662713
}
663714

664715
message DiarizeSegment {
@@ -693,6 +744,18 @@ message SoundGenerationRequest {
693744
optional string language = 14;
694745
optional string timesignature = 15;
695746
optional bool instrumental = 17;
747+
// ModelIdentity is a SEPARATE field from `model` above and carries the
748+
// UNTRANSLATED controller-side ModelConfig.Model, so a backend can reject a
749+
// request that reached it through a stale distributed route (#10952).
750+
//
751+
// `model` cannot be reused for this: FileStagingClient.TTS/.TTSStream and the
752+
// SoundGeneration path rewrite it into a worker-local absolute path
753+
// (core/services/nodes/file_staging_client.go), while the load-time value is
754+
// untranslated. In distributed mode - exactly the configuration this guards -
755+
// the two already differ, so comparing them would reject valid requests.
756+
//
757+
// Empty means "no identity supplied" and backends MUST skip the check.
758+
string ModelIdentity = 18;
696759
}
697760

698761
message TokenizationResponse {
@@ -732,6 +795,10 @@ message DetectOptions {
732795
repeated float points = 3; // Point coordinates as [x1, y1, label1, x2, y2, label2, ...] (label: 1=pos, 0=neg)
733796
repeated float boxes = 4; // Box coordinates as [x1, y1, x2, y2, ...]
734797
float threshold = 5; // Detection confidence threshold
798+
// ModelIdentity names the model this request is for; see
799+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
800+
// identity supplied" and backends MUST skip the check.
801+
string ModelIdentity = 6;
735802
}
736803

737804
message Detection {
@@ -754,6 +821,10 @@ message SoundDetectionRequest {
754821
string src = 1; // audio file path (LocalAI writes the upload to disk)
755822
int32 top_k = 2; // number of top tags to return (0 = all classes)
756823
float threshold = 3; // optional: drop tags scoring below this
824+
// ModelIdentity names the model this request is for; see
825+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
826+
// identity supplied" and backends MUST skip the check.
827+
string ModelIdentity = 4;
757828
}
758829

759830
message SoundClass {
@@ -778,6 +849,10 @@ message DepthRequest {
778849
bool include_points = 7; // back-project to a 3D point cloud (DualDPT)
779850
float points_conf_thresh = 8; // keep points with confidence >= this threshold
780851
repeated string exports = 9; // requested exports: "glb", "colmap"
852+
// ModelIdentity names the model this request is for; see
853+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
854+
// identity supplied" and backends MUST skip the check.
855+
string ModelIdentity = 10;
781856
}
782857

783858
message DepthResponse {
@@ -809,6 +884,10 @@ message FaceVerifyRequest {
809884
string img2 = 2; // base64-encoded image
810885
float threshold = 3; // cosine-distance threshold; 0 = use backend default
811886
bool anti_spoofing = 4; // run MiniFASNet liveness on each image; failed liveness forces verified=false
887+
// ModelIdentity names the model this request is for; see
888+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
889+
// identity supplied" and backends MUST skip the check.
890+
string ModelIdentity = 5;
812891
}
813892

814893
message FaceVerifyResponse {
@@ -830,6 +909,10 @@ message FaceAnalyzeRequest {
830909
string img = 1; // base64-encoded image
831910
repeated string actions = 2; // subset of ["age","gender","emotion","race"]; empty = all-supported
832911
bool anti_spoofing = 3;
912+
// ModelIdentity names the model this request is for; see
913+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
914+
// identity supplied" and backends MUST skip the check.
915+
string ModelIdentity = 4;
833916
}
834917

835918
message FaceAnalysis {
@@ -862,6 +945,10 @@ message VoiceVerifyRequest {
862945
string audio2 = 2; // path to second audio clip
863946
float threshold = 3; // cosine-distance threshold; 0 = use backend default
864947
bool anti_spoofing = 4; // reserved for future AASIST bolt-on
948+
// ModelIdentity names the model this request is for; see
949+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
950+
// identity supplied" and backends MUST skip the check.
951+
string ModelIdentity = 5;
865952
}
866953

867954
message VoiceVerifyResponse {
@@ -876,6 +963,10 @@ message VoiceVerifyResponse {
876963
message VoiceAnalyzeRequest {
877964
string audio = 1; // path to audio clip
878965
repeated string actions = 2; // subset of ["age","gender","emotion"]; empty = all-supported
966+
// ModelIdentity names the model this request is for; see
967+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
968+
// identity supplied" and backends MUST skip the check.
969+
string ModelIdentity = 3;
879970
}
880971

881972
message VoiceAnalysis {
@@ -894,6 +985,10 @@ message VoiceAnalyzeResponse {
894985

895986
message VoiceEmbedRequest {
896987
string audio = 1; // path to audio clip
988+
// ModelIdentity names the model this request is for; see
989+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
990+
// identity supplied" and backends MUST skip the check.
991+
string ModelIdentity = 2;
897992
}
898993

899994
message VoiceEmbedResponse {
@@ -988,6 +1083,10 @@ message AudioTransformRequest {
9881083
string reference_path = 2; // optional auxiliary; empty => zero-fill
9891084
string dst = 3; // required, output file path
9901085
map<string, string> params = 4; // backend-specific tuning
1086+
// ModelIdentity names the model this request is for; see
1087+
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
1088+
// identity supplied" and backends MUST skip the check.
1089+
string ModelIdentity = 5;
9911090
}
9921091

9931092
message AudioTransformResult {

backend/cpp/llama-cpp/grpc-server.cpp

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1416,7 +1416,11 @@ class BackendServiceImpl final : public backend::Backend::Service {
14161416
// field and for the synthetic PredictOptions this server builds internally
14171417
// for ASR, and the loaded side is empty when such a controller performed
14181418
// the load. A false rejection is worse than the miss it prevents.
1419-
grpc::Status checkModelIdentity(const backend::PredictOptions* request) {
1419+
// Templated over the request type: every guarded request message exposes
1420+
// modelidentity(), and one body keeps the rule identical across modalities
1421+
// rather than repeating it per RPC.
1422+
template <typename Request>
1423+
grpc::Status checkModelIdentity(const Request* request) {
14201424
if (request == nullptr || request->modelidentity().empty()) {
14211425
return grpc::Status::OK;
14221426
}
@@ -2846,6 +2850,8 @@ class BackendServiceImpl final : public backend::Backend::Service {
28462850
}
28472851

28482852
grpc::Status Rerank(ServerContext* context, const backend::RerankRequest* request, backend::RerankResult* rerankResult) override {
2853+
auto identity = checkModelIdentity(request);
2854+
if (!identity.ok()) return identity;
28492855
if (!params_base.embedding || params_base.pooling_type != LLAMA_POOLING_TYPE_RANK) {
28502856
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED, "This server does not support reranking. Start it with `--reranking` and without `--embedding`");
28512857
}
@@ -2970,6 +2976,8 @@ class BackendServiceImpl final : public backend::Backend::Service {
29702976
grpc::Status Score(ServerContext* context, const backend::ScoreRequest* request, backend::ScoreResponse* response) override {
29712977
auto auth = checkAuth(context);
29722978
if (!auth.ok()) return auth;
2979+
auto identity = checkModelIdentity(request);
2980+
if (!identity.ok()) return identity;
29732981
if (params_base.model.path.empty()) {
29742982
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
29752983
}
@@ -3428,6 +3436,8 @@ class BackendServiceImpl final : public backend::Backend::Service {
34283436
backend::TranscriptResult* response) override {
34293437
auto auth = checkAuth(context);
34303438
if (!auth.ok()) return auth;
3439+
auto identity = checkModelIdentity(request);
3440+
if (!identity.ok()) return identity;
34313441

34323442
backend::Reply reply;
34333443
grpc::Status st = runTranscriptionAsCompletion(context, request, &reply);
@@ -3446,6 +3456,8 @@ class BackendServiceImpl final : public backend::Backend::Service {
34463456
grpc::ServerWriter<backend::TranscriptStreamResponse>* writer) override {
34473457
auto auth = checkAuth(context);
34483458
if (!auth.ok()) return auth;
3459+
auto identity = checkModelIdentity(request);
3460+
if (!identity.ok()) return identity;
34493461

34503462
// Buffered streaming: run the transcription as a normal chat
34513463
// completion, then emit one delta + one final event. Real

backend/cpp/privacy-filter/grpc-server.cpp

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ namespace {
4141
// per loaded model. g_mu guards (re)load against in-flight classification.
4242
std::mutex g_mu;
4343
pf_ctx * g_ctx = nullptr;
44+
// The ModelOptions.Model this process loaded, compared against
45+
// TokenClassifyRequest.ModelIdentity so a request that arrived through a stale
46+
// distributed route is rejected rather than answered from the wrong model
47+
// (#10952). Guarded by g_mu like the rest of the engine state.
48+
std::string g_loaded_model_identity;
4449
std::atomic<Server *> g_server{nullptr};
4550

4651
// Resolve the device string the engine expects ("cpu" / "gpu" / "cuda" /
@@ -113,17 +118,48 @@ class PrivacyFilterBackend final : public backend::Backend::Service {
113118
}
114119

115120
g_ctx = ctx;
121+
// Record what we loaded so TokenClassify can reject a request meant
122+
// for a different model. request->model(), not modelfile(): it is the
123+
// value the controller also sends as ModelIdentity, and the two are
124+
// read from the same ModelConfig.Model (#10952).
125+
g_loaded_model_identity = request->model();
116126
result->set_success(true);
117127
result->set_message("privacy-filter loaded (" + device + ")");
118128
return GStatus::OK;
119129
}
120130

131+
// checkModelIdentity mirrors pkg/grpc/server.go,
132+
// backend/python/common/model_identity.py and the llama-cpp server. In
133+
// distributed mode a worker can recycle a stopped backend's gRPC port for
134+
// another model's backend, and the controller's liveness-only probe cannot
135+
// tell a stale cached route from a valid one, so the backend has to catch
136+
// it. Either side empty means "skip": the request side is empty for a
137+
// controller that predates the field, the loaded side when such a
138+
// controller performed the load. A false rejection is worse than the miss.
139+
// Callers must already hold g_mu.
140+
GStatus checkModelIdentity(const backend::TokenClassifyRequest * request) {
141+
if (request == nullptr || request->modelidentity().empty()) {
142+
return GStatus::OK;
143+
}
144+
if (g_loaded_model_identity.empty() ||
145+
g_loaded_model_identity == request->modelidentity()) {
146+
return GStatus::OK;
147+
}
148+
// NOT_FOUND plus this exact sentinel is the cross-language contract
149+
// the router matches on (grpcerrors.ModelMismatchSentinel).
150+
return GStatus(StatusCode::NOT_FOUND,
151+
"privacy-filter: model identity mismatch: loaded \"" +
152+
g_loaded_model_identity + "\", requested \"" +
153+
request->modelidentity() + "\"");
154+
}
155+
121156
GStatus TokenClassify(ServerContext *, const backend::TokenClassifyRequest * request,
122157
backend::TokenClassifyResponse * response) override {
123158
std::lock_guard<std::mutex> lock(g_mu);
124159
if (!g_ctx) {
125160
return GStatus(StatusCode::FAILED_PRECONDITION, "Model not loaded");
126161
}
162+
if (GStatus id = checkModelIdentity(request); !id.ok()) return id;
127163

128164
const std::string & text = request->text();
129165
if (text.empty()) {

0 commit comments

Comments
 (0)