[Rust Frontend] Support HF config overrides `--hf-overrides` (#56931)
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
Sep 16, 2026 at 10:52 UTC
bfd713bf8b3869a7b65a8f3198095c3d73c88875
16 files changed
+298
-19
rust/Cargo.lock
+1
@@ -5813,6 +5813,7 @@ dependencies = [
5813
"vllm-engine-core-client",
5814
"vllm-managed-engine",
5815
"vllm-server",
5816
+ "vllm-text",
5817
"vllm-tracing",
5818
]
5819
rust/src/chat/src/backend/hf.rs
+9
-13
@@ -129,7 +129,8 @@ pub(super) async fn load_model_backends(
129
model_id: &str,
130
options: LoadModelBackendsOptions,
131
) -> Result<LoadedModelBackends> {
132
- let files = ResolvedModelFiles::new(model_id, options.revision.as_deref()).await?;
132
+ let mut files = ResolvedModelFiles::new(model_id, options.revision.as_deref()).await?;
133
+ files.apply_overrides(&options.hf_overrides)?;
134
let text_backend = HfTextBackend::from_resolved_model_files(
135
files.clone(),
136
model_id.to_string(),
@@ -166,13 +167,11 @@ fn resolve_multimodal_render_info(
167
#[cfg(test)]
168
mod tests {
169
use std::collections::HashMap;
169
- use std::path::PathBuf;
170
use std::sync::Arc;
171
172
use tempfile::tempdir;
173
use thiserror_ext::AsReport as _;
174
use vllm_text::Prompt;
175
- use vllm_text::backend::hf::TokenizerSource;
175
use vllm_text::tokenizer::DynTokenizer;
176
use vllm_tokenizer::test_utils::TestTokenizer;
177
@@ -206,16 +205,12 @@ mod tests {
205
write_json(&config_path, config_json);
206
write_json(&tokenizer_config_path, tokenizer_config_json);
207
209
- vllm_text::backend::hf::ResolvedModelFiles {
210
- tokenizer: TokenizerSource::HuggingFace(PathBuf::from("/tmp/unused-tokenizer.json")),
211
- tokenizer_config_path: Some(tokenizer_config_path),
212
- generation_config_path: None,
213
- preprocessor_config_path: None,
214
- video_preprocessor_config_path: None,
215
- processor_config_path: None,
216
- chat_template_path: None,
217
- config_path: Some(config_path),
218
- }
208
+ write_json(&root.join("tokenizer.json"), "{}");
209
+ futures::executor::block_on(vllm_text::backend::hf::ResolvedModelFiles::new(
210
+ root.to_str().unwrap(),
211
+ None,
212
+ ))
213
+ .unwrap()
214
}
215
216
fn test_tokenizer() -> DynTokenizer {
@@ -232,6 +227,7 @@ mod tests {
227
"test-model".to_string(),
228
LoadModelBackendsOptions {
229
revision: None,
230
+ hf_overrides: Default::default(),
231
generation_config: Default::default(),
232
renderer,
233
language_model_only: false,
rust/src/chat/src/backend/mod.rs
+3
@@ -5,6 +5,7 @@ use std::collections::HashMap;
5
use std::sync::Arc;
6
7
use serde_json::Value;
8
+use vllm_text::backend::hf::HfOverrides;
9
use vllm_text::{DynTextBackend, GenerationConfigMode, TextBackend};
10
11
use crate::error::Result;
@@ -63,6 +64,8 @@ pub type DynChatTextBackend = Arc<dyn ChatTextBackend>;
64
pub struct LoadModelBackendsOptions {
65
/// Model revision on the Hugging Face Hub (branch, tag, or commit SHA).
66
pub revision: Option<String>,
67
+ /// JSON Merge Patch applied to the model config before loading any backend.
68
+ pub hf_overrides: HfOverrides,
69
/// Which generation-config sampling defaults to inherit.
70
pub generation_config: GenerationConfigMode,
71
/// Which chat renderer implementation to use.
rust/src/cmd/Cargo.toml
+1
@@ -33,6 +33,7 @@ vllm-chat.workspace = true
33
vllm-engine-core-client.workspace = true
34
vllm-managed-engine.workspace = true
35
vllm-server.workspace = true
36
+vllm-text.workspace = true
37
vllm-tracing.workspace = true
38
39
[dev-dependencies]
rust/src/cmd/src/cli.rs
+17
@@ -33,6 +33,7 @@ use vllm_server::{
33
DEFAULT_KEEP_ALIVE_TIMEOUT, HttpListenerMode, LoraModulePath, ParserSelection, RenderConfig,
34
RendererSelection,
35
};
36
+use vllm_text::backend::hf::HfOverrides;
37
38
use crate::cli::ssl::SslArgs;
39
use crate::cli::unsupported::UnsupportedArgs;
@@ -113,6 +114,9 @@ pub struct RenderArgs {
114
/// Model revision on the Hugging Face Hub (branch, tag, or commit SHA).
115
#[arg(long)]
116
revision: Option<String>,
117
+ /// JSON Merge Patch (RFC 7396) for config.json; null removes a field.
118
+ #[arg(long, value_parser = parse_json::<HfOverrides>, default_value = "{}", value_name = "JSON")]
119
+ hf_overrides: HfOverrides,
120
/// HTTP bind host.
121
#[arg(long, default_value = "127.0.0.1")]
122
host: String,
@@ -161,6 +165,7 @@ impl RenderArgs {
165
RenderConfig {
166
model: self.model,
167
revision: self.revision,
168
+ hf_overrides: self.hf_overrides,
169
served_model_name: self.served_model_name,
170
host: self.host,
171
port: self.port,
@@ -204,6 +209,12 @@ pub struct SharedRuntimeArgs {
209
#[serde(default)]
210
pub revision: Option<String>,
211
212
+ /// JSON Merge Patch (RFC 7396) for config.json; null removes a field.
213
+ /// Objects merge recursively and arrays/scalars replace existing values.
214
+ #[arg(long, value_parser = parse_json::<HfOverrides>, default_value = "{}", value_name = "JSON")]
215
+ #[serde(default)]
216
+ pub hf_overrides: HfOverrides,
217
+
218
/// The source of generation-config sampling defaults. `"auto"` loads the
219
/// model's defaults, while `"vllm"` uses vLLM's neutral defaults.
220
#[arg(long, default_value_t)]
@@ -500,6 +511,7 @@ impl SharedRuntimeArgs {
511
},
512
model: self.model,
513
revision: self.revision,
514
+ hf_overrides: self.hf_overrides,
515
generation_config: self.generation_config,
516
served_model_name: self.served_model_name,
517
listener_mode: HttpListenerMode::InheritedFd { fd: listen_fd },
@@ -556,6 +568,7 @@ impl SharedRuntimeArgs {
568
coordinator_mode: CoordinatorMode::MaybeInProc,
569
model: self.model,
570
revision: self.revision,
571
+ hf_overrides: self.hf_overrides,
572
generation_config: self.generation_config,
573
served_model_name: self.served_model_name,
574
listener_mode,
@@ -761,6 +774,9 @@ impl ServeArgs {
774
let reasoning_parser =
775
effective_engine_reasoning_parser(&self.runtime.reasoning_parser, &self.runtime.model);
776
let profiler_config = self.runtime.profiler_config_json();
777
+ let hf_overrides = (!self.runtime.hf_overrides.is_empty()).then(|| {
778
+ serde_json::to_string(&self.runtime.hf_overrides).expect("JSON object serializes")
779
+ });
780
781
self.managed_engine.clone().into_config(
782
self.runtime.model.clone(),
@@ -773,6 +789,7 @@ impl ServeArgs {
789
self.runtime.shutdown_timeout,
790
handshake_port,
791
self.runtime.limit_mm_per_prompt_json(),
792
+ hf_overrides,
793
)
794
}
795
}
rust/src/cmd/src/cli/tests.rs
+85
@@ -177,6 +177,9 @@ fn serve_args_forward_python_flags_with_separator() {
177
runtime: SharedRuntimeArgs {
178
model: "Qwen/Qwen3-0.6B",
179
revision: None,
180
+ hf_overrides: HfOverrides(
181
+ {},
182
+ ),
183
generation_config: Auto,
184
engine_ready_timeout_secs: 600,
185
tool_call_parser: Auto,
@@ -1013,6 +1016,9 @@ fn frontend_args_accept_json() {
1016
runtime: SharedRuntimeArgs {
1017
model: "Qwen/Qwen3-0.6B",
1018
revision: None,
1019
+ hf_overrides: HfOverrides(
1020
+ {},
1021
+ ),
1022
generation_config: Auto,
1023
engine_ready_timeout_secs: 600,
1024
tool_call_parser: None,
@@ -1120,6 +1126,73 @@ fn frontend_args_json_ignores_engine_owned_max_model_len() {
1126
assert_eq!(args.runtime.model, "Qwen/Qwen3-0.6B");
1127
}
1128
1129
+#[test]
1130
+fn hf_overrides_preserve_merge_patch_in_cli_and_python_bootstrap() {
1131
+ let patch = r#"{"text_config":{"rope_parameters":{"factor":4}},"sliding_window":null}"#;
1132
+ let cli = Cli::try_parse_from([
1133
+ "vllm-rs",
1134
+ "serve",
1135
+ "Qwen/Qwen3-0.6B",
1136
+ "--hf-overrides",
1137
+ patch,
1138
+ ])
1139
+ .unwrap();
1140
+ let Command::Serve(args) = cli.command else {
1141
+ panic!("expected serve args")
1142
+ };
1143
+ let expected: serde_json::Value = serde_json::from_str(patch).unwrap();
1144
+ assert_eq!(
1145
+ serde_json::to_value(&args.runtime.hf_overrides).unwrap(),
1146
+ expected
1147
+ );
1148
+ let engine = args.to_managed_engine_config(1234);
1149
+ let index = engine.python_args.iter().position(|arg| arg == "--hf-overrides").unwrap();
1150
+ assert_eq!(
1151
+ serde_json::from_str::<serde_json::Value>(&engine.python_args[index + 1]).unwrap(),
1152
+ expected
1153
+ );
1154
+ assert!(!engine.python_args.iter().any(|arg| arg == "--hf-config-path"));
1155
+ let config = args.to_frontend_config("tcp://127.0.0.1:1234".to_string());
1156
+ assert_eq!(serde_json::to_value(config.hf_overrides).unwrap(), expected);
1157
+
1158
+ let payload = serde_json::json!({"model_tag":"Qwen/Qwen3-0.6B", "hf_overrides":expected});
1159
+ let cli = Cli::try_parse_from([
1160
+ "vllm-rs",
1161
+ "frontend",
1162
+ "--listen-fd",
1163
+ "3",
1164
+ "--input-address",
1165
+ "ipc:///tmp/input.sock",
1166
+ "--output-address",
1167
+ "ipc:///tmp/output.sock",
1168
+ "--args-json",
1169
+ &payload.to_string(),
1170
+ ])
1171
+ .unwrap();
1172
+ let Command::Frontend(args) = cli.command else {
1173
+ panic!("expected frontend args")
1174
+ };
1175
+ assert_eq!(
1176
+ serde_json::to_value(args.into_config().hf_overrides).unwrap(),
1177
+ expected
1178
+ );
1179
+}
1180
+
1181
+#[test]
1182
+fn hf_overrides_reject_non_object_inputs() {
1183
+ for patch in ["null", "[]", "1", r#""callable""#] {
1184
+ let error = Cli::try_parse_from([
1185
+ "vllm-rs",
1186
+ "serve",
1187
+ "Qwen/Qwen3-0.6B",
1188
+ "--hf-overrides",
1189
+ patch,
1190
+ ])
1191
+ .unwrap_err();
1192
+ assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation);
1193
+ }
1194
+}
1195
+
1196
#[test]
1197
fn frontend_args_json_accepts_supported_non_default_fields() {
1198
let cli = Cli::try_parse_from([
@@ -1613,6 +1686,9 @@ fn serve_args_accept_handshake_aliases() {
1686
runtime: SharedRuntimeArgs {
1687
model: "Qwen/Qwen3-0.6B",
1688
revision: None,
1689
+ hf_overrides: HfOverrides(
1690
+ {},
1691
+ ),
1692
generation_config: Auto,
1693
engine_ready_timeout_secs: 600,
1694
tool_call_parser: Auto,
@@ -1763,6 +1839,9 @@ fn serve_frontend_config_uses_dp_address_as_advertised_host() {
1839
coordinator_mode: MaybeInProc,
1840
model: "Qwen/Qwen3-0.6B",
1841
revision: None,
1842
+ hf_overrides: HfOverrides(
1843
+ {},
1844
+ ),
1845
generation_config: Auto,
1846
served_model_name: [],
1847
listener_mode: BindTcp {
@@ -1852,6 +1931,9 @@ fn serve_frontend_config_keeps_tcp_transport_for_non_local_only_topology() {
1931
coordinator_mode: MaybeInProc,
1932
model: "Qwen/Qwen3-0.6B",
1933
revision: None,
1934
+ hf_overrides: HfOverrides(
1935
+ {},
1936
+ ),
1937
generation_config: Auto,
1938
served_model_name: [],
1939
listener_mode: BindTcp {
@@ -1963,6 +2045,9 @@ fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present
2045
},
2046
model: "Qwen/Qwen3-0.6B",
2047
revision: None,
2048
+ hf_overrides: HfOverrides(
2049
+ {},
2050
+ ),
2051
generation_config: Auto,
2052
served_model_name: [],
2053
listener_mode: InheritedFd {
rust/src/cmd/src/cli/unsupported.rs
-5
@@ -235,11 +235,6 @@ pub struct EngineUnsupportedArgs {
235
#[arg(long, default_missing_value = "true", num_args = 0..=1)]
236
pub hf_token: Option<Unsupported>,
237
238
- /// If a dictionary, contains arguments to be forwarded to the Hugging Face
239
- /// config. If a callable, it is called to update the HuggingFace config.
240
- #[arg(long)]
241
- pub hf_overrides: Option<Unsupported>,
242
-
238
/// Overrides or sets generation config. e.g. `{"temperature": 0.5}`. If
239
/// used with `--generation-config auto`, the override parameters will be
240
/// merged with the default config from the model. If used with
rust/src/managed-engine/src/cli.rs
+5
@@ -93,6 +93,7 @@ impl ManagedEngineArgs {
93
shutdown_timeout: u64,
94
handshake_port: u16,
95
limit_mm_per_prompt: Option<String>,
96
+ hf_overrides: Option<String>,
97
) -> ManagedEngineConfig {
98
let mut python_args = self.python_args;
99
// Manually forward some args to the Python engine.
@@ -136,6 +137,10 @@ impl ManagedEngineArgs {
137
python_args.push("--limit-mm-per-prompt".to_string());
138
python_args.push(limit_mm_per_prompt);
139
}
140
+ if let Some(hf_overrides) = hf_overrides {
141
+ python_args.push("--hf-overrides".to_string());
142
+ python_args.push(hf_overrides);
143
+ }
144
145
ManagedEngineConfig {
146
python: self.python,
rust/src/server/examples/external_engine_openai_qwen.rs
+1
@@ -61,6 +61,7 @@ async fn main() -> Result<()> {
61
coordinator_mode: CoordinatorMode::MaybeInProc,
62
model: args.model,
63
revision: None,
64
+ hf_overrides: Default::default(),
65
generation_config: Default::default(),
66
served_model_name: vec![],
67
listener_mode: HttpListenerMode::BindTcp {
rust/src/server/src/config.rs
+3
@@ -16,6 +16,7 @@ use vllm_chat::{
16
ChatTemplateContentFormatOption, GenerationConfigMode, ParserSelection, RendererSelection,
17
};
18
use vllm_engine_core_client::{CoordinatorMode as EngineCoreCoordinatorMode, TransportMode};
19
+use vllm_text::backend::hf::HfOverrides;
20
21
/// Default keep-alive idle timeout (seconds); also the head-read bound
22
/// when keep-alive is disabled (`0`).
@@ -214,6 +215,8 @@ pub struct Config {
215
pub model: String,
216
/// Model revision on the Hugging Face Hub (branch, tag, or commit SHA).
217
pub revision: Option<String>,
218
+ /// JSON Merge Patch applied to the model config before backend initialization.
219
+ pub hf_overrides: HfOverrides,
220
/// Which generation-config sampling defaults to inherit.
221
pub generation_config: GenerationConfigMode,
222
/// Model name(s) exposed to clients via the OpenAI API. When non-empty,
rust/src/server/src/lib.rs
+1
@@ -99,6 +99,7 @@ async fn build_state(config: &Config) -> Result<Arc<AppState>> {
99
&config.model,
100
LoadModelBackendsOptions {
101
revision: config.revision.clone(),
102
+ hf_overrides: config.hf_overrides.clone(),
103
generation_config: config.generation_config,
104
renderer: config.renderer,
105
language_model_only: config.language_model_only,
rust/src/server/src/render.rs
+4
@@ -13,6 +13,7 @@ use vllm_chat::{
13
ParserSelection, RendererSelection, load_model_backends,
14
};
15
use vllm_text::TextRequestProcessor;
16
+use vllm_text::backend::hf::HfOverrides;
17
18
use crate::{
19
HttpListenerMode, TlsConfig,
@@ -25,6 +26,7 @@ use crate::{
26
pub struct RenderConfig {
27
pub model: String,
28
pub revision: Option<String>,
29
+ pub hf_overrides: HfOverrides,
30
pub served_model_name: Vec<String>,
31
pub host: String,
32
pub port: u16,
@@ -69,6 +71,7 @@ async fn build_state(config: &RenderConfig) -> Result<Arc<RenderState>> {
71
&config.model,
72
LoadModelBackendsOptions {
73
revision: config.revision.clone(),
74
+ hf_overrides: config.hf_overrides.clone(),
75
generation_config: Default::default(),
76
renderer: config.renderer,
77
language_model_only: true,
@@ -153,6 +156,7 @@ mod tests {
156
RenderConfig {
157
model: "test-model".to_string(),
158
revision: None,
159
+ hf_overrides: Default::default(),
160
served_model_name: Vec::new(),
161
host: "127.0.0.1".to_string(),
162
port: 8000,
rust/src/text/Cargo.toml
+1
-1
@@ -16,6 +16,7 @@ reqwest.workspace = true
16
serde.workspace = true
17
serde_json.workspace = true
18
serde_with.workspace = true
19
+tempfile.workspace = true
20
thiserror.workspace = true
21
thiserror-ext.workspace = true
22
tracing.workspace = true
@@ -28,7 +29,6 @@ vllm-tokenizer.workspace = true
29
expect-test.workspace = true
30
futures.workspace = true
31
serial_test.workspace = true
31
-tempfile.workspace = true
32
tokio.workspace = true
33
vllm-llm = { workspace = true, features = ["test-util"] }
34
vllm-tokenizer = { workspace = true, features = ["test-utils"] }
rust/src/text/src/backend/hf/mod.rs
+2
@@ -3,6 +3,7 @@
3
4
mod config;
5
mod model_files;
6
+mod overrides;
7
8
use std::collections::BTreeSet;
9
use std::sync::Arc;
@@ -18,6 +19,7 @@ pub use self::config::{
19
load_tokenizer_config,
20
};
21
pub use self::model_files::{ResolvedModelFiles, TokenizerSource};
22
+pub use self::overrides::HfOverrides;
23
use crate::backend::{GenerationConfigMode, SamplingHints, TextBackend};
24
use crate::error::Result;
25
rust/src/text/src/backend/hf/model_files.rs
+72
@@ -2,11 +2,13 @@
2
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
use std::path::{Path, PathBuf};
5
+use std::sync::Arc;
6
7
use hf_hub::api::tokio::{Api, ApiBuilder, ApiRepo};
8
use hf_hub::{Cache, Repo, RepoType};
9
use thiserror_ext::AsReport as _;
10
11
+use super::HfOverrides;
12
use super::config::{HfTokenizerConfig, load_tokenizer_config};
13
use crate::error::{Error, Result};
14
@@ -50,9 +52,27 @@ pub struct ResolvedModelFiles {
52
pub processor_config_path: Option<PathBuf>,
53
pub chat_template_path: Option<PathBuf>,
54
pub config_path: Option<PathBuf>,
55
+ /// Keep a patched config alive across clones and backend ownership.
56
+ config_temp_path: Option<Arc<tempfile::TempPath>>,
57
}
58
59
impl ResolvedModelFiles {
60
+ /// Materialize a merge-patched config owned by these resolved model files.
61
+ pub fn apply_overrides(&mut self, overrides: &HfOverrides) -> Result<()> {
62
+ if overrides.is_empty() {
63
+ return Ok(());
64
+ }
65
+ let path = materialize_config(self.config_path.as_deref(), overrides).map_err(|error| {
66
+ Error::Tokenizer(format!(
67
+ "failed to materialize HF overrides: {}",
68
+ error.as_report()
69
+ ))
70
+ })?;
71
+ self.config_path = Some(path.to_path_buf());
72
+ self.config_temp_path = Some(Arc::new(path));
73
+ Ok(())
74
+ }
75
+
76
/// Resolve tokenizer/config files from a local model directory first when
77
/// `model_id` points to one, otherwise consult the local HF cache and
78
/// finally the Hub.
@@ -84,6 +104,24 @@ impl ResolvedModelFiles {
104
}
105
}
106
107
+fn materialize_config(
108
+ path: Option<&Path>,
109
+ overrides: &HfOverrides,
110
+) -> anyhow::Result<tempfile::TempPath> {
111
+ let mut config: serde_json::Value = match path {
112
+ Some(path) => serde_json::from_slice(&std::fs::read(path)?)?,
113
+ None => serde_json::json!({}),
114
+ };
115
+ anyhow::ensure!(config.is_object(), "HF model config must be a JSON object");
116
+ overrides.apply(&mut config);
117
+ let file = tempfile::Builder::new()
118
+ .prefix("vllm-rs-hf-config-")
119
+ .suffix(".json")
120
+ .tempfile()?;
121
+ serde_json::to_writer(file.as_file(), &config)?;
122
+ Ok(file.into_temp_path())
123
+}
124
+
125
fn resolve_local_model_files(model_dir: &Path) -> Result<ResolvedModelFiles> {
126
let tokenizer_config_path = local_file_if_exists(model_dir, "tokenizer_config.json");
127
let tokenizer_config = load_tokenizer_config(tokenizer_config_path.as_deref())?;
@@ -101,6 +139,7 @@ fn resolve_local_model_files(model_dir: &Path) -> Result<ResolvedModelFiles> {
139
processor_config_path: local_file_if_exists(model_dir, "processor_config.json"),
140
chat_template_path: discover_chat_template_in_dir(model_dir),
141
config_path: local_file_if_exists(model_dir, "config.json"),
142
+ config_temp_path: None,
143
})
144
}
145
@@ -164,6 +203,7 @@ async fn resolve_remote_model_files(
203
processor_config_path,
204
chat_template_path,
205
config_path,
206
+ config_temp_path: None,
207
})
208
}
209
@@ -204,6 +244,7 @@ fn resolve_cached_model_files(cache: &Cache, repo: &Repo) -> Result<Option<Resol
244
processor_config_path,
245
chat_template_path,
246
config_path,
247
+ config_temp_path: None,
248
}))
249
}
250
@@ -434,6 +475,37 @@ mod tests {
475
476
use super::{ResolvedModelFiles, TokenizerSource};
477
478
+ #[tokio::test]
479
+ async fn overrides_keep_configs_alive_across_clones_and_isolate_instances() {
480
+ let source = tempdir().unwrap();
481
+ let original = r#"{"vocab_size":10}"#;
482
+ fs::write(source.path().join("config.json"), original).unwrap();
483
+ fs::write(source.path().join("tokenizer.json"), "{}").unwrap();
484
+ let mut first =
485
+ ResolvedModelFiles::new(source.path().to_str().unwrap(), None).await.unwrap();
486
+ let patch = serde_json::from_value(serde_json::json!({"vocab_size":20})).unwrap();
487
+ first.apply_overrides(&patch).unwrap();
488
+ let first_path = first.config_path.clone().unwrap();
489
+
490
+ let mut second = first.clone();
491
+ second.apply_overrides(&patch).unwrap();
492
+ let second_path = second.config_path.clone().unwrap();
493
+ assert_ne!(first_path, second_path);
494
+
495
+ let retained = first.clone();
496
+ drop(first);
497
+ assert!(first_path.exists());
498
+ drop(second);
499
+ assert!(!second_path.exists());
500
+ assert!(first_path.exists());
501
+ drop(retained);
502
+ assert!(!first_path.exists());
503
+ assert_eq!(
504
+ fs::read_to_string(source.path().join("config.json")).unwrap(),
505
+ original
506
+ );
507
+ }
508
+
509
#[tokio::test]
510
async fn resolved_model_files_prefers_absolute_local_model_dir() {
511
let dir = tempdir().expect("create temp dir");
rust/src/text/src/backend/hf/overrides.rs
new
+93
@@ -0,0 +1,93 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+use serde::{Deserialize, Serialize};
5
+use serde_json::{Map, Value};
6
+
7
+/// An object-shaped JSON Merge Patch (RFC 7396) for the model's HF config.
8
+/// Objects merge recursively, arrays/scalars replace, and null removes a key.
9
+#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
10
+#[serde(transparent)]
11
+pub struct HfOverrides(pub Map<String, Value>);
12
+
13
+impl HfOverrides {
14
+ pub fn is_empty(&self) -> bool {
15
+ self.0.is_empty()
16
+ }
17
+
18
+ /// Apply the patch to an in-memory config, preserving the source file.
19
+ pub fn apply(&self, config: &mut Value) {
20
+ merge_object(config, &self.0);
21
+ }
22
+}
23
+
24
+/// Apply RFC 7396 object-member updates at every nesting level.
25
+fn merge_object(target: &mut Value, patch: &Map<String, Value>) {
26
+ if !target.is_object() {
27
+ *target = Value::Object(Map::new());
28
+ }
29
+ let target = target.as_object_mut().unwrap();
30
+ for (key, value) in patch {
31
+ match value {
32
+ Value::Null => {
33
+ target.remove(key);
34
+ }
35
+ Value::Object(object) => {
36
+ merge_object(target.entry(key.clone()).or_insert(Value::Null), object)
37
+ }
38
+ _ => {
39
+ target.insert(key.clone(), value.clone());
40
+ }
41
+ }
42
+ }
43
+}
44
+
45
+#[cfg(test)]
46
+mod tests {
47
+ use serde_json::json;
48
+
49
+ use super::HfOverrides;
50
+
51
+ #[test]
52
+ fn merge_patch_preserves_siblings_replaces_arrays_and_deletes_null_members() {
53
+ let mut config = json!({
54
+ "rope_parameters": {"rope_type": "yarn", "factor": 2},
55
+ "text_config": {"rope_parameters": {"rope_type": "yarn", "factor": 2}},
56
+ "architectures": ["Old", "Other"],
57
+ "sliding_window": 4096
58
+ });
59
+ let patch: HfOverrides = serde_json::from_value(json!({
60
+ "rope_parameters": {"factor": 4},
61
+ "text_config": {"rope_parameters": {"factor": 4}},
62
+ "architectures": ["New"],
63
+ "sliding_window": null,
64
+ "missing": null
65
+ }))
66
+ .unwrap();
67
+ patch.apply(&mut config);
68
+ assert_eq!(
69
+ config,
70
+ json!({
71
+ "rope_parameters": {"rope_type": "yarn", "factor": 4},
72
+ "text_config": {"rope_parameters": {"rope_type": "yarn", "factor": 4}},
73
+ "architectures": ["New"]
74
+ })
75
+ );
76
+ }
77
+
78
+ #[test]
79
+ fn merge_patch_creates_objects_and_preserves_nulls_inside_replacement_arrays() {
80
+ let mut config = json!({"a": 1, "b": [1, 2]});
81
+ let patch: HfOverrides = serde_json::from_value(json!({
82
+ "a": {"nested": {"removed": null}},
83
+ "b": {},
84
+ "c": [null, {"literal": null}]
85
+ }))
86
+ .unwrap();
87
+ patch.apply(&mut config);
88
+ assert_eq!(
89
+ config,
90
+ json!({"a": {"nested": {}}, "b": {}, "c": [null, {"literal": null}]})
91
+ );
92
+ }
93
+}