[Rust Frontend][gRPC] Advertise LoRA capabilities (#52031)

Signed-off-by: Connor Carpenter <connorc@nvidia.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Connor Carpenter committed Aug 17, 2026 at 13:27 UTC f08a95f8d84260fb093975cbf2bbccee8d106b69
10 files changed +53
rust/proto/control.proto
+2
@@ -38,6 +38,7 @@ message ServerInfo {
38 uint64 total_kv_blocks = 7;
39 uint64 max_running_requests = 8;
40 uint64 max_batched_tokens = 9;
41 + uint32 max_loras = 10;
42 RlCapabilities rl_capabilities = 11;
43 }
44
@@ -65,6 +66,7 @@ message ModelInfo {
66
67 bool supports_text_input = 20;
68 bool supports_token_ids_input = 21;
69 + bool supports_lora = 22;
70 bool supports_multimodal = 23;
71 string reasoning_parser = 24;
72 string tool_call_parser = 25;
rust/src/engine-core-client/src/client.rs
+27
@@ -286,6 +286,7 @@ impl EngineCoreClient {
286 config: EngineCoreClientConfig,
287 connected: transport::ConnectedTransport,
288 ) -> Result<Self> {
289 + validate_lora_capabilities(&connected.engines)?;
290 let (output_tx, output_rx) = mpsc::channel(64);
291 let (abort_tx, abort_rx) = mpsc::unbounded_channel();
292 let engines = connected.engines;
@@ -476,6 +477,32 @@ impl EngineCoreClient {
477 }
478 }
479
480 +fn validate_lora_capabilities(engines: &[ConnectedEngine]) -> Result<()> {
481 + let first = engines.first().expect("engine core client requires at least one engine");
482 + for engine in engines {
483 + let ready = &engine.ready_response;
484 + if ready.supports_lora != (ready.max_loras > 0) {
485 + return Err(Error::UnexpectedHandshakeMessage {
486 + message: format!(
487 + "engine {:?} reported inconsistent LoRA capability (supports_lora={}, max_loras={})",
488 + engine.engine_id, ready.supports_lora, ready.max_loras
489 + ),
490 + });
491 + }
492 + if ready.supports_lora != first.ready_response.supports_lora
493 + || ready.max_loras != first.ready_response.max_loras
494 + {
495 + return Err(Error::UnexpectedHandshakeMessage {
496 + message: format!(
497 + "engine {:?} reported LoRA capability inconsistent with engine {:?}",
498 + engine.engine_id, first.engine_id
499 + ),
500 + });
501 + }
502 + }
503 + Ok(())
504 +}
505 +
506 // Client API implementation.
507 impl EngineCoreClient {
508 /// Add a new request to the engine and return a per-request raw output
rust/src/engine-core-client/src/mock_engine.rs
+2
@@ -64,6 +64,8 @@ pub fn default_ready_response() -> EngineCoreReadyResponse {
64 max_num_seqs: 256,
65 max_num_batched_tokens: 8192,
66 instance_id: "test-instance".to_string(),
67 + supports_lora: false,
68 + max_loras: 0,
69 kv_cache_size_tokens: None,
70 kv_cache_max_concurrency: None,
71 kv_events_config: None,
rust/src/engine-core-client/src/protocol/handshake.rs
+4
@@ -80,6 +80,10 @@ pub struct EngineCoreReadyResponse {
80 pub max_num_batched_tokens: u64,
81 /// Unique identifier for this server instance.
82 pub instance_id: String,
83 + /// Whether the engine was started with LoRA support enabled.
84 + pub supports_lora: bool,
85 + /// Maximum number of LoRA adapters the engine may keep active.
86 + pub max_loras: u32,
87 /// Total KV cache capacity in tokens, if reported.
88 pub kv_cache_size_tokens: Option<u64>,
89 /// Maximum achievable request concurrency given the KV cache, if reported.
rust/src/engine-core-client/src/tests/client.rs
+2
@@ -2686,6 +2686,8 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2686
2687 let ready_response: EngineCoreReadyResponse =
2688 rmp_serde::from_slice(&hex::decode(ready_response_hex).unwrap()).unwrap();
2689 + assert!(ready_response.supports_lora);
2690 + assert_eq!(ready_response.max_loras, 8);
2691 assert_eq!(
2692 ready_response.weight_transfer_backend.as_deref(),
2693 Some("nccl")
rust/src/engine-core-client/src/tests/python_compat.py
+4
@@ -409,6 +409,8 @@ class EngineCoreReadyResponse:
409 max_num_seqs: int
410 max_num_batched_tokens: int
411 instance_id: str
412 + supports_lora: bool
413 + max_loras: int
414 kv_cache_size_tokens: int | None = None
415 kv_cache_max_concurrency: float | None = None
416 kv_events_config: KVEventsConfig | None = None
@@ -433,6 +435,8 @@ ready_response = EngineCoreReadyResponse(
435 max_num_seqs=256,
436 max_num_batched_tokens=8192,
437 instance_id="test-instance",
438 + supports_lora=True,
439 + max_loras=8,
440 weight_transfer_backend="nccl",
441 enable_sleep_mode=True,
442 supports_draft_weight_updates=True,
rust/src/server/src/grpc/control.rs
+2
@@ -150,6 +150,7 @@ impl pb::control_server::Control for ControlServiceImpl {
150 total_kv_blocks: self.state.engine_core_client().total_num_gpu_blocks(),
151 max_running_requests: ready.max_num_seqs,
152 max_batched_tokens: ready.max_num_batched_tokens,
153 + max_loras: ready.max_loras,
154 rl_capabilities: Some(self.rl_capabilities()),
155 }))
156 }
@@ -166,6 +167,7 @@ impl pb::control_server::Control for ControlServiceImpl {
167 // GenerateRequest accepts both prompt representations.
168 supports_text_input: true,
169 supports_token_ids_input: true,
170 + supports_lora: self.ready().supports_lora,
171 supports_multimodal: self.state.chat.supports_multimodal(),
172 reasoning_parser: self
173 .state
tests/v1/engine/test_engine_core_client.py
+2
@@ -335,6 +335,8 @@ def test_apply_ready_response_syncs_block_size():
335 max_num_seqs=256,
336 max_num_batched_tokens=8192,
337 instance_id="test-instance",
338 + supports_lora=False,
339 + max_loras=0,
340 )
341 )
342 client._apply_ready_response(payload)
vllm/v1/engine/__init__.py
+2
@@ -88,6 +88,8 @@ class EngineCoreReadyResponse:
88 max_num_seqs: int
89 max_num_batched_tokens: int
90 instance_id: str
91 + supports_lora: bool
92 + max_loras: int
93 # KV cache capacity (None for encoder-only/attention-free models).
94 kv_cache_size_tokens: int | None = None
95 kv_cache_max_concurrency: float | None = None
vllm/v1/engine/core.py
+6
@@ -1636,6 +1636,12 @@ class EngineCoreProc(EngineCore):
1636 max_num_seqs=scheduler_config.max_num_seqs,
1637 max_num_batched_tokens=scheduler_config.max_num_batched_tokens,
1638 instance_id=self.vllm_config.instance_id,
1639 + supports_lora=self.vllm_config.lora_config is not None,
1640 + max_loras=(
1641 + self.vllm_config.lora_config.max_loras
1642 + if self.vllm_config.lora_config is not None
1643 + else 0
1644 + ),
1645 kv_events_config=self.scheduler.get_kv_event_publisher_config(),
1646 weight_transfer_backend=(
1647 self.vllm_config.weight_transfer_config.backend