Skip to content

Commit 465d488

Browse files
localai-botmudler
andauthored
fix(distributed): reject wrong-model requests at the backend (#10970)
fix(distributed): reject wrong-model requests at the backend (#10952) In distributed mode the controller caches a NodeModel row naming a backend's host:port. A worker can recycle a stopped backend's gRPC port for a different model's backend, and probeHealth verifies liveness rather than identity, so the probe succeeds against whatever now occupies the port and the request is dispatched to the wrong backend. The caller gets a silent wrong-model answer. Nothing in the request could catch this: PredictOptions had no model field, so model identity crossed the wire only in ModelOptions.Model at LoadModel time, and the cached-hit path issues no LoadModel. Every backend's "model not loaded" guard checks a nil handle, which a process holding a different model passes, so the stale row was never dropped either. Add PredictOptions.ModelIdentity and enforce it at the point of use: - The controller populates it in gRPCPredictOpts from ModelConfig.Model, the same expression ModelOptions feeds to model.WithModel and therefore the same value the backend received as ModelOptions.Model. Both are read from one config value in one function, so they are equal by construction and the comparison cannot false-reject. - Backends compare it against what they loaded and return NOT_FOUND with a fixed sentinel. Enforced in pkg/grpc/server.go (27 Go backends), an interceptor in backend/python/common (all 36 Python backends, no per-backend change), and the llama-cpp / ik-llama-cpp / ds4 C++ servers. That is every backend with real exposure: kokoros answers all four RPCs with unimplemented and privacy-filter implements none of them. - The router's reconcile drops the stale replica row on a mismatch, so the next request reloads somewhere correct. Empty means "skip the check" on both sides: a controller that predates the field sends nothing, a backend loaded by such a controller has nothing to compare, and the C++ server synthesizes PredictOptions internally for ASR. That keeps upgrades working in both directions. Scoped to the four PredictOptions RPCs. TTSRequest.model and SoundGenerationRequest.model are deliberately NOT validated: FileStagingClient already rewrites them to worker-local absolute paths, so in distributed mode they already differ from the load-time value and comparing them would reject valid requests. IsModelMismatch requires both the NOT_FOUND code and the sentinel, unlike the neighbouring helpers which accept either. insightface's Embedding returns NOT_FOUND "no face detected" on a PredictOptions RPC, and a code-only check would drop a healthy replica row on every faceless image. Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 1618c2e commit 465d488

17 files changed

Lines changed: 1030 additions & 11 deletions

File tree

backend/backend.proto

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,32 @@ message PredictOptions {
315315
int32 TopLogprobs = 51; // Number of top logprobs to return per token (maps to OpenAI top_logprobs parameter)
316316
map<string, string> Metadata = 52; // Generic per-request metadata (e.g., enable_thinking)
317317
float MinP = 53; // Minimum probability sampling threshold (0.0 = disabled)
318+
319+
// ModelIdentity names the model this request is for, so a backend can reject
320+
// a request that reached it by mistake instead of answering from whatever
321+
// model it happens to hold. In distributed mode a worker can recycle a
322+
// stopped backend's gRPC port for a different model's backend, and a
323+
// liveness-only health probe cannot tell that apart from a valid cached
324+
// route (#10952).
325+
//
326+
// The value is the controller's ModelConfig.Model, the SAME expression that
327+
// produces ModelOptions.Model at LoadModel time, so the two are equal by
328+
// construction rather than by convention.
329+
//
330+
// Empty means "no identity supplied": backends MUST skip the check. That
331+
// keeps an old controller talking to a new backend working, and covers
332+
// callers that legitimately synthesize a PredictOptions internally.
333+
//
334+
// Do NOT reuse TTSRequest.model or SoundGenerationRequest.model for this
335+
// purpose. FileStagingClient already rewrites those to worker-local absolute
336+
// paths (core/services/nodes/file_staging_client.go), so in distributed mode
337+
// they already differ from the load-time value and comparing them would
338+
// reject valid requests. Extending identity to those RPCs needs a separate
339+
// field carrying the untranslated value.
340+
string ModelIdentity = 54;
341+
342+
// 24 was never assigned; reserve it so it is not silently reused.
343+
reserved 24;
318344
}
319345

320346
// ToolCallDelta represents an incremental tool call update from the C++ parser.

backend/cpp/ds4/grpc-server.cpp

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ namespace {
5151

5252
// Global state - ds4 is single-engine-per-process by design.
5353
std::mutex g_engine_mu;
54+
// The ModelOptions.Model this process loaded, compared against
55+
// PredictOptions.ModelIdentity so a request that arrived through a stale
56+
// distributed route is rejected rather than answered from the wrong model
57+
// (#10952). Guarded by g_engine_mu like the rest of the engine state.
58+
std::string g_loaded_model_identity;
5459
ds4_engine *g_engine = nullptr;
5560
ds4_session *g_session = nullptr;
5661
int g_ctx_size = 32768;
@@ -562,6 +567,24 @@ static void build_prompt(ds4_engine *engine, const backend::PredictOptions *requ
562567
ds4_chat_append_assistant_prefix(engine, out, think);
563568
}
564569

570+
// check_model_identity mirrors pkg/grpc/server.go and
571+
// backend/python/common/model_identity.py. Either side empty means "skip": the
572+
// request side is empty for a controller that predates the field, the loaded
573+
// side when such a controller performed the load. A false rejection is worse
574+
// than the miss it prevents. Callers must already hold g_engine_mu.
575+
static GStatus check_model_identity(const backend::PredictOptions *request) {
576+
if (request == nullptr || request->modelidentity().empty()) return GStatus::OK;
577+
if (g_loaded_model_identity.empty() ||
578+
g_loaded_model_identity == request->modelidentity()) {
579+
return GStatus::OK;
580+
}
581+
// NOT_FOUND plus this exact sentinel is the cross-language contract the
582+
// router matches on (grpcerrors.ModelMismatchSentinel).
583+
return GStatus(StatusCode::NOT_FOUND,
584+
"ds4: model identity mismatch: loaded \"" + g_loaded_model_identity +
585+
"\", requested \"" + request->modelidentity() + "\"");
586+
}
587+
565588
class DS4Backend final : public backend::Backend::Service {
566589
public:
567590
GStatus Health(ServerContext *, const backend::HealthMessage *,
@@ -716,6 +739,7 @@ class DS4Backend final : public backend::Backend::Service {
716739
}
717740

718741
result->set_success(true);
742+
g_loaded_model_identity = request->model();
719743
result->set_message("loaded " + model_path);
720744
return GStatus::OK;
721745
}
@@ -724,6 +748,7 @@ class DS4Backend final : public backend::Backend::Service {
724748
backend::TokenizationResponse *response) override {
725749
std::lock_guard<std::mutex> lock(g_engine_mu);
726750
if (!g_engine) return GStatus(StatusCode::FAILED_PRECONDITION, "ds4: model not loaded");
751+
if (GStatus id = check_model_identity(request); !id.ok()) return id;
727752
ds4_tokens out = {};
728753
ds4_tokenize_text(g_engine, request->prompt().c_str(), &out);
729754
for (int i = 0; i < out.len; ++i) response->add_tokens(out.v[i]);
@@ -738,6 +763,7 @@ class DS4Backend final : public backend::Backend::Service {
738763
if (!g_engine || !g_session) {
739764
return GStatus(StatusCode::FAILED_PRECONDITION, "ds4: model not loaded");
740765
}
766+
if (GStatus id = check_model_identity(request); !id.ok()) return id;
741767
if (std::string route_err = wait_route_ready(lock); !route_err.empty()) {
742768
return GStatus(StatusCode::UNAVAILABLE, route_err);
743769
}
@@ -837,6 +863,7 @@ class DS4Backend final : public backend::Backend::Service {
837863
if (!g_engine || !g_session) {
838864
return GStatus(StatusCode::FAILED_PRECONDITION, "ds4: model not loaded");
839865
}
866+
if (GStatus id = check_model_identity(request); !id.ok()) return id;
840867
if (std::string route_err = wait_route_ready(lock); !route_err.empty()) {
841868
return GStatus(StatusCode::UNAVAILABLE, route_err);
842869
}

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2412,7 +2412,33 @@ static void params_parse(const backend::ModelOptions* request,
24122412

24132413
// GRPC Server start
24142414
class BackendServiceImpl final : public backend::Backend::Service {
2415+
private:
2416+
// The ModelOptions.Model this process was loaded with. Compared against
2417+
// PredictOptions.ModelIdentity so a request that reached us through a stale
2418+
// distributed route is rejected instead of answered from the wrong model
2419+
// (#10952).
2420+
std::string loaded_model_identity;
2421+
24152422
public:
2423+
// checkModelIdentity mirrors pkg/grpc/server.go and
2424+
// backend/python/common/model_identity.py. Either side being empty means
2425+
// "skip": the request side is empty for a controller that predates the field,
2426+
// and the loaded side is empty when such a controller performed the load. A
2427+
// false rejection is worse than the miss it prevents.
2428+
grpc::Status checkModelIdentity(const backend::PredictOptions* request) {
2429+
if (request == nullptr || request->modelidentity().empty()) {
2430+
return grpc::Status::OK;
2431+
}
2432+
if (loaded_model_identity.empty() || loaded_model_identity == request->modelidentity()) {
2433+
return grpc::Status::OK;
2434+
}
2435+
// NOT_FOUND plus this exact sentinel is the cross-language contract the
2436+
// router matches on (grpcerrors.ModelMismatchSentinel).
2437+
return grpc::Status(grpc::StatusCode::NOT_FOUND,
2438+
"ik-llama-cpp: model identity mismatch: loaded \"" + loaded_model_identity +
2439+
"\", requested \"" + request->modelidentity() + "\"");
2440+
}
2441+
24162442
grpc::Status Health(ServerContext* context, const backend::HealthMessage* request, backend::Reply* reply) {
24172443
// Implement Health RPC
24182444
reply->set_message("OK");
@@ -2438,9 +2464,12 @@ class BackendServiceImpl final : public backend::Backend::Service {
24382464
result->set_message("Loading succeeded");
24392465
result->set_success(true);
24402466
loaded_model = true;
2467+
loaded_model_identity = request->model();
24412468
return Status::OK;
24422469
}
24432470
grpc::Status PredictStream(grpc::ServerContext* context, const backend::PredictOptions* request, grpc::ServerWriter<backend::Reply>* writer) override {
2471+
auto identity = checkModelIdentity(request);
2472+
if (!identity.ok()) return identity;
24442473
json data = parse_options(true, request, llama);
24452474
const int task_id = llama.queue_tasks.get_new_id();
24462475
llama.queue_results.add_waiting_task_id(task_id);
@@ -2495,6 +2524,8 @@ class BackendServiceImpl final : public backend::Backend::Service {
24952524

24962525

24972526
grpc::Status Predict(ServerContext* context, const backend::PredictOptions* request, backend::Reply* reply) {
2527+
auto identity = checkModelIdentity(request);
2528+
if (!identity.ok()) return identity;
24982529
json data = parse_options(false, request, llama);
24992530
const int task_id = llama.queue_tasks.get_new_id();
25002531
llama.queue_results.add_waiting_task_id(task_id);
@@ -2532,6 +2563,8 @@ class BackendServiceImpl final : public backend::Backend::Service {
25322563

25332564
/// https://github.com/ggerganov/llama.cpp/blob/aa2341298924ac89778252015efcb792f2df1e20/examples/server/server.cpp#L2969
25342565
grpc::Status Embedding(ServerContext* context, const backend::PredictOptions* request, backend::EmbeddingResult* embeddingResult) {
2566+
auto identity = checkModelIdentity(request);
2567+
if (!identity.ok()) return identity;
25352568
json data = parse_options(false, request, llama);
25362569
const int task_id = llama.queue_tasks.get_new_id();
25372570
llama.queue_results.add_waiting_task_id(task_id);
@@ -2556,6 +2589,8 @@ class BackendServiceImpl final : public backend::Backend::Service {
25562589
}
25572590

25582591
grpc::Status TokenizeString(ServerContext* context, const backend::PredictOptions* request, backend::TokenizationResponse* response){
2592+
auto identity = checkModelIdentity(request);
2593+
if (!identity.ok()) return identity;
25592594
json data = parse_options(false, request, llama);
25602595

25612596
std::vector<llama_token> tokens = llama.tokenize(data["prompt"],false);

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,10 +1401,36 @@ class BackendServiceImpl final : public backend::Backend::Service {
14011401
private:
14021402
server_context& ctx_server;
14031403
common_params params_base; // Store copy of params_base, set after model load
1404+
// The ModelOptions.Model this process was loaded with. Compared against
1405+
// PredictOptions.ModelIdentity so a request that reached us through a stale
1406+
// distributed route is rejected instead of answered from the wrong model
1407+
// (#10952). Written under LoadModel, read by the inference RPCs.
1408+
std::string loaded_model_identity;
14041409

14051410
public:
14061411
BackendServiceImpl(server_context& ctx) : ctx_server(ctx) {}
14071412

1413+
// checkModelIdentity mirrors pkg/grpc/server.go and
1414+
// backend/python/common/model_identity.py. Either side being empty means
1415+
// "skip": the request side is empty for a controller that predates the
1416+
// field and for the synthetic PredictOptions this server builds internally
1417+
// for ASR, and the loaded side is empty when such a controller performed
1418+
// the load. A false rejection is worse than the miss it prevents.
1419+
grpc::Status checkModelIdentity(const backend::PredictOptions* request) {
1420+
if (request == nullptr || request->modelidentity().empty()) {
1421+
return grpc::Status::OK;
1422+
}
1423+
if (loaded_model_identity.empty() || loaded_model_identity == request->modelidentity()) {
1424+
return grpc::Status::OK;
1425+
}
1426+
// NOT_FOUND plus this exact sentinel is the cross-language contract the
1427+
// router matches on (grpcerrors.ModelMismatchSentinel). The code alone
1428+
// is not enough: NOT_FOUND is returned for unrelated reasons elsewhere.
1429+
return grpc::Status(grpc::StatusCode::NOT_FOUND,
1430+
"llama-cpp: model identity mismatch: loaded \"" + loaded_model_identity +
1431+
"\", requested \"" + request->modelidentity() + "\"");
1432+
}
1433+
14081434
grpc::Status Health(ServerContext* context, const backend::HealthMessage* /*request*/, backend::Reply* reply) override {
14091435
auto auth = checkAuth(context);
14101436
if (!auth.ok()) return auth;
@@ -1535,6 +1561,7 @@ class BackendServiceImpl final : public backend::Backend::Service {
15351561
result->set_message("Loading succeeded");
15361562
result->set_success(true);
15371563
loaded_model = true;
1564+
loaded_model_identity = request->model();
15381565
// Store copy of params_base for use in parse_options and other methods
15391566
params_base = params;
15401567

@@ -1616,6 +1643,8 @@ class BackendServiceImpl final : public backend::Backend::Service {
16161643
grpc::Status PredictStream(grpc::ServerContext* context, const backend::PredictOptions* request, grpc::ServerWriter<backend::Reply>* writer) override {
16171644
auto auth = checkAuth(context);
16181645
if (!auth.ok()) return auth;
1646+
auto identity = checkModelIdentity(request);
1647+
if (!identity.ok()) return identity;
16191648
if (params_base.model.path.empty()) {
16201649
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
16211650
}
@@ -2183,6 +2212,8 @@ class BackendServiceImpl final : public backend::Backend::Service {
21832212
grpc::Status Predict(ServerContext* context, const backend::PredictOptions* request, backend::Reply* reply) override {
21842213
auto auth = checkAuth(context);
21852214
if (!auth.ok()) return auth;
2215+
auto identity = checkModelIdentity(request);
2216+
if (!identity.ok()) return identity;
21862217
if (params_base.model.path.empty()) {
21872218
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
21882219
}
@@ -2715,6 +2746,8 @@ class BackendServiceImpl final : public backend::Backend::Service {
27152746
grpc::Status Embedding(ServerContext* context, const backend::PredictOptions* request, backend::EmbeddingResult* embeddingResult) override {
27162747
auto auth = checkAuth(context);
27172748
if (!auth.ok()) return auth;
2749+
auto identity = checkModelIdentity(request);
2750+
if (!identity.ok()) return identity;
27182751
if (params_base.model.path.empty()) {
27192752
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
27202753
}
@@ -3108,6 +3141,8 @@ class BackendServiceImpl final : public backend::Backend::Service {
31083141
grpc::Status TokenizeString(ServerContext* context, const backend::PredictOptions* request, backend::TokenizationResponse* response) override {
31093142
auto auth = checkAuth(context);
31103143
if (!auth.ok()) return auth;
3144+
auto identity = checkModelIdentity(request);
3145+
if (!identity.ok()) return identity;
31113146
if (params_base.model.path.empty()) {
31123147
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
31133148
}

backend/python/common/grpc_auth.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import grpc
1313

14+
from model_identity import AsyncModelIdentityInterceptor, ModelIdentityInterceptor
1415
from parent_watch import start_parent_death_watcher
1516

1617

@@ -64,13 +65,15 @@ async def intercept_service(self, continuation, handler_call_details):
6465

6566

6667
def get_auth_interceptors(*, aio: bool = False):
67-
"""Return a list of gRPC interceptors for bearer token auth.
68+
"""Return the gRPC server interceptors every LocalAI Python backend installs.
69+
70+
Always includes model-identity enforcement (model_identity.py), which is
71+
unrelated to authentication. Bearer token auth is added on top only when
72+
LOCALAI_GRPC_AUTH_TOKEN is set.
6873
6974
Args:
7075
aio: If True, return async-compatible interceptors for grpc.aio.server().
7176
If False (default), return sync interceptors for grpc.server().
72-
73-
Returns an empty list when LOCALAI_GRPC_AUTH_TOKEN is not set.
7477
"""
7578
# Arm the best-effort parent-death backstop here: this is the single helper
7679
# every LocalAI Python backend invokes exactly once while building its gRPC
@@ -79,9 +82,17 @@ def get_auth_interceptors(*, aio: bool = False):
7982
# unsupported platforms — see parent_watch.py.
8083
start_parent_death_watcher()
8184

85+
# Model-identity enforcement is independent of authentication and must be
86+
# installed BEFORE the token check returns. gRPC auth is off by default, so
87+
# an identity interceptor added below the early return would never be
88+
# installed on any Python backend, and nothing would report it.
89+
interceptors = [AsyncModelIdentityInterceptor()] if aio else [ModelIdentityInterceptor()]
90+
8291
token = os.environ.get("LOCALAI_GRPC_AUTH_TOKEN", "")
8392
if not token:
84-
return []
93+
return interceptors
8594
if aio:
86-
return [AsyncTokenAuthInterceptor(token)]
87-
return [TokenAuthInterceptor(token)]
95+
interceptors.append(AsyncTokenAuthInterceptor(token))
96+
else:
97+
interceptors.append(TokenAuthInterceptor(token))
98+
return interceptors

0 commit comments

Comments
 (0)