[Rust Frontend] Support `--lora-modules` for static adapter loading (#54837)
Co-authored-by: Bugen Zhao <i@bugenzhao.com> Signed-off-by: Will Eaton <weaton@redhat.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Will Eaton committed
Sep 3, 2026 at 15:12 UTC
2db1c4dc31c58bc4fd1f5fc48dbfceebc598e502
12 files changed
+497
-99
rust/src/cmd/src/cli.rs
+11
-2
@@ -29,8 +29,8 @@ use vllm_managed_engine::ManagedEngineConfig;
29
use vllm_managed_engine::cli::{ManagedEngineArgs, repartition_managed_engine_args};
30
use vllm_server::{
31
ApiServerOptions, ChatTemplateContentFormatOption, Config, CoordinatorMode, CorsConfig,
32
- DEFAULT_KEEP_ALIVE_TIMEOUT, HttpListenerMode, ParserSelection, RenderConfig, RendererSelection,
33
- TlsConfig,
32
+ DEFAULT_KEEP_ALIVE_TIMEOUT, HttpListenerMode, LoraModulePath, ParserSelection, RenderConfig,
33
+ RendererSelection, TlsConfig,
34
};
35
36
use crate::cli::unsupported::UnsupportedArgs;
@@ -269,6 +269,13 @@ pub struct SharedRuntimeArgs {
269
#[serde(default)]
270
pub limit_mm_per_prompt: MmLimitPerPrompt,
271
272
+ /// LoRA adapters to load before serving, each as `name=path` or a JSON
273
+ /// object: `{"name": "name", "path": "lora_path", "base_model_name": "id"}`.
274
+ /// Requires `--enable-lora`; startup fails if any adapter cannot be loaded.
275
+ #[arg(long, num_args = 1.., value_name = "MODULE")]
276
+ #[serde(default)]
277
+ pub lora_modules: Vec<LoraModulePath>,
278
+
279
/// The format to render message content within a chat template.
280
///
281
/// * "auto" detects the format from the template
@@ -498,6 +505,7 @@ impl SharedRuntimeArgs {
505
chat_template: self.chat_template,
506
default_chat_template_kwargs: self.default_chat_template_kwargs,
507
limit_mm_per_prompt: self.limit_mm_per_prompt,
508
+ lora_modules: self.lora_modules,
509
chat_template_content_format: self.chat_template_content_format,
510
max_logprobs: self.max_logprobs,
511
api_server_options,
@@ -552,6 +560,7 @@ impl SharedRuntimeArgs {
560
chat_template: self.chat_template,
561
default_chat_template_kwargs: self.default_chat_template_kwargs,
562
limit_mm_per_prompt: self.limit_mm_per_prompt,
563
+ lora_modules: self.lora_modules,
564
chat_template_content_format: self.chat_template_content_format,
565
max_logprobs: self.max_logprobs,
566
api_server_options,
rust/src/cmd/src/cli/tests.rs
+105
-1
@@ -4,7 +4,8 @@
4
use expect_test::expect;
5
use vllm_engine_core_client::TransportMode;
6
use vllm_server::{
7
- Config, GenerationConfigMode, HttpListenerMode, ParserSelection, RendererSelection,
7
+ Config, GenerationConfigMode, HttpListenerMode, LoraModulePath, ParserSelection,
8
+ RendererSelection,
9
};
10
11
use super::{BenchCommand, Cli, Command};
@@ -103,6 +104,7 @@ fn serve_args_forward_python_flags_with_separator() {
104
chat_template: None,
105
default_chat_template_kwargs: None,
106
limit_mm_per_prompt: {},
107
+ lora_modules: [],
108
chat_template_content_format: Auto,
109
enable_log_requests: false,
110
enable_prompt_tokens_details: false,
@@ -623,6 +625,103 @@ fn frontend_args_json_passes_enable_request_id_headers_into_config() {
625
assert!(config.api_server_options.enable_request_id_headers);
626
}
627
628
+#[test]
629
+fn serve_args_parse_lora_modules_in_both_forms() {
630
+ let cli = Cli::try_parse_from([
631
+ "vllm-rs",
632
+ "serve",
633
+ "Qwen/Qwen3-0.6B",
634
+ "--enable-lora",
635
+ "--lora-modules",
636
+ "alice=charent/self_cognition_Alice",
637
+ r#"{"name": "bob", "path": "/adapters/bob", "base_model_name": "base"}"#,
638
+ "--lora-modules",
639
+ "carol=org/carol",
640
+ ])
641
+ .unwrap();
642
+
643
+ let Command::Serve(args) = cli.command else {
644
+ panic!("expected serve args");
645
+ };
646
+ // Frontend-only flag: the managed Python engine must not see it.
647
+ assert_eq!(args.managed_engine.python_args, vec!["--enable-lora"]);
648
+ let config = args.to_frontend_config("tcp://127.0.0.1:62100".to_string());
649
+ expect![[r#"
650
+ [
651
+ LoraModulePath {
652
+ name: "alice",
653
+ path: "charent/self_cognition_Alice",
654
+ base_model_name: None,
655
+ is_3d_lora_weight: false,
656
+ },
657
+ LoraModulePath {
658
+ name: "bob",
659
+ path: "/adapters/bob",
660
+ base_model_name: Some(
661
+ "base",
662
+ ),
663
+ is_3d_lora_weight: false,
664
+ },
665
+ LoraModulePath {
666
+ name: "carol",
667
+ path: "org/carol",
668
+ base_model_name: None,
669
+ is_3d_lora_weight: false,
670
+ },
671
+ ]
672
+ "#]]
673
+ .assert_debug_eq(&config.lora_modules);
674
+}
675
+
676
+#[test]
677
+fn serve_args_reject_malformed_lora_module() {
678
+ let error = Cli::try_parse_from([
679
+ "vllm-rs",
680
+ "serve",
681
+ "Qwen/Qwen3-0.6B",
682
+ "--lora-modules",
683
+ "alice",
684
+ ])
685
+ .unwrap_err();
686
+
687
+ expect![[r#"
688
+ error: invalid value 'alice' for '--lora-modules <MODULE>...': expected `name=path`, got `alice`
689
+
690
+ For more information, try '--help'.
691
+ "#]].assert_eq(&error.to_string());
692
+}
693
+
694
+#[test]
695
+fn frontend_args_json_passes_lora_modules_into_config() {
696
+ let cli = Cli::try_parse_from([
697
+ "vllm-rs",
698
+ "frontend",
699
+ "--listen-fd",
700
+ "3",
701
+ "--input-address",
702
+ "ipc:///tmp/input.sock",
703
+ "--output-address",
704
+ "ipc:///tmp/output.sock",
705
+ "--args-json",
706
+ r#"{"model_tag":"Qwen/Qwen3-0.6B","lora_modules":[{"name":"alice","path":"org/alice","base_model_name":null,"is_3d_lora_weight":true}]}"#,
707
+ ])
708
+ .unwrap();
709
+
710
+ let Command::Frontend(args) = cli.command else {
711
+ panic!("expected frontend args");
712
+ };
713
+ let config = args.into_config();
714
+ assert_eq!(
715
+ config.lora_modules,
716
+ vec![LoraModulePath {
717
+ name: "alice".to_string(),
718
+ path: "org/alice".to_string(),
719
+ base_model_name: None,
720
+ is_3d_lora_weight: true,
721
+ }]
722
+ );
723
+}
724
+
725
#[test]
726
fn serve_passes_api_keys_into_config() {
727
let cli = Cli::try_parse_from([
@@ -822,6 +921,7 @@ fn frontend_args_accept_json() {
921
chat_template: None,
922
default_chat_template_kwargs: None,
923
limit_mm_per_prompt: {},
924
+ lora_modules: [],
925
chat_template_content_format: Auto,
926
enable_log_requests: false,
927
enable_prompt_tokens_details: false,
@@ -1416,6 +1516,7 @@ fn serve_args_accept_handshake_aliases() {
1516
chat_template: None,
1517
default_chat_template_kwargs: None,
1518
limit_mm_per_prompt: {},
1519
+ lora_modules: [],
1520
chat_template_content_format: Auto,
1521
enable_log_requests: false,
1522
enable_prompt_tokens_details: false,
@@ -1561,6 +1662,7 @@ fn serve_frontend_config_uses_dp_address_as_advertised_host() {
1662
chat_template: None,
1663
default_chat_template_kwargs: None,
1664
limit_mm_per_prompt: {},
1665
+ lora_modules: [],
1666
chat_template_content_format: Auto,
1667
max_logprobs: None,
1668
api_server_options: ApiServerOptions {
@@ -1647,6 +1749,7 @@ fn serve_frontend_config_keeps_tcp_transport_for_non_local_only_topology() {
1749
chat_template: None,
1750
default_chat_template_kwargs: None,
1751
limit_mm_per_prompt: {},
1752
+ lora_modules: [],
1753
chat_template_content_format: Auto,
1754
max_logprobs: None,
1755
api_server_options: ApiServerOptions {
@@ -1754,6 +1857,7 @@ fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present
1857
chat_template: None,
1858
default_chat_template_kwargs: None,
1859
limit_mm_per_prompt: {},
1860
+ lora_modules: [],
1861
chat_template_content_format: Auto,
1862
max_logprobs: None,
1863
api_server_options: ApiServerOptions {
rust/src/cmd/src/cli/unsupported.rs
-7
@@ -353,13 +353,6 @@ pub struct EngineUnsupportedArgs {
353
#[serde_with::skip_serializing_none]
354
#[derive(Debug, Clone, PartialEq, Eq, Default, Args, Serialize, Deserialize)]
355
pub struct ServerUnsupportedArgs {
356
- /// LoRA modules configurations in either 'name=path' format or JSON format
357
- /// or JSON list format. Example (old format): `'name=path'` Example (new
358
- /// format): `{"name": "name", "path": "lora_path",
359
- /// "base_model_name": "id"}`
360
- #[arg(long)]
361
- pub lora_modules: Option<Unsupported>,
362
-
356
/// Whether to trust the chat template provided in the request. If False,
357
/// the server will always use the chat template specified by
358
/// `--chat-template` or the ones from tokenizer.
rust/src/engine-core-client/src/protocol/lora.rs
-41
@@ -2,7 +2,6 @@
2
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
use serde_tuple::{Deserialize_tuple, Serialize_tuple};
5
-use thiserror::Error;
5
6
use crate::protocol::OpaqueValue;
7
@@ -24,43 +23,3 @@ pub struct LoraRequest {
23
#[serde(default)]
24
pub is_3d_lora_weight: bool,
25
}
27
-
28
-#[derive(Debug, Error, PartialEq, Eq)]
29
-pub enum LoraRequestError {
30
- #[error("lora_name must not be empty")]
31
- EmptyName,
32
- #[error("lora_int_id must be nonzero")]
33
- ZeroId,
34
- #[error("lora_path must not be empty")]
35
- EmptyPath,
36
-}
37
-
38
-impl LoraRequest {
39
- pub fn new(
40
- lora_name: String,
41
- lora_int_id: u64,
42
- lora_path: String,
43
- load_inplace: bool,
44
- is_3d_lora_weight: bool,
45
- ) -> Result<Self, LoraRequestError> {
46
- if lora_name.trim().is_empty() {
47
- return Err(LoraRequestError::EmptyName);
48
- }
49
- if lora_int_id == 0 {
50
- return Err(LoraRequestError::ZeroId);
51
- }
52
- if lora_path.trim().is_empty() {
53
- return Err(LoraRequestError::EmptyPath);
54
- }
55
-
56
- Ok(Self {
57
- lora_name,
58
- lora_int_id,
59
- lora_path,
60
- base_model_name: None,
61
- tensorizer_config_dict: None,
62
- load_inplace,
63
- is_3d_lora_weight,
64
- })
65
- }
66
-}
rust/src/server/examples/external_engine_openai_qwen.rs
+1
@@ -73,6 +73,7 @@ async fn main() -> Result<()> {
73
chat_template: None,
74
default_chat_template_kwargs: None,
75
limit_mm_per_prompt: HashMap::new(),
76
+ lora_modules: Vec::new(),
77
chat_template_content_format: ChatTemplateContentFormatOption::Auto,
78
max_logprobs: None,
79
api_server_options: ApiServerOptions::default(),
rust/src/server/src/config.rs
+110
-1
@@ -3,12 +3,13 @@
3
4
use std::collections::HashMap;
5
use std::fmt;
6
+use std::str::FromStr;
7
use std::time::Duration;
8
9
use anyhow::{Result, bail};
10
use axum::http::{HeaderName, HeaderValue, Method};
11
use educe::Educe;
11
-use serde::Serialize;
12
+use serde::{Deserialize, Serialize};
13
use serde_json::Value;
14
use vllm_chat::multimodal::MmLimitPerPrompt;
15
use vllm_chat::{
@@ -157,6 +158,48 @@ impl TlsConfig {
158
}
159
}
160
161
+/// One LoRA adapter to load before the server accepts traffic.
162
+///
163
+/// Mirrors `LoRAModulePath` in vllm/entrypoints/openai/models/protocol.py,
164
+/// which is also the JSON shape the Python supervisor forwards in
165
+/// `--args-json`.
166
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167
+pub struct LoraModulePath {
168
+ /// Public model id the adapter is served under.
169
+ pub name: String,
170
+ /// Local path or Hugging Face repo id of the adapter.
171
+ pub path: String,
172
+ /// Base model reported as `parent` in `/v1/models`; defaults to the
173
+ /// primary served model name.
174
+ #[serde(default)]
175
+ pub base_model_name: Option<String>,
176
+ #[serde(default)]
177
+ pub is_3d_lora_weight: bool,
178
+}
179
+
180
+impl FromStr for LoraModulePath {
181
+ type Err = String;
182
+
183
+ /// Accept the two CLI forms Python's `LoRAParserAction` accepts:
184
+ /// `name=path`, or a JSON object with the struct's fields.
185
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
186
+ if !value.trim_start().starts_with('{') {
187
+ let (name, path) = value
188
+ .split_once('=')
189
+ .filter(|(name, path)| !name.is_empty() && !path.is_empty())
190
+ .ok_or_else(|| format!("expected `name=path`, got `{value}`"))?;
191
+ return Ok(Self {
192
+ name: name.to_string(),
193
+ path: path.to_string(),
194
+ base_model_name: None,
195
+ is_3d_lora_weight: false,
196
+ });
197
+ }
198
+ serde_json::from_str(value)
199
+ .map_err(|e| format!("expected `name=path` or a JSON object: {e}"))
200
+ }
201
+}
202
+
203
/// Normalized runtime configuration for the minimal OpenAI-compatible server.
204
#[derive(Educe, Clone, PartialEq, Eq, Serialize)]
205
#[educe(Debug)]
@@ -192,6 +235,9 @@ pub struct Config {
235
/// Maximum number of input items allowed per prompt for each modality.
236
/// Unspecified modalities are unlimited.
237
pub limit_mm_per_prompt: MmLimitPerPrompt,
238
+ /// LoRA adapters loaded at startup, in order. Startup fails if any of
239
+ /// them cannot be loaded.
240
+ pub lora_modules: Vec<LoraModulePath>,
241
/// How to serialize `message.content` for chat-template rendering.
242
pub chat_template_content_format: ChatTemplateContentFormatOption,
243
/// Optional maximum number of top log probabilities accepted by the
@@ -290,3 +336,66 @@ impl fmt::Debug for RedactedApiKeys<'_> {
336
fn fmt_redacted_api_keys(api_keys: &[String], f: &mut fmt::Formatter<'_>) -> fmt::Result {
337
fmt::Debug::fmt(&RedactedApiKeys(api_keys), f)
338
}
339
+
340
+#[cfg(test)]
341
+mod lora_module_path_tests {
342
+ use super::LoraModulePath;
343
+
344
+ #[test]
345
+ fn parses_name_equals_path() {
346
+ let module: LoraModulePath = "alice=charent/self_cognition_Alice".parse().unwrap();
347
+ assert_eq!(
348
+ module,
349
+ LoraModulePath {
350
+ name: "alice".to_string(),
351
+ path: "charent/self_cognition_Alice".to_string(),
352
+ base_model_name: None,
353
+ is_3d_lora_weight: false,
354
+ }
355
+ );
356
+ }
357
+
358
+ #[test]
359
+ fn parses_json_object() {
360
+ let module: LoraModulePath =
361
+ r#"{"name": "alice", "path": "/adapters/alice", "base_model_name": "base", "is_3d_lora_weight": true}"#
362
+ .parse()
363
+ .unwrap();
364
+ assert_eq!(
365
+ module,
366
+ LoraModulePath {
367
+ name: "alice".to_string(),
368
+ path: "/adapters/alice".to_string(),
369
+ base_model_name: Some("base".to_string()),
370
+ is_3d_lora_weight: true,
371
+ }
372
+ );
373
+ }
374
+
375
+ #[test]
376
+ fn parses_name_equals_path_with_comma() {
377
+ let module: LoraModulePath = "alice=/adapters/a,b".parse().unwrap();
378
+ assert_eq!(module.name, "alice");
379
+ assert_eq!(module.path, "/adapters/a,b");
380
+ }
381
+
382
+ #[test]
383
+ fn json_object_optional_fields_default() {
384
+ let module: LoraModulePath = r#"{"name": "a", "path": "org/a"}"#.parse().unwrap();
385
+ assert_eq!(module.base_model_name, None);
386
+ assert!(!module.is_3d_lora_weight);
387
+ }
388
+
389
+ #[test]
390
+ fn rejects_empty_name_or_path() {
391
+ assert!("=org/a".parse::<LoraModulePath>().is_err());
392
+ assert!("a=".parse::<LoraModulePath>().is_err());
393
+ }
394
+
395
+ #[test]
396
+ fn rejects_bare_path_and_bad_json() {
397
+ assert!("org/a".parse::<LoraModulePath>().is_err());
398
+ assert!(r#"{"name": "a"}"#.parse::<LoraModulePath>().is_err());
399
+ assert!(r#"[{"name": "a", "path": "org/a"}]"#.parse::<LoraModulePath>().is_err());
400
+ }
401
+}
rust/src/server/src/grpc/control.rs
+9
-6
@@ -13,6 +13,7 @@ use vllm_engine_core_client::protocol::lora::LoraRequest;
13
use vllm_engine_core_client::protocol::utility::PauseMode as EnginePauseMode;
14
15
use super::{ControlServer, pb};
16
+use crate::config::LoraModulePath;
17
use crate::lora::{LoadLoraError, LoraDisabledError, LoraPathAccessError, UnloadLoraError};
18
use crate::state::AppState;
19
@@ -147,7 +148,7 @@ fn lora_to_proto(adapter: &LoraRequest) -> pb::LoraAdapter {
148
fn load_lora_status(error: LoadLoraError) -> Status {
149
let code = match &error {
150
LoadLoraError::Disabled(_) => Code::FailedPrecondition,
150
- LoadLoraError::InvalidRequest(_) => Code::InvalidArgument,
151
+ LoadLoraError::InvalidAdapter { .. } => Code::InvalidArgument,
152
LoadLoraError::PathAccess(LoraPathAccessError::InvalidPath { .. }) => Code::InvalidArgument,
153
LoadLoraError::PathAccess(LoraPathAccessError::InvalidConfiguration { .. }) => {
154
Code::Internal
@@ -247,11 +248,13 @@ impl pb::control_server::Control for ControlServiceImpl {
248
request: Request<pb::LoadLoraRequest>,
249
) -> Result<Response<pb::LoadLoraResponse>, Status> {
250
let request = request.into_inner();
250
- let adapter = self
251
- .state
252
- .load_lora(request.lora_name, request.source_path, false, false)
253
- .await
254
- .map_err(load_lora_status)?;
251
+ let module = LoraModulePath {
252
+ name: request.lora_name,
253
+ path: request.source_path,
254
+ base_model_name: None,
255
+ is_3d_lora_weight: false,
256
+ };
257
+ let adapter = self.state.load_lora(module, false).await.map_err(load_lora_status)?;
258
Ok(Response::new(pb::LoadLoraResponse {
259
adapter: Some(lora_to_proto(&adapter)),
260
}))
rust/src/server/src/lib.rs
+30
-5
@@ -29,7 +29,7 @@ use axum::body::Body;
29
use axum::http::Request;
30
pub use config::{
31
ApiServerOptions, Config, CoordinatorMode, CorsConfig, DEFAULT_KEEP_ALIVE_TIMEOUT,
32
- HttpListenerMode, TlsConfig,
32
+ HttpListenerMode, LoraModulePath, TlsConfig,
33
};
34
use hyper::body::Incoming;
35
use hyper::server::conn::http1;
@@ -86,7 +86,7 @@ fn grpc_bind_host(listener_mode: &HttpListenerMode) -> &str {
86
}
87
88
/// Build the shared application state for one configured model and one engine
89
-/// client.
89
+/// client, including any static LoRA adapters from `--lora-modules`.
90
async fn build_state(config: &Config) -> Result<Arc<AppState>> {
91
// If no served names are specified, fall back to the backend model path so
92
// that the API always has at least one valid model ID. Use the same primary
@@ -139,7 +139,7 @@ async fn build_state(config: &Config) -> Result<Arc<AppState>> {
139
.with_tool_call_parser(config.tool_call_parser.clone())
140
.with_reasoning_parser(config.reasoning_parser.clone());
141
142
- Ok(Arc::new(
142
+ let state = Arc::new(
143
AppState::new(served_model_names, chat)
144
.with_model_path(config.model.clone())
145
.with_api_server_options(config.api_server_options)
@@ -147,7 +147,31 @@ async fn build_state(config: &Config) -> Result<Arc<AppState>> {
147
.with_api_keys(config.api_keys.clone())
148
.with_cors(config.cors.clone())
149
.with_profiler(config.profiler.clone()),
150
- ))
150
+ );
151
+
152
+ // Load operator-configured static LoRA adapters before serving, failing
153
+ // startup on any error like Python's `init_static_loras` does.
154
+ load_static_loras(&state, &config.lora_modules).await?;
155
+
156
+ Ok(state)
157
+}
158
+
159
+async fn load_static_loras(state: &AppState, modules: &[LoraModulePath]) -> Result<()> {
160
+ for module in modules {
161
+ let request = state.load_static_lora(module).await.with_context(|| {
162
+ format!(
163
+ "failed to load LoRA adapter `{}` from --lora-modules",
164
+ module.name
165
+ )
166
+ })?;
167
+ info!(
168
+ lora_name = %request.lora_name,
169
+ lora_int_id = request.lora_int_id,
170
+ lora_path = %request.lora_path,
171
+ "loaded static LoRA adapter"
172
+ );
173
+ }
174
+ Ok(())
175
}
176
177
/// Run the OpenAI-compatible HTTP server until the supplied shutdown token is
@@ -182,7 +206,8 @@ where
206
.transpose()
207
.context("invalid TLS configuration")?;
208
185
- // Also check shutdown during the (potentially long) startup handshake.
209
+ // Also check shutdown during the (potentially long) startup handshake and
210
+ // static LoRA loading.
211
let state = tokio::select! {
212
result = build_state(&config) => result?,
213
_ = shutdown.cancelled() => return Ok(()),
rust/src/server/src/lora.rs
+51
-13
@@ -9,7 +9,9 @@ use thiserror::Error;
9
use thiserror_ext::Macro;
10
use tokio::sync::{Mutex, RwLock};
11
use vllm_engine_core_client::EngineCoreClient;
12
-use vllm_engine_core_client::protocol::lora::{LoraRequest, LoraRequestError};
12
+use vllm_engine_core_client::protocol::lora::LoraRequest;
13
+
14
+use crate::config::LoraModulePath;
15
16
const RUNTIME_LORA_ALLOWED_PATH_PREFIXES_ENV: &str = "VLLM_RUNTIME_LORA_ALLOWED_PATH_PREFIXES";
17
@@ -103,12 +105,12 @@ pub(crate) struct LoraManager {
105
#[error("engine was not started with LoRA enabled")]
106
pub(crate) struct LoraDisabledError;
107
106
-#[derive(Debug, Error)]
108
+#[derive(Debug, Error, Macro)]
109
pub(crate) enum LoadLoraError {
110
#[error(transparent)]
111
Disabled(#[from] LoraDisabledError),
110
- #[error(transparent)]
111
- InvalidRequest(#[from] LoraRequestError),
112
+ #[error("{message}")]
113
+ InvalidAdapter { message: String },
114
#[error(transparent)]
115
PathAccess(#[from] LoraPathAccessError),
116
#[error("LoRA adapter `{lora_name}` is already loaded")]
@@ -186,14 +188,49 @@ impl LoraManager {
188
&self,
189
engine_core_client: &EngineCoreClient,
190
base_model_names: &[String],
189
- lora_name: String,
190
- lora_path: String,
191
+ mut module: LoraModulePath,
192
load_inplace: bool,
192
- is_3d_lora_weight: bool,
193
) -> Result<LoraRequest, LoadLoraError> {
194
let allowed_prefixes = runtime_lora_allowed_path_prefixes();
195
- let lora_path = validate_lora_path_access(&lora_path, allowed_prefixes.as_deref())?
196
- .unwrap_or(lora_path);
195
+ if let Some(canonical_path) =
196
+ validate_lora_path_access(&module.path, allowed_prefixes.as_deref())?
197
+ {
198
+ module.path = canonical_path;
199
+ }
200
+ self.register(engine_core_client, base_model_names, module, load_inplace).await
201
+ }
202
+
203
+ /// Load an operator-configured adapter (`--lora-modules`). Unlike the
204
+ /// runtime endpoint, the path is trusted as given: no allowed-prefix
205
+ /// check applies.
206
+ pub async fn load_static_lora(
207
+ &self,
208
+ engine_core_client: &EngineCoreClient,
209
+ base_model_names: &[String],
210
+ module: &LoraModulePath,
211
+ ) -> Result<LoraRequest, LoadLoraError> {
212
+ self.register(engine_core_client, base_model_names, module.clone(), false).await
213
+ }
214
+
215
+ async fn register(
216
+ &self,
217
+ engine_core_client: &EngineCoreClient,
218
+ base_model_names: &[String],
219
+ module: LoraModulePath,
220
+ load_inplace: bool,
221
+ ) -> Result<LoraRequest, LoadLoraError> {
222
+ let LoraModulePath {
223
+ name: lora_name,
224
+ path: lora_path,
225
+ base_model_name,
226
+ is_3d_lora_weight,
227
+ } = module;
228
+ if lora_name.trim().is_empty() {
229
+ bail_invalid_adapter!("lora_name must not be empty");
230
+ }
231
+ if lora_path.trim().is_empty() {
232
+ bail_invalid_adapter!("lora_path must not be empty");
233
+ }
234
let _guard = self.update_lock.lock().await;
235
if base_model_names.iter().any(|name| name == &lora_name) {
236
return Err(LoadLoraError::BaseModelName { lora_name });
@@ -206,14 +243,15 @@ impl LoraManager {
243
244
let lora_int_id = existing_lora_int_id
245
.unwrap_or_else(|| self.id_counter.fetch_add(1, Ordering::Relaxed) + 1);
209
- let lora_request = LoraRequest::new(
210
- lora_name.clone(),
246
+ let lora_request = LoraRequest {
247
+ lora_name: lora_name.clone(),
248
lora_int_id,
249
lora_path,
250
+ base_model_name,
251
+ tensorizer_config_dict: None,
252
load_inplace,
253
is_3d_lora_weight,
215
- )
216
- .map_err(LoadLoraError::InvalidRequest)?;
254
+ };
255
drop(requests);
256
257
let loaded = engine_core_client.add_lora(&lora_request).await.map_err(|source| {
rust/src/server/src/routes/lora.rs
+9
-7
@@ -8,6 +8,7 @@ use serde::Deserialize;
8
use thiserror_ext::AsReport;
9
use validator::Validate;
10
11
+use crate::config::LoraModulePath;
12
use crate::error::ApiError;
13
use crate::lora::{LoadLoraError, LoraPathAccessError, UnloadLoraError};
14
use crate::routes::openai::utils::types::Normalizable;
@@ -39,7 +40,7 @@ fn load_lora_api_error(error: LoadLoraError) -> ApiError {
40
let message = error.to_report_string();
41
match error {
42
LoadLoraError::Disabled(_) => ApiError::invalid_request(message, None),
42
- LoadLoraError::InvalidRequest(_) => ApiError::invalid_request(message, None),
43
+ LoadLoraError::InvalidAdapter { .. } => ApiError::invalid_request(message, None),
44
LoadLoraError::PathAccess(LoraPathAccessError::InvalidPath { .. }) => {
45
ApiError::invalid_request(message, Some("lora_path"))
46
}
@@ -81,13 +82,14 @@ pub async fn load_lora_adapter(
82
ValidatedJson(request): ValidatedJson<LoadLoraAdapterRequest>,
83
) -> Result<String, ApiError> {
84
let lora_name = request.lora_name;
85
+ let module = LoraModulePath {
86
+ name: lora_name.clone(),
87
+ path: request.lora_path,
88
+ base_model_name: None,
89
+ is_3d_lora_weight: request.is_3d_lora_weight,
90
+ };
91
state
85
- .load_lora(
86
- lora_name.clone(),
87
- request.lora_path,
88
- request.load_inplace,
89
- request.is_3d_lora_weight,
90
- )
92
+ .load_lora(module, request.load_inplace)
93
.await
94
.map_err(load_lora_api_error)?;
95
rust/src/server/src/routes/tests.rs
+154
-9
@@ -57,7 +57,8 @@ use super::{
57
build_router_with_scale_out_endpoints, parse_scale_out_endpoints_flag,
58
render::build_router as build_render_router,
59
};
60
-use crate::config::{ApiServerOptions, CorsConfig};
60
+use crate::config::{ApiServerOptions, CorsConfig, LoraModulePath};
61
+use crate::lora::LoadLoraError;
62
use crate::render::RenderState;
63
use crate::state::AppState;
64
@@ -941,6 +942,20 @@ async fn test_admin_app_with_ready_and_engine_script<F>(
942
ready: EngineCoreReadyResponse,
943
script: F,
944
) -> (axum::Router, MockEngineTask)
945
+where
946
+ F: for<'a> FnOnce(&'a mut DealerSocket, &'a mut PushSocket) -> TestFuture<'a> + Send + 'static,
947
+{
948
+ let (state, engine_task) = test_admin_state_with_ready_and_engine_script(ready, script).await;
949
+ (
950
+ build_router_with_dev_mode_and_lora(state, true, true),
951
+ engine_task,
952
+ )
953
+}
954
+
955
+async fn test_admin_state_with_ready_and_engine_script<F>(
956
+ ready: EngineCoreReadyResponse,
957
+ script: F,
958
+) -> (Arc<AppState>, MockEngineTask)
959
where
960
F: for<'a> FnOnce(&'a mut DealerSocket, &'a mut PushSocket) -> TestFuture<'a> + Send + 'static,
961
{
@@ -968,18 +983,148 @@ where
983
984
let chat = ChatLlm::from_shared_backend(test_llm(client), Arc::new(FakeChatBackend::new()));
985
(
971
- build_router_with_dev_mode_and_lora(
972
- Arc::new(AppState::new(
973
- vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()],
974
- chat,
975
- )),
976
- true,
977
- true,
978
- ),
986
+ Arc::new(AppState::new(
987
+ vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()],
988
+ chat,
989
+ )),
990
engine_task,
991
)
992
}
993
994
+/// Engine script that answers one `add_lora` utility call with `loaded` and
995
+/// hands the decoded LoRA request tuple to `check`.
996
+fn add_lora_script(
997
+ loaded: bool,
998
+ check: impl FnOnce(&[Value]) + Send + 'static,
999
+) -> impl for<'a> FnOnce(&'a mut DealerSocket, &'a mut PushSocket) -> TestFuture<'a> + Send + 'static
1000
+{
1001
+ move |dealer, push| {
1002
+ boxed_test_future(async move {
1003
+ let utility = recv_engine_message(dealer).await;
1004
+ assert_eq!(utility[0].as_ref(), &[0x03]);
1005
+ let payload = decode_value(&utility[1]).expect("decode utility payload");
1006
+ let array = payload.as_array().expect("utility payload array");
1007
+ let call_id = array[1].as_u64().expect("call id");
1008
+ assert_eq!(array[2], Value::from("add_lora"));
1009
+ let args = array[3].as_array().expect("utility args");
1010
+ check(args[0].as_array().expect("lora request tuple"));
1011
+ send_outputs(push, utility_outputs(call_id, utility_result_value(loaded))).await;
1012
+ })
1013
+ }
1014
+}
1015
+
1016
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1017
+#[serial]
1018
+async fn static_lora_module_loads_and_lists_with_parent() {
1019
+ // An absolute local path: the runtime endpoint would reject it without
1020
+ // VLLM_RUNTIME_LORA_ALLOWED_PATH_PREFIXES, static config trusts it.
1021
+ let mut ready = default_ready_response();
1022
+ ready.supports_lora = true;
1023
+ ready.max_loras = 4;
1024
+ let (state, _engine_task) = test_admin_state_with_ready_and_engine_script(
1025
+ ready,
1026
+ add_lora_script(true, |lora| {
1027
+ assert_eq!(lora[0], Value::from("alice"));
1028
+ assert_eq!(lora[1], Value::from(1));
1029
+ assert_eq!(lora[2], Value::from("/adapters/alice"));
1030
+ assert_eq!(lora[3], Value::from("base-model"));
1031
+ }),
1032
+ )
1033
+ .await;
1034
+
1035
+ let module = LoraModulePath {
1036
+ name: "alice".to_string(),
1037
+ path: "/adapters/alice".to_string(),
1038
+ base_model_name: Some("base-model".to_string()),
1039
+ is_3d_lora_weight: false,
1040
+ };
1041
+ let request = state.load_static_lora(&module).await.expect("load static lora");
1042
+ assert_eq!(request.lora_name, "alice");
1043
+ assert_eq!(request.lora_int_id, 1);
1044
+
1045
+ let mut app = build_router_with_dev_mode_and_lora(state, true, false);
1046
+ let models = app
1047
+ .call(Request::builder().uri("/v1/models").body(Body::empty()).expect("build request"))
1048
+ .await
1049
+ .expect("call app");
1050
+ let body = to_bytes(models.into_body(), usize::MAX).await.expect("read body");
1051
+ let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
1052
+ assert_eq!(json["data"][1]["id"], "alice");
1053
+ assert_eq!(json["data"][1]["root"], "/adapters/alice");
1054
+ assert_eq!(json["data"][1]["parent"], "base-model");
1055
+}
1056
+
1057
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1058
+#[serial]
1059
+async fn static_lora_module_requires_lora_enabled_engine() {
1060
+ let (state, _engine_task) = test_admin_state_with_ready_and_engine_script(
1061
+ default_ready_response(),
1062
+ |_dealer, _push| boxed_test_future(async {}),
1063
+ )
1064
+ .await;
1065
+
1066
+ let module = LoraModulePath {
1067
+ name: "alice".to_string(),
1068
+ path: "org/alice".to_string(),
1069
+ base_model_name: None,
1070
+ is_3d_lora_weight: false,
1071
+ };
1072
+ let error = state.load_static_lora(&module).await.expect_err("engine has no lora");
1073
+ assert!(matches!(error, LoadLoraError::Disabled(_)), "{error:?}");
1074
+}
1075
+
1076
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1077
+#[serial]
1078
+async fn static_lora_module_fails_when_engine_rejects_it() {
1079
+ let mut ready = default_ready_response();
1080
+ ready.supports_lora = true;
1081
+ ready.max_loras = 4;
1082
+ let (state, _engine_task) =
1083
+ test_admin_state_with_ready_and_engine_script(ready, add_lora_script(false, |_| {})).await;
1084
+
1085
+ let module = LoraModulePath {
1086
+ name: "alice".to_string(),
1087
+ path: "org/alice".to_string(),
1088
+ base_model_name: None,
1089
+ is_3d_lora_weight: false,
1090
+ };
1091
+ let error = state.load_static_lora(&module).await.expect_err("engine rejected");
1092
+ assert!(
1093
+ matches!(error, LoadLoraError::NotLoaded { .. }),
1094
+ "{error:?}"
1095
+ );
1096
+ assert!(state.served_lora_requests().await.is_empty());
1097
+}
1098
+
1099
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1100
+#[serial]
1101
+async fn static_lora_module_rejects_empty_name_or_path() {
1102
+ // The JSON form of `--lora-modules` is not validated at parse time; the
1103
+ // manager rejects empty fields before any engine RPC.
1104
+ let mut ready = default_ready_response();
1105
+ ready.supports_lora = true;
1106
+ ready.max_loras = 4;
1107
+ let (state, _engine_task) =
1108
+ test_admin_state_with_ready_and_engine_script(ready, |_dealer, _push| {
1109
+ boxed_test_future(async {})
1110
+ })
1111
+ .await;
1112
+
1113
+ for (name, path) in [("", "org/alice"), ("alice", "")] {
1114
+ let module = LoraModulePath {
1115
+ name: name.to_string(),
1116
+ path: path.to_string(),
1117
+ base_model_name: None,
1118
+ is_3d_lora_weight: false,
1119
+ };
1120
+ let error = state.load_static_lora(&module).await.expect_err("empty field");
1121
+ assert!(
1122
+ matches!(error, LoadLoraError::InvalidAdapter { .. }),
1123
+ "{error:?}"
1124
+ );
1125
+ }
1126
+}
1127
+
1128
async fn test_app_with_engine_handle() -> (axum::Router, MockEngineTask) {
1129
test_app_with_stream_output_specs(default_stream_output_specs()).await
1130
}
rust/src/server/src/state.rs
+17
-7
@@ -14,7 +14,7 @@ use vllm_engine_core_client::EngineCoreClient;
14
use vllm_engine_core_client::protocol::lora::LoraRequest;
15
use vllm_engine_core_client::runtime::BackgroundShutdownRuntime;
16
17
-use crate::config::{ApiServerOptions, CorsConfig};
17
+use crate::config::{ApiServerOptions, CorsConfig, LoraModulePath};
18
use crate::lora::{
19
LoadLoraError, LoraDisabledError, LoraManager, LoraModelResolution, UnloadLoraError,
20
};
@@ -181,24 +181,34 @@ impl AppState {
181
/// Returns error if the engine was started without LoRA support.
182
pub async fn load_lora(
183
&self,
184
- lora_name: String,
185
- lora_path: String,
184
+ module: LoraModulePath,
185
load_inplace: bool,
187
- is_3d_lora_weight: bool,
186
) -> Result<LoraRequest, LoadLoraError> {
187
self.ensure_lora_enabled()?;
188
self.lora_manager
189
.load_lora(
190
self.engine_core_client(),
191
&self.served_model_names,
194
- lora_name,
195
- lora_path,
192
+ module,
193
load_inplace,
197
- is_3d_lora_weight,
194
)
195
.await
196
}
197
198
+ /// Load one adapter from `--lora-modules` and register it as a public
199
+ /// model name.
200
+ ///
201
+ /// Returns error if the engine was started without LoRA support.
202
+ pub async fn load_static_lora(
203
+ &self,
204
+ module: &LoraModulePath,
205
+ ) -> Result<LoraRequest, LoadLoraError> {
206
+ self.ensure_lora_enabled()?;
207
+ self.lora_manager
208
+ .load_static_lora(self.engine_core_client(), &self.served_model_names, module)
209
+ .await
210
+ }
211
+
212
/// Remove one dynamic LoRA adapter from the engine and public model
213
/// registry.
214
///