diff --git a/python/sglang/srt/arg_groups/arg_utils.py b/python/sglang/srt/arg_groups/arg_utils.py index c7e20720f..d05c39d1a 100644 --- a/python/sglang/srt/arg_groups/arg_utils.py +++ b/python/sglang/srt/arg_groups/arg_utils.py @@ -85,7 +85,12 @@ class Arg: @functools.lru_cache(maxsize=None) def model_overridable_fields(cls) -> frozenset: """Names of ``cls`` dataclass fields whose ``Arg`` metadata declares - ``model_overridable=True`` — the whitelist for model-override resolution.""" + ``model_overridable=True`` — the whitelist for model-override resolution. + + Non-dataclass types (e.g. mock config objects in tests) have no Arg + metadata and yield an empty whitelist.""" + if not dataclasses.is_dataclass(cls): + return frozenset() hints = get_type_hints(cls, include_extras=True) names = set() for field in dataclasses.fields(cls): diff --git a/python/sglang/srt/arg_groups/overrides.py b/python/sglang/srt/arg_groups/overrides.py index fb0ddccf1..e14beb7ff 100644 --- a/python/sglang/srt/arg_groups/overrides.py +++ b/python/sglang/srt/arg_groups/overrides.py @@ -172,7 +172,23 @@ def apply_declarations_to_server_args( Retired per field once that field's readers have all flipped to the flags tier (at which point the server_args field returns to pristine). + + Validates against the same whitelist as the publish gate BEFORE any write: + a registry typo or a not-yet-resolvable field must fail fast here, not + mutate ``server_args`` and only be rejected at publish time. """ + # Non-dataclass fixtures carry no Arg metadata (mirrors the + # model_overridable_fields escape); only real ServerArgs is validated. + if dataclasses.is_dataclass(type(server_args)): + whitelist = model_overridable_fields(type(server_args)) + for source, decl in list(declarations) + list(terminal): + unknown = set(decl) - whitelist + if unknown: + raise ValueError( + f"{source}: {sorted(unknown)} not model-overridable; the " + "transition dual-apply refuses fields the publish gate " + "would reject." + ) for _source, decl in list(declarations) + list(terminal): for field, value in decl.items(): setattr(server_args, field, value) diff --git a/python/sglang/srt/runtime_context.py b/python/sglang/srt/runtime_context.py index 4612635dd..2765f4585 100644 --- a/python/sglang/srt/runtime_context.py +++ b/python/sglang/srt/runtime_context.py @@ -359,9 +359,40 @@ class RuntimeContext: Overwrite-allowed: a re-publish replaces the slot (test kits re-publish per test; production ordering discipline lives at the call-sites, e.g. the draft-worker guard in ``ModelRunner.__init__``). + + Publishing also resolves the stashed model-override declarations + into the flags tier (skipped for objects without the stash — dummy / + "none" fixture ServerArgs and test-kit mocks never compute it). + Resolution runs first: if it fails, the previous publish stays intact. """ + self._resolve_flags(server_args) self._server_args = server_args + def _resolve_flags(self, server_args: ServerArgs) -> None: + declarations = getattr(server_args, "_resolved_overrides", None) + if declarations is None: + return + from sglang.srt.arg_groups.overrides import ( + apply_model_overrides, + assert_flag_parity, + ) + + # Resolve into a fresh container and only install it once everything + # passed: a failed resolution (gate validation or the parity assert) + # must not leave the process-global flags half-written for callers + # that catch the error or republish (same install-fresh semantics as + # reset_context()). + flags = Flags() + apply_model_overrides(flags, server_args, declarations) + # Transition-period drift guard: dual-apply keeps the declared fields + # on server_args byte-identical to the resolved flag leaves. + assert_flag_parity( + flags, + server_args, + {field for _source, decl in declarations for field in decl}, + ) + self.flags = flags + _PARALLEL = ParallelContext() _CONTEXT = RuntimeContext(parallel=_PARALLEL) diff --git a/python/sglang/srt/server_args.py b/python/sglang/srt/server_args.py index 40312dbe9..75e01f67f 100644 --- a/python/sglang/srt/server_args.py +++ b/python/sglang/srt/server_args.py @@ -2569,6 +2569,12 @@ class ServerArgs: Orchestrates the handling of various server arguments, ensuring proper configuration and validation. """ + # Declaration stash for the override/post-process passes. Set before any + # short-circuit (none/dummy model paths) so run_post_process_pass and + # direct handler invocations can rely on it even when + # _handle_model_specific_adjustments never runs. + self._resolved_overrides = [] + self._maybe_download_model_for_runai() # Normalize load balancing defaults early (before dummy-model short-circuit). @@ -3709,6 +3715,7 @@ class ServerArgs: self.uses_mamba_radix_cache = False if parse_connector_type(self.model_path) == ConnectorType.INSTANCE: + self._resolved_overrides = [] return hf_config = self.get_model_config().hf_config @@ -3718,6 +3725,22 @@ class ServerArgs: if _hybrid_spec is not None and _hybrid_spec.uses_mamba_radix_cache: self._handle_mamba_radix_cache(model_arch=model_arch) + # Collect the declarative model overrides (registry) on the + # pristine config and stash them for publish-time flags resolution. + # Transition dual-apply: the same declarations are applied to + # server_args right here, byte-identical to the imperative arch + # branches this dispatch gradually replaces (dual-apply is retired + # per field once that field's readers migrate to the flags tier). + from sglang.srt.arg_groups.overrides import ( + apply_declarations_to_server_args, + collect_model_override_declarations, + ) + + self._resolved_overrides = collect_model_override_declarations( + model_arch, self, hf_config + ) + apply_declarations_to_server_args(self, self._resolved_overrides) + if model_arch in [ "MistralLarge3ForCausalLM", "PixtralForConditionalGeneration", diff --git a/test/registered/unit/test_model_overrides.py b/test/registered/unit/test_model_overrides.py index a462bf1e9..1600d5598 100644 --- a/test/registered/unit/test_model_overrides.py +++ b/test/registered/unit/test_model_overrides.py @@ -21,7 +21,12 @@ from sglang.srt.arg_groups.overrides import ( collect_model_override_declarations, register_model_override, ) -from sglang.srt.runtime_context import _StaticFlags +from sglang.srt.runtime_context import ( + _StaticFlags, + get_context, + get_server_args, + reset_context, +) from sglang.test.test_utils import CustomTestCase @@ -50,6 +55,9 @@ class TestModelOverridableWhitelist(CustomTestCase): self.assertEqual(model_overridable_fields(ServerArgs), frozenset()) + def test_non_dataclass_yields_empty_whitelist(self): + self.assertEqual(model_overridable_fields(SimpleNamespace), frozenset()) + class _IsolatedRegistry(CustomTestCase): """Run each test against empty registries (they are process-global).""" @@ -199,6 +207,59 @@ class TestApplyModelOverridesGate(CustomTestCase): self.assertEqual(flags.resolved_by_model, "unset") # flat leaf untouched +class _IsolatedPublish(CustomTestCase): + """Publishing writes the process-global context; save/restore around it.""" + + def setUp(self): + super().setUp() + self._saved_server_args = get_context()._server_args + + def tearDown(self): + reset_context() + if self._saved_server_args is not None: + get_context()._server_args = self._saved_server_args + super().tearDown() + + +@dataclasses.dataclass +class _NoOverridableArgs: + x: int = 1 + + +class TestPublishResolvesFlags(_IsolatedPublish): + """Publish wiring: stash-carrying publishes resolve into flags via the + gate; publishes without the stash skip resolution.""" + + def test_dummy_fixture_has_empty_stash_and_publishes_cleanly(self): + from sglang.srt.server_args import ( + ServerArgs, + set_global_server_args_for_scheduler, + ) + + sa = ServerArgs(model_path="dummy") # __post_init__ early-returns + # The stash is created before the dummy short-circuit and stays empty. + self.assertEqual(sa._resolved_overrides, []) + set_global_server_args_for_scheduler(sa) + self.assertIs(get_server_args(), sa) + + def test_empty_stash_publish_runs_gate_as_noop(self): + sa = _NoOverridableArgs() + sa._resolved_overrides = [] + get_context().set_server_args(sa) + self.assertIs(get_server_args(), sa) + + def test_non_whitelisted_declaration_fails_at_publish(self): + from sglang.srt.runtime_context import get_flags + + flags_before = get_flags() + sa = _NoOverridableArgs() + sa._resolved_overrides = [("rogue", {"x": 2})] + with self.assertRaises(ValueError): + get_context().set_server_args(sa) + # a failed publish must leave BOTH the slot and the flags untouched + self.assertIs(get_flags(), flags_before) + + class TestDualApplyParity(CustomTestCase): def test_dual_apply_replays_and_parity_holds(self): flags, args = _FakeFlags(), _FakeArgs()