From 400275d208363958b9dd8a7766db8f2c782fac4e Mon Sep 17 00:00:00 2001 From: Dmitry Meyer Date: Thu, 3 Sep 2026 12:39:12 +0000 Subject: [PATCH] [CLI] Exclude unset service `groups` from requests `ServiceConfigurationParams.groups` was added in 0.21.3 (#4177) along with a model serializer that popped `groups` from every dump and re-emitted it as the legacy `replicas: [{count: ...}]` shape. Living on the model, it applied in both directions, so a 0.21.3 client never put `groups` on the wire. PR #4248 removed that serializer in favor of a server-side patch gated on `client_version < 0.21.3`. That covers server -> old client, but nothing replaced client -> old server, so 0.21.4 clients always send `configuration.groups` and servers before 0.21.3 reject it as an extra field. Exclude service `groups` when unset, as already done for `TaskConfiguration.groups`. This covers all three client paths that send a `run_spec`: `get_plan`, `apply`, and `gpus/list`. Fixes: https://github.com/dstackai/dstack/issues/4255 Co-Authored-By: Claude Opus 5 (1M context) --- .../_internal/core/compatibility/runs.py | 9 ++++++- src/tests/_internal/core/models/test_runs.py | 26 ++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/dstack/_internal/core/compatibility/runs.py b/src/dstack/_internal/core/compatibility/runs.py index d33462b67..545c729cb 100644 --- a/src/dstack/_internal/core/compatibility/runs.py +++ b/src/dstack/_internal/core/compatibility/runs.py @@ -5,7 +5,10 @@ IncludeExcludeDictType, IncludeExcludeSetType, ) -from dstack._internal.core.models.configurations import TaskConfiguration +from dstack._internal.core.models.configurations import ( + ServiceConfiguration, + TaskConfiguration, +) from dstack._internal.core.models.runs import ( DEFAULT_REPLICA_GROUP_NAME, ApplyRunPlanInput, @@ -89,6 +92,10 @@ def get_run_spec_excludes(run_spec: RunSpec) -> IncludeExcludeDictType: if run_spec.configuration.nodes is None: # Omit nodes when unset so old servers never see null (pre-hetero nodes was int=1). configuration_excludes["nodes"] = True + elif isinstance(run_spec.configuration, ServiceConfiguration): + if run_spec.configuration.groups is None: + # Servers before 0.21.3 have no service `groups` and reject it as an extra field. + configuration_excludes["groups"] = True if configuration_excludes: spec_excludes["configuration"] = configuration_excludes diff --git a/src/tests/_internal/core/models/test_runs.py b/src/tests/_internal/core/models/test_runs.py index b5c613236..3ec6f8024 100644 --- a/src/tests/_internal/core/models/test_runs.py +++ b/src/tests/_internal/core/models/test_runs.py @@ -3,7 +3,10 @@ from dstack._internal.core.compatibility.runs import get_run_spec_excludes from dstack._internal.core.models.common import validate_extra_ignore -from dstack._internal.core.models.configurations import TaskConfiguration +from dstack._internal.core.models.configurations import ( + ServiceConfiguration, + TaskConfiguration, +) from dstack._internal.core.models.profiles import ( CreationPolicy, Profile, @@ -42,6 +45,27 @@ def test_unset_task_nodes_are_excluded_for_compatibility(): assert configuration_excludes["nodes"] is True +def test_unset_service_groups_are_excluded_for_compatibility(): + configuration = ServiceConfiguration(commands=["true"], port=8000) + + configuration_excludes = get_run_spec_excludes(RunSpec(configuration=configuration)).get( + "configuration" + ) + + assert isinstance(configuration_excludes, dict) + assert configuration_excludes["groups"] is True + + +def test_set_service_groups_are_not_excluded(): + configuration = ServiceConfiguration(port=8000, groups=[{"replicas": 1, "commands": ["true"]}]) + + configuration_excludes = get_run_spec_excludes(RunSpec(configuration=configuration)).get( + "configuration" + ) + + assert not isinstance(configuration_excludes, dict) or "groups" not in configuration_excludes + + def test_job_termination_reason_to_status_works_with_all_enum_variants(): for job_termination_reason in JobTerminationReason: job_status = job_termination_reason.to_status()