mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
#10970 gave the four PredictOptions RPCs a model-identity check so a backend reached through a stale distributed route rejects the request instead of answering from whatever model it holds (#10952). Every other modality shares that exposure: the route is cached by host:port, a worker can recycle a stopped backend's port for another model's backend, and a liveness-only probe cannot tell a stale row from a valid one. Extends the same mechanism to the 21 remaining request messages that reach a backend through the router, using the pattern #10970 established rather than a parallel one: - proto: ModelIdentity on each modality request message. - controller: populated from ModelConfig.Model at the call site that also builds ModelOptions, so load-time and request-time values are equal by construction. - backends: one generic guard in pkg/grpc/server.go (27 Go backends), the method set in backend/python/common (36 Python backends), llama-cpp (AudioTranscription/Stream, Rerank, Score) and privacy-filter (TokenClassify). - reconcile already drops the stale row on IsModelMismatch; no change. TTSRequest and SoundGenerationRequest get a SEPARATE ModelIdentity field rather than reusing their existing `model`: FileStagingClient rewrites `model` to a worker-local path, so comparing it would reject valid requests in exactly the configuration this guards. AudioEncode/AudioDecode are deliberately left unguarded: the opus codec backend is loaded from a literal rather than a ModelConfig, so no value carries the equality guarantee the comparison depends on. The four bidirectional stream RPCs are out of scope; they bypass reconcile. Empty means skip on both sides, so an old controller, an old backend, and the bare request structs in tests/e2e-backends all keep working. Assisted-by: Claude Code:claude-opus-4-8 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""Unit tests for model-identity enforcement (model_identity.py).
|
|
|
|
Run inside any backend venv (needs grpcio, which every Python backend has):
|
|
python -m unittest model_identity_test
|
|
|
|
Mirrors the Go coverage in pkg/grpc/model_identity_test.go and
|
|
pkg/grpc/grpcerrors/errors_test.go. The rules under test are the ones whose
|
|
failure modes are silent: enforcement that is wired up but never installed, and
|
|
enforcement that rejects requests it should serve.
|
|
"""
|
|
|
|
import os
|
|
import unittest
|
|
|
|
import grpc
|
|
|
|
import grpc_auth
|
|
import model_identity
|
|
|
|
|
|
class _Aborted(Exception):
|
|
pass
|
|
|
|
|
|
class _FakeContext:
|
|
"""Minimal ServicerContext: abort() records and raises, like the real one."""
|
|
|
|
def __init__(self):
|
|
self.code = None
|
|
self.details = None
|
|
|
|
def abort(self, code, details):
|
|
self.code = code
|
|
self.details = details
|
|
raise _Aborted(details)
|
|
|
|
|
|
class _FakeCallDetails:
|
|
def __init__(self, method):
|
|
self.method = method
|
|
self.invocation_metadata = ()
|
|
|
|
|
|
class _Request:
|
|
"""Stands in for ModelOptions / PredictOptions.
|
|
|
|
The generated protobuf classes are built at container-build time and are
|
|
not importable here, but the interceptor only ever reads two attributes.
|
|
"""
|
|
|
|
def __init__(self, Model="", ModelIdentity=""):
|
|
self.Model = Model
|
|
self.ModelIdentity = ModelIdentity
|
|
|
|
|
|
class _Result:
|
|
def __init__(self, success=True):
|
|
self.success = success
|
|
|
|
|
|
def _handler(behavior, response_streaming=False):
|
|
if response_streaming:
|
|
return grpc.unary_stream_rpc_method_handler(behavior)
|
|
return grpc.unary_unary_rpc_method_handler(behavior)
|
|
|
|
|
|
class TestInterceptorInstalled(unittest.TestCase):
|
|
"""The wiring, which is where this can silently do nothing.
|
|
|
|
get_auth_interceptors() returns early when LOCALAI_GRPC_AUTH_TOKEN is
|
|
unset, which is the DEFAULT configuration. An identity interceptor added
|
|
after that return is never installed on any of the 36 Python backends, and
|
|
nothing else would notice.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self._saved = os.environ.get("LOCALAI_GRPC_AUTH_TOKEN")
|
|
os.environ.pop("LOCALAI_GRPC_AUTH_TOKEN", None)
|
|
|
|
def tearDown(self):
|
|
if self._saved is None:
|
|
os.environ.pop("LOCALAI_GRPC_AUTH_TOKEN", None)
|
|
else:
|
|
os.environ["LOCALAI_GRPC_AUTH_TOKEN"] = self._saved
|
|
|
|
def test_installed_when_auth_is_disabled(self):
|
|
interceptors = grpc_auth.get_auth_interceptors()
|
|
self.assertTrue(
|
|
any(isinstance(i, model_identity.ModelIdentityInterceptor) for i in interceptors),
|
|
"identity enforcement must be installed even with gRPC auth off "
|
|
"(the default); got {!r}".format(interceptors),
|
|
)
|
|
|
|
def test_installed_when_auth_is_disabled_aio(self):
|
|
interceptors = grpc_auth.get_auth_interceptors(aio=True)
|
|
self.assertTrue(
|
|
any(
|
|
isinstance(i, model_identity.AsyncModelIdentityInterceptor)
|
|
for i in interceptors
|
|
),
|
|
"async identity enforcement must be installed with gRPC auth off",
|
|
)
|
|
|
|
def test_installed_alongside_auth_when_enabled(self):
|
|
os.environ["LOCALAI_GRPC_AUTH_TOKEN"] = "secret"
|
|
interceptors = grpc_auth.get_auth_interceptors()
|
|
self.assertTrue(
|
|
any(isinstance(i, model_identity.ModelIdentityInterceptor) for i in interceptors)
|
|
)
|
|
self.assertTrue(
|
|
any(isinstance(i, grpc_auth.TokenAuthInterceptor) for i in interceptors)
|
|
)
|
|
|
|
|
|
class TestMismatchRule(unittest.TestCase):
|
|
"""The pure policy. Every 'serve' case here is a false-rejection guard."""
|
|
|
|
def setUp(self):
|
|
self.state = model_identity.ModelIdentityState()
|
|
|
|
def test_rejects_a_different_model(self):
|
|
self.state.record("a.gguf")
|
|
message = self.state.mismatch("b.gguf")
|
|
self.assertIsNotNone(message)
|
|
self.assertIn(model_identity.MODEL_MISMATCH_SENTINEL, message)
|
|
self.assertIn("a.gguf", message)
|
|
self.assertIn("b.gguf", message)
|
|
|
|
def test_serves_the_same_model(self):
|
|
self.state.record("a.gguf")
|
|
self.assertIsNone(self.state.mismatch("a.gguf"))
|
|
|
|
def test_serves_when_the_request_has_no_identity(self):
|
|
self.state.record("a.gguf")
|
|
self.assertIsNone(self.state.mismatch(""))
|
|
|
|
def test_serves_when_nothing_was_recorded(self):
|
|
self.assertIsNone(self.state.mismatch("b.gguf"))
|
|
|
|
|
|
class TestInterceptorBehavior(unittest.TestCase):
|
|
def setUp(self):
|
|
self.interceptor = model_identity.ModelIdentityInterceptor()
|
|
self.served = []
|
|
|
|
def _intercept(self, method, handler):
|
|
return self.interceptor.intercept_service(
|
|
lambda _: handler, _FakeCallDetails(method)
|
|
)
|
|
|
|
def _load(self, model, success=True):
|
|
handler = _handler(lambda request, context: _Result(success=success))
|
|
wrapped = self._intercept("/backend.Backend/LoadModel", handler)
|
|
wrapped.unary_unary(_Request(Model=model), _FakeContext())
|
|
|
|
def _call(self, method, identity, response_streaming=False):
|
|
def behavior(request, context):
|
|
self.served.append(method)
|
|
return "served"
|
|
|
|
handler = _handler(behavior, response_streaming=response_streaming)
|
|
wrapped = self._intercept(method, handler)
|
|
context = _FakeContext()
|
|
behavior_fn = wrapped.unary_stream if response_streaming else wrapped.unary_unary
|
|
return behavior_fn(_Request(ModelIdentity=identity), context), context
|
|
|
|
def test_load_records_the_identity(self):
|
|
self._load("a.gguf")
|
|
self.assertEqual(self.interceptor.state.loaded, "a.gguf")
|
|
|
|
def test_failed_load_records_nothing(self):
|
|
self._load("a.gguf", success=False)
|
|
self.assertEqual(self.interceptor.state.loaded, "")
|
|
|
|
def test_rejects_every_guarded_rpc_on_mismatch(self):
|
|
self._load("a.gguf")
|
|
for method in sorted(model_identity._GUARDED_METHODS):
|
|
streaming = method.endswith("PredictStream")
|
|
with self.subTest(method=method):
|
|
with self.assertRaises(_Aborted):
|
|
self._call(method, "b.gguf", response_streaming=streaming)
|
|
self.assertEqual(self.served, [], "no request may reach the model")
|
|
|
|
def test_reject_uses_not_found_and_the_sentinel(self):
|
|
self._load("a.gguf")
|
|
|
|
def behavior(request, context):
|
|
return "served"
|
|
|
|
wrapped = self._intercept(
|
|
"/backend.Backend/Predict", _handler(behavior)
|
|
)
|
|
context = _FakeContext()
|
|
with self.assertRaises(_Aborted):
|
|
wrapped.unary_unary(_Request(ModelIdentity="b.gguf"), context)
|
|
self.assertEqual(context.code, grpc.StatusCode.NOT_FOUND)
|
|
self.assertIn(model_identity.MODEL_MISMATCH_SENTINEL, context.details)
|
|
|
|
def test_serves_matching_identity(self):
|
|
self._load("a.gguf")
|
|
for method in sorted(model_identity._GUARDED_METHODS):
|
|
streaming = method.endswith("PredictStream")
|
|
self._call(method, "a.gguf", response_streaming=streaming)
|
|
self.assertEqual(len(self.served), len(model_identity._GUARDED_METHODS))
|
|
|
|
def test_serves_request_without_identity(self):
|
|
self._load("a.gguf")
|
|
self._call("/backend.Backend/Predict", "")
|
|
self.assertEqual(self.served, ["/backend.Backend/Predict"])
|
|
|
|
def test_serves_when_load_recorded_nothing(self):
|
|
self._call("/backend.Backend/Predict", "b.gguf")
|
|
self.assertEqual(self.served, ["/backend.Backend/Predict"])
|
|
|
|
def test_unguarded_rpcs_pass_through_untouched(self):
|
|
handler = _handler(lambda request, context: "served")
|
|
wrapped = self._intercept("/backend.Backend/Health", handler)
|
|
self.assertIs(wrapped, handler)
|
|
|
|
|
|
# Every modality request message now carries a ModelIdentity field, so every
|
|
# modality RPC shares the guard. The set below is the enforcement surface for
|
|
# all 36 Python backends at once: an RPC missing from it is silently
|
|
# unprotected, which is the failure mode this class exists to catch.
|
|
_EXPECTED_MODALITY_METHODS = (
|
|
"/backend.Backend/GenerateImage",
|
|
"/backend.Backend/GenerateVideo",
|
|
"/backend.Backend/TTS",
|
|
"/backend.Backend/TTSStream",
|
|
"/backend.Backend/SoundGeneration",
|
|
"/backend.Backend/AudioTranscription",
|
|
"/backend.Backend/AudioTranscriptionStream",
|
|
"/backend.Backend/Detect",
|
|
"/backend.Backend/Depth",
|
|
"/backend.Backend/FaceVerify",
|
|
"/backend.Backend/FaceAnalyze",
|
|
"/backend.Backend/VoiceVerify",
|
|
"/backend.Backend/VoiceAnalyze",
|
|
"/backend.Backend/VoiceEmbed",
|
|
"/backend.Backend/Rerank",
|
|
"/backend.Backend/TokenClassify",
|
|
"/backend.Backend/Score",
|
|
"/backend.Backend/VAD",
|
|
"/backend.Backend/Diarize",
|
|
"/backend.Backend/SoundDetection",
|
|
"/backend.Backend/AudioTransform",
|
|
)
|
|
|
|
|
|
class TestModalityMethods(unittest.TestCase):
|
|
def setUp(self):
|
|
self.interceptor = model_identity.ModelIdentityInterceptor()
|
|
self.interceptor.state.record("a.gguf")
|
|
self.served = []
|
|
|
|
def _call(self, method, identity, response_streaming=False):
|
|
def behavior(request, context):
|
|
self.served.append(method)
|
|
return "served"
|
|
|
|
handler = _handler(behavior, response_streaming=response_streaming)
|
|
wrapped = self.interceptor.intercept_service(
|
|
lambda _: handler, _FakeCallDetails(method)
|
|
)
|
|
fn = wrapped.unary_stream if response_streaming else wrapped.unary_unary
|
|
return fn(_Request(ModelIdentity=identity), _FakeContext())
|
|
|
|
def test_every_modality_rpc_is_guarded(self):
|
|
for method in _EXPECTED_MODALITY_METHODS:
|
|
with self.subTest(method=method):
|
|
self.assertIn(
|
|
method,
|
|
model_identity._GUARDED_METHODS,
|
|
"{} is unprotected on all Python backends".format(method),
|
|
)
|
|
|
|
def test_every_modality_rpc_rejects_a_mismatch(self):
|
|
for method in _EXPECTED_MODALITY_METHODS:
|
|
streaming = method.endswith("Stream")
|
|
with self.subTest(method=method):
|
|
with self.assertRaises(_Aborted):
|
|
self._call(method, "b.gguf", response_streaming=streaming)
|
|
self.assertEqual(self.served, [], "no request may reach the model")
|
|
|
|
def test_every_modality_rpc_serves_a_match(self):
|
|
for method in _EXPECTED_MODALITY_METHODS:
|
|
streaming = method.endswith("Stream")
|
|
self._call(method, "a.gguf", response_streaming=streaming)
|
|
self.assertEqual(len(self.served), len(_EXPECTED_MODALITY_METHODS))
|
|
|
|
# Compatibility: an old controller sends nothing, and the e2e backend suite
|
|
# drives real backends with bare request structs.
|
|
def test_every_modality_rpc_serves_without_an_identity(self):
|
|
for method in _EXPECTED_MODALITY_METHODS:
|
|
streaming = method.endswith("Stream")
|
|
self._call(method, "", response_streaming=streaming)
|
|
self.assertEqual(len(self.served), len(_EXPECTED_MODALITY_METHODS))
|
|
|
|
# AudioEncode/AudioDecode stay out: the opus codec backend is loaded from a
|
|
# literal, not a ModelConfig, so no value carries the structural guarantee
|
|
# the comparison depends on.
|
|
def test_codec_rpcs_stay_unguarded(self):
|
|
for method in ("/backend.Backend/AudioEncode", "/backend.Backend/AudioDecode"):
|
|
self.assertNotIn(method, model_identity._GUARDED_METHODS)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|