fix(distributed): reject wrong-model requests on the remaining modalities#10990
Merged
Conversation
…ties #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. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-4-8 [Read] [Edit] [Bash]
mudler
approved these changes
Jul 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
#10970 fixed silent wrong-model answers for the four
PredictOptionsRPCs and explicitly deferred the rest. The exposure is not specific to those four: the controller caches aNodeModelrow naming a backend'shost:port, a worker can recycle a stopped backend's port for a different model's backend, andprobeHealthverifies liveness, not identity. Any modality routed through that cache can be answered by the wrong model, silently.Scope
Enumerated from
grpc.InferenceBackend(pkg/grpc/backend.go:69-98) rather than the proto service — that interface is exactly the set of RPCsInFlightTrackingClientwraps withreconcile, i.e. the ones reachable through a cachedNodeModelrow.#10970 covered 4. This covers the remaining 21 request messages / 24 methods:
GenerateImageRequest,GenerateVideoRequest,TranscriptRequest(×2),TTSRequest(×2),SoundGenerationRequest,DetectOptions,DepthRequest,FaceVerifyRequest,FaceAnalyzeRequest,VoiceVerifyRequest,VoiceAnalyzeRequest,VoiceEmbedRequest,RerankRequest,TokenClassifyRequest,ScoreRequest,VADRequest,DiarizeRequest,SoundDetectionRequest,AudioTransformRequest.Approach
The same mechanism as #10970, not a parallel one:
ModelIdentityon each message.ModelConfig.Modelat the call site that also buildsModelOptions, so the load-time and request-time values are equal by construction.RerankandDepthreceive caller-built requests (fromjina/rerank.goandlocalai/depth.go, neither of which holds aModelConfig), so they are stamped insidebackend.Rerank/backend.Depth; the threetoProtohelpers take identity as a parameter for the same reason.pkg/grpc/server.gotakes aGetModelIdentity() stringinterface so one guard covers every message (27 Go backends);_GUARDED_METHODSinbackend/python/common(36 Python backends); llama-cpp's guard becomes a template and gainsAudioTranscription/Stream,Rerank,Score; privacy-filter gets the guard forTokenClassify.reconcilealready drops the stale row onIsModelMismatch.Verified
FileStagingClientrewrites onlySrc/Dst/Voice/Model/StartImage/EndImage/Audio— neverModelIdentity.TTS and SoundGeneration get a separate field
Both already carry a
modelstring, and it is not usable for identity:FileStagingClient.TTS/.TTSStreamrewrite it into a worker-local absolute path while the load-time value is untranslated, so in distributed mode the two already differ. Comparing them would reject valid requests in exactly the configuration this guards.They therefore get a distinct
ModelIdentitycarrying the untranslated value.newTTSRequestnow takes both, with specs pinning that they are distinct inputs.Deliberately excluded, with reasons
AudioEncode/AudioDecode. The opus codec backend is loaded atcore/http/endpoints/openai/realtime_webrtc.go:98with a literalmodel.WithModel("opus")and noModelConfiganywhere in the path; the requests are built in a different type and file. No value there carries the structural equality guarantee that makes this safe — an identity would be a duplicated literal, i.e. convention rather than construction, which is the false-rejection risk #10970 refused. Pinned by spec in both Go and Python so a future completeness pass must decide deliberately.AudioTranscriptionLive,AudioTransformStream,AudioToAudioStream,Forward.ControlBackendmembers that bypassreconcileentirely; guarding them needs identity on a config message plus first-message inspection in each handler. Noted in the proto and Python comments.Transparent retry: assessed, not implemented
reconcilereceives only anerror. Retrying would need each wrapper to pass a re-invocable closure,InFlightTrackingClientto hold a back-reference toSmartRouterplus all fiveRouteparameters it does not currently know, ownership of a secondReleasethe outer caller cannot see, and a guard against Route step 2 — which detaches from the caller's context and can stage multiple GB under a distributed lock, inside a request that has already spent its budget. That is a routing-lifecycle change, not a retry.Behaviour is unchanged: the first request after a misroute fails cleanly, the second succeeds.
Compatibility
Empty means skip on both sides. Old controller → new backend sends nothing; a backend loaded by an old controller has recorded nothing. The 6 bare-request-struct sites in
tests/e2e-backends/backend_test.goare unaffected, and specs in Go and Python pin the empty case so a future tightening fails there first.Testing
Red first and functionally in all three languages:
pkg/grpc: "VoiceEmbed must reject a wrong-model request: Expected an error to have occurred. Got: nil"core/backend: 17 specs, "must send ModelConfig.Model … Expected<string>: (empty)"The TTS spec was rewritten to fail on behaviour after it was found failing on arity.
make protogen-goclean;go build ./core/... ./pkg/... ./tests/...OK;go test -count=1green onpkg/grpc,core/backend,core/services/nodes(188s),core/http/endpoints/...; Python 20/20 including the auth-off default path (get_auth_interceptors()withLOCALAI_GRPC_AUTH_TOKENunset — the trap #10970 hit, where anything wired after the early return silently does nothing across all 36 backends);make lint0 issues.Not verified — needs CI
The C++ changes do not compile in the development environment (no protobuf/gRPC headers in the worktree;
backend/cpp/run-unit-tests.shonly covers stdlib-only tests). The llama-cpp guard is now a template over the request type with 4 new call sites, and privacy-filter gets the guard from scratch including a newg_loaded_model_identityglobal. Both need a CI build before merge. #10970 shipped its C++ half the same way, but this is stated explicitly rather than assumed to be at parity.🤖 Generated with Claude Code