[UX] Reject incompatible nested runtime overrides (#49247)
Signed-off-by: Taneem Ibrahim <taneem.ibrahim@gmail.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Taneem Ibrahim committed
Jul 24, 2026 at 10:16 UTC
7b40fb96450e1deef40ebc383ff549bea35b9b8f
4 files changed
+56
-23
tests/test_config.py
+18
-3
@@ -406,26 +406,41 @@ class _TestNestedConfig:
406
a: _TestConfigFields = field(default_factory=lambda: _TestConfigFields(a=0))
407
408
409
+@dataclass
410
+class _TestDerivedConfigFields(_TestConfigFields):
411
+ pass
412
+
413
+
414
def test_update_config():
415
# Simple update
416
config1 = _TestConfigFields(a=0)
417
new_config1 = update_config(config1, {"a": 42})
418
assert new_config1.a == 42
419
# Nonexistent field
415
- with pytest.raises(AssertionError):
420
+ with pytest.raises(ValueError, match=r"_TestConfigFields\.nonexistent"):
421
new_config1 = update_config(config1, {"nonexistent": 1})
422
# Nested update with dataclass
423
config2 = _TestNestedConfig()
424
new_inner_config = _TestConfigFields(a=1, c="new_value")
425
new_config2 = update_config(config2, {"a": new_inner_config})
426
assert new_config2.a == new_inner_config
427
+ # Declared field type, not the live value's subtype, defines valid overrides
428
+ config_with_derived = _TestNestedConfig(a=_TestDerivedConfigFields(a=0))
429
+ new_config2 = update_config(config_with_derived, {"a": new_inner_config})
430
+ assert new_config2.a is new_inner_config
431
+ # Nested update with unrelated dataclass
432
+ with pytest.raises(ValueError, match=r"_TestNestedConfig\.a"):
433
+ update_config(config2, {"a": _TestNestedConfig()})
434
# Nested update with dict
435
config3 = _TestNestedConfig()
436
new_config3 = update_config(config3, {"a": {"c": "new_value"}})
437
assert new_config3.a.c == "new_value"
438
# Nested update with invalid type
427
- with pytest.raises(AssertionError):
428
- new_config3 = update_config(config3, {"a": "new_value"})
439
+ with pytest.raises(ValueError, match=r"_TestNestedConfig\.a"):
440
+ update_config(config3, {"a": "new_value"})
441
+ # Invalid nested field preserves its full path
442
+ with pytest.raises(ValueError, match=r"_TestNestedConfig\.a\.nonexistent"):
443
+ update_config(config3, {"a": {"nonexistent": 1}})
444
445
446
@pytest.mark.parametrize(
tests/v1/worker/test_gpu_model_runner.py
+1
-1
@@ -831,7 +831,7 @@ def test_update_config(model_runner):
831
model_runner.update_config({"load_config": {"load_format": "dummy"}})
832
assert model_runner.load_config.load_format == "dummy"
833
# Raise error on non-existing config
834
- with pytest.raises(AssertionError):
834
+ with pytest.raises(ValueError, match="do_not_exist_config"):
835
model_runner.update_config({"do_not_exist_config": "dummy"})
836
837
vllm/config/utils.py
+31
-15
@@ -13,7 +13,7 @@ import textwrap
13
from collections.abc import Callable, Mapping, Sequence, Set
14
from dataclasses import MISSING, field, fields, is_dataclass
15
from itertools import pairwise
16
-from typing import TYPE_CHECKING, Any, Protocol, TypeVar, cast, overload
16
+from typing import TYPE_CHECKING, Any, Protocol, TypeVar, cast, get_type_hints, overload
17
18
import torch
19
from pydantic import ConfigDict
@@ -227,22 +227,38 @@ class SupportsMetricsInfo(Protocol):
227
def metrics_info(self) -> dict[str, str]: ...
228
229
230
-def update_config(config: ConfigT, overrides: dict[str, Any]) -> ConfigT:
231
- processed_overrides = {}
230
+def update_config(config: ConfigT, overrides: Mapping[str, Any]) -> ConfigT:
231
+ return _update_config(config, overrides, type(config).__name__)
232
+
233
+
234
+def _update_config(
235
+ config: ConfigT, overrides: Mapping[str, Any], config_path: str
236
+) -> ConfigT:
237
+ processed_overrides: dict[str, Any] = {}
238
+ field_types = get_type_hints(type(config))
239
for field_name, value in overrides.items():
233
- assert hasattr(config, field_name), (
234
- f"{type(config)} has no field `{field_name}`"
235
- )
240
+ field_path = f"{config_path}.{field_name}"
241
+ if not hasattr(config, field_name):
242
+ raise ValueError(f"{field_path} is not a valid config field")
243
+
244
current_value = getattr(config, field_name)
237
- if is_dataclass(current_value) and not is_dataclass(value):
238
- assert isinstance(value, dict), (
239
- f"Overrides to {type(config)}.{field_name} must be a dict"
240
- f" or {type(current_value)}, but got {type(value)}"
241
- )
242
- value = update_config(
243
- current_value, # type: ignore[type-var]
244
- value,
245
- )
245
+ if is_dataclass(current_value):
246
+ expected_type = field_types[field_name]
247
+ if isinstance(value, Mapping):
248
+ value = _update_config(
249
+ current_value, # type: ignore[type-var]
250
+ value,
251
+ field_path,
252
+ )
253
+ elif not isinstance(value, expected_type):
254
+ expected_type_name = getattr(
255
+ expected_type, "__name__", str(expected_type)
256
+ )
257
+ raise ValueError(
258
+ f"Override for {field_path} must be a mapping or "
259
+ f"{expected_type_name}, got {type(value).__name__}"
260
+ )
261
+
262
processed_overrides[field_name] = value
263
return replace(config, **processed_overrides)
264
vllm/v1/worker/gpu_model_runner.py
+6
-4
@@ -5273,10 +5273,12 @@ class GPUModelRunner(
5273
def update_config(self, overrides: dict[str, Any]) -> None:
5274
allowed_config_names = {"load_config", "model_config"}
5275
for config_name, config_overrides in overrides.items():
5276
- assert config_name in allowed_config_names, (
5277
- f"Config `{config_name}` not supported. "
5278
- f"Allowed configs: {allowed_config_names}"
5279
- )
5276
+ if config_name not in allowed_config_names:
5277
+ allowed = ", ".join(sorted(allowed_config_names))
5278
+ raise ValueError(
5279
+ f"Config override '{config_name}' is not supported. "
5280
+ f"Supported configs: {allowed}"
5281
+ )
5282
config = getattr(self, config_name)
5283
new_config = update_config(config, config_overrides)
5284
setattr(self, config_name, new_config)