[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)