diff --git a/src/dstack/_internal/core/backends/aws/compute.py b/src/dstack/_internal/core/backends/aws/compute.py index b28cb0e104..449110fa7c 100644 --- a/src/dstack/_internal/core/backends/aws/compute.py +++ b/src/dstack/_internal/core/backends/aws/compute.py @@ -715,7 +715,7 @@ def terminate_gateway( return try: - backend_data_parsed = AWSGatewayBackendData.parse_raw(backend_data) + backend_data_parsed = AWSGatewayBackendData.__response__.parse_raw(backend_data) except ValidationError: logger.exception( "Failed to terminate all gateway %s resources. backend_data parsing error.", @@ -1321,7 +1321,7 @@ def _parse_instance_backend_data(backend_data: Optional[str]) -> "AWSInstanceBac if backend_data is None: return AWSInstanceBackendData() try: - return AWSInstanceBackendData.parse_raw(backend_data) + return AWSInstanceBackendData.__response__.parse_raw(backend_data) except ValidationError: logger.exception("Failed to parse AWS instance backend_data; treating as empty") return AWSInstanceBackendData() diff --git a/src/dstack/_internal/core/backends/aws/configurator.py b/src/dstack/_internal/core/backends/aws/configurator.py index 8d6c8afe14..a7a1b92a3a 100644 --- a/src/dstack/_internal/core/backends/aws/configurator.py +++ b/src/dstack/_internal/core/backends/aws/configurator.py @@ -106,7 +106,7 @@ def get_backend(self, record: BackendRecord) -> AWSBackend: def _get_config(self, record: BackendRecord) -> AWSConfig: return AWSConfig.__response__( **json.loads(record.config), - creds=AWSCreds.parse_raw(record.auth).__root__, + creds=AWSCreds.__response__.parse_raw(record.auth).__root__, ) def _check_config_tags(self, config: AWSBackendConfigWithCreds): diff --git a/src/dstack/_internal/core/backends/aws/models.py b/src/dstack/_internal/core/backends/aws/models.py index 95459bebe6..11b9708d00 100644 --- a/src/dstack/_internal/core/backends/aws/models.py +++ b/src/dstack/_internal/core/backends/aws/models.py @@ -130,7 +130,7 @@ class AWSStoredConfig(AWSBackendConfig): class AWSConfig(AWSStoredConfig): - creds: AnyAWSCreds + creds: Annotated[AnyAWSCreds, Field(discriminator="type")] @property def allocate_public_ips(self) -> bool: diff --git a/src/dstack/_internal/core/backends/azure/configurator.py b/src/dstack/_internal/core/backends/azure/configurator.py index 31297e4ce3..33a8576981 100644 --- a/src/dstack/_internal/core/backends/azure/configurator.py +++ b/src/dstack/_internal/core/backends/azure/configurator.py @@ -155,7 +155,7 @@ def _get_config(self, record: BackendRecord) -> AzureConfig: return AzureConfig.__response__( **config_dict, regions=regions, - creds=AzureCreds.parse_raw(record.auth).__root__, + creds=AzureCreds.__response__.parse_raw(record.auth).__root__, ) def _check_config_tenant_id( diff --git a/src/dstack/_internal/core/backends/azure/models.py b/src/dstack/_internal/core/backends/azure/models.py index 9881edcaea..0d7c11a116 100644 --- a/src/dstack/_internal/core/backends/azure/models.py +++ b/src/dstack/_internal/core/backends/azure/models.py @@ -10,7 +10,7 @@ class AzureClientCreds(CoreModel): client_id: Annotated[str, Field(description="The client ID")] client_secret: Annotated[str, Field(description="The client secret")] # if tenant_id is missing, it will be populated from config info - tenant_id: Optional[str] + tenant_id: Optional[str] = None class AzureDefaultCreds(CoreModel): @@ -99,7 +99,7 @@ class AzureStoredConfig(AzureBackendConfig): class AzureConfig(AzureStoredConfig): - creds: AnyAzureCreds + creds: Annotated[AnyAzureCreds, Field(discriminator="type")] @property def allocate_public_ips(self) -> bool: diff --git a/src/dstack/_internal/core/backends/cloudrift/configurator.py b/src/dstack/_internal/core/backends/cloudrift/configurator.py index b6097d1654..9f69958026 100644 --- a/src/dstack/_internal/core/backends/cloudrift/configurator.py +++ b/src/dstack/_internal/core/backends/cloudrift/configurator.py @@ -61,7 +61,7 @@ def get_backend(self, record: BackendRecord) -> CloudRiftBackend: def _get_config(self, record: BackendRecord) -> CloudRiftConfig: return CloudRiftConfig.__response__( **json.loads(record.config), - creds=CloudRiftCreds.parse_raw(record.auth), + creds=CloudRiftCreds.__response__.parse_raw(record.auth), ) def _validate_creds(self, creds: AnyCloudRiftCreds): diff --git a/src/dstack/_internal/core/backends/crusoe/configurator.py b/src/dstack/_internal/core/backends/crusoe/configurator.py index 95f805458e..a6e4274a43 100644 --- a/src/dstack/_internal/core/backends/crusoe/configurator.py +++ b/src/dstack/_internal/core/backends/crusoe/configurator.py @@ -74,5 +74,5 @@ def get_backend(self, record: BackendRecord) -> CrusoeBackend: def _get_config(self, record: BackendRecord) -> CrusoeConfig: return CrusoeConfig.__response__( **json.loads(record.config), - creds=CrusoeCreds.parse_raw(record.auth), + creds=CrusoeCreds.__response__.parse_raw(record.auth), ) diff --git a/src/dstack/_internal/core/backends/digitalocean_base/configurator.py b/src/dstack/_internal/core/backends/digitalocean_base/configurator.py index f44c5d2d0f..9f0fc21699 100644 --- a/src/dstack/_internal/core/backends/digitalocean_base/configurator.py +++ b/src/dstack/_internal/core/backends/digitalocean_base/configurator.py @@ -50,7 +50,7 @@ def get_backend(self, record: BackendRecord) -> BaseDigitalOceanBackend: def _get_config(self, record: BackendRecord) -> BaseDigitalOceanConfig: return BaseDigitalOceanConfig.__response__( **json.loads(record.config), - creds=BaseDigitalOceanCreds.parse_raw(record.auth), + creds=BaseDigitalOceanCreds.__response__.parse_raw(record.auth), ) def _validate_creds(self, creds: AnyBaseDigitalOceanCreds, project_name: Optional[str] = None): diff --git a/src/dstack/_internal/core/backends/gcp/configurator.py b/src/dstack/_internal/core/backends/gcp/configurator.py index c40aa2b7d3..ce15264fd1 100644 --- a/src/dstack/_internal/core/backends/gcp/configurator.py +++ b/src/dstack/_internal/core/backends/gcp/configurator.py @@ -166,7 +166,7 @@ def get_backend(self, record: BackendRecord) -> GCPBackend: def _get_config(self, record: BackendRecord) -> GCPConfig: return GCPConfig.__response__( **json.loads(record.config), - creds=GCPCreds.parse_raw(record.auth).__root__, + creds=GCPCreds.__response__.parse_raw(record.auth).__root__, ) def _check_config_tags(self, config: GCPBackendConfigWithCreds): diff --git a/src/dstack/_internal/core/backends/gcp/models.py b/src/dstack/_internal/core/backends/gcp/models.py index 4d06144ee8..10c5a42d0f 100644 --- a/src/dstack/_internal/core/backends/gcp/models.py +++ b/src/dstack/_internal/core/backends/gcp/models.py @@ -141,7 +141,7 @@ class GCPStoredConfig(GCPBackendConfig): class GCPConfig(GCPStoredConfig): - creds: AnyGCPCreds + creds: Annotated[AnyGCPCreds, Field(discriminator="type")] @property def allocate_public_ips(self) -> bool: diff --git a/src/dstack/_internal/core/backends/hotaisle/configurator.py b/src/dstack/_internal/core/backends/hotaisle/configurator.py index 8f7a6f537f..19162ca992 100644 --- a/src/dstack/_internal/core/backends/hotaisle/configurator.py +++ b/src/dstack/_internal/core/backends/hotaisle/configurator.py @@ -58,7 +58,7 @@ def get_backend(self, record: BackendRecord) -> HotAisleBackend: def _get_config(self, record: BackendRecord) -> HotAisleConfig: return HotAisleConfig.__response__( **json.loads(record.config), - creds=HotAisleCreds.parse_raw(record.auth), + creds=HotAisleCreds.__response__.parse_raw(record.auth), ) def _validate_creds(self, creds: AnyHotAisleCreds, team_handle: str): diff --git a/src/dstack/_internal/core/backends/jarvislabs/configurator.py b/src/dstack/_internal/core/backends/jarvislabs/configurator.py index ceaebeedf9..041694256f 100644 --- a/src/dstack/_internal/core/backends/jarvislabs/configurator.py +++ b/src/dstack/_internal/core/backends/jarvislabs/configurator.py @@ -62,7 +62,7 @@ def get_backend(self, record: BackendRecord) -> JarvisLabsBackend: def _get_config(self, record: BackendRecord) -> JarvisLabsConfig: return JarvisLabsConfig.__response__( **json.loads(record.config), - creds=JarvisLabsCreds.parse_raw(record.auth), + creds=JarvisLabsCreds.__response__.parse_raw(record.auth), ) def _validate_api_key(self, api_key: str): diff --git a/src/dstack/_internal/core/backends/lambdalabs/configurator.py b/src/dstack/_internal/core/backends/lambdalabs/configurator.py index 7c99cb2139..7e4d49c717 100644 --- a/src/dstack/_internal/core/backends/lambdalabs/configurator.py +++ b/src/dstack/_internal/core/backends/lambdalabs/configurator.py @@ -56,7 +56,7 @@ def get_backend(self, record: BackendRecord) -> LambdaBackend: def _get_config(self, record: BackendRecord) -> LambdaConfig: return LambdaConfig.__response__( **json.loads(record.config), - creds=LambdaCreds.parse_raw(record.auth), + creds=LambdaCreds.__response__.parse_raw(record.auth), ) def _validate_lambda_api_key(self, api_key: str): diff --git a/src/dstack/_internal/core/backends/models.py b/src/dstack/_internal/core/backends/models.py index a7bb8c9ad9..bf06dbf3f0 100644 --- a/src/dstack/_internal/core/backends/models.py +++ b/src/dstack/_internal/core/backends/models.py @@ -1,4 +1,6 @@ -from typing import Union +from typing import Annotated, Union + +from pydantic import Field from dstack._internal.core.backends.aws.models import ( AWSBackendConfig, @@ -140,6 +142,43 @@ DstackBackendConfig, ] +# Permissive counterpart of `AnyBackendConfigWithCreds` for parsing server responses. +# A newer server may add config fields that an older client's models don't know about; +# parsing with the strict variant would reject the response outright. +# +# Discriminated on `type`: without it, arm selection would depend on trying each of the 20 +# arms in order, which only works because every arm happens to declare a `Literal` type. +# `AnyBackendConfigWithCreds` above stays a bare `Union` on purpose. Its two server-side users apply +# `Field(discriminator="type")` at the point of use, which is fine against a bare alias. +# Baking the discriminator into the alias would turn those into doubled `Annotated` `Field`s and +# fail with `ValueError: cannot specify multiple 'Annotated' 'Field's`. +# Discriminating here is because nothing else wraps this alias. +AnyBackendConfigWithCredsResponse = Annotated[ + Union[ + AWSBackendConfigWithCreds.__response__, + AzureBackendConfigWithCreds.__response__, + CloudRiftBackendConfigWithCreds.__response__, + CrusoeBackendConfigWithCreds.__response__, + CudoBackendConfigWithCreds.__response__, + VerdaBackendConfigWithCreds.__response__, + BaseDigitalOceanBackendConfigWithCreds.__response__, + GCPBackendConfigWithCreds.__response__, + HotAisleBackendConfigWithCreds.__response__, + JarvisLabsBackendConfigWithCreds.__response__, + KubernetesBackendConfigWithCreds.__response__, + LambdaBackendConfigWithCreds.__response__, + OCIBackendConfigWithCreds.__response__, + NebiusBackendConfigWithCreds.__response__, + RunpodBackendConfigWithCreds.__response__, + TensorDockBackendConfigWithCreds.__response__, + VastAIBackendConfigWithCreds.__response__, + VultrBackendConfigWithCreds.__response__, + SlurmBackendConfigWithCreds.__response__, + DstackBackendConfig.__response__, + ], + Field(discriminator="type"), +] + # Backend config accepted in server/config.yaml. # This can be different from the API config. # For example, it can make creds data optional and resolve it by filename. diff --git a/src/dstack/_internal/core/backends/nebius/compute.py b/src/dstack/_internal/core/backends/nebius/compute.py index 530d08e48e..a12eb7a205 100644 --- a/src/dstack/_internal/core/backends/nebius/compute.py +++ b/src/dstack/_internal/core/backends/nebius/compute.py @@ -355,7 +355,7 @@ class NebiusClusterBackendData(CoreModel): class NebiusPlacementGroupBackendData(CoreModel): - cluster: Optional[NebiusClusterBackendData] + cluster: Optional[NebiusClusterBackendData] = None @classmethod def load(cls, raw: Optional[str]) -> "NebiusPlacementGroupBackendData": diff --git a/src/dstack/_internal/core/backends/nebius/configurator.py b/src/dstack/_internal/core/backends/nebius/configurator.py index a27fa0b0aa..a349863527 100644 --- a/src/dstack/_internal/core/backends/nebius/configurator.py +++ b/src/dstack/_internal/core/backends/nebius/configurator.py @@ -94,5 +94,5 @@ def get_backend(self, record: BackendRecord) -> NebiusBackend: def _get_config(self, record: BackendRecord) -> NebiusConfig: return NebiusConfig.__response__( **json.loads(record.config), - creds=NebiusCreds.parse_raw(record.auth), + creds=NebiusCreds.__response__.parse_raw(record.auth), ) diff --git a/src/dstack/_internal/core/backends/oci/configurator.py b/src/dstack/_internal/core/backends/oci/configurator.py index 4558e8bf96..61ee596ad6 100644 --- a/src/dstack/_internal/core/backends/oci/configurator.py +++ b/src/dstack/_internal/core/backends/oci/configurator.py @@ -102,7 +102,7 @@ def get_backend(self, record: BackendRecord) -> OCIBackend: def _get_config(self, record: BackendRecord) -> OCIConfig: return OCIConfig.__response__( **json.loads(record.config), - creds=OCICreds.parse_raw(record.auth).__root__, + creds=OCICreds.__response__.parse_raw(record.auth).__root__, ) diff --git a/src/dstack/_internal/core/backends/oci/models.py b/src/dstack/_internal/core/backends/oci/models.py index 12ce4e9f91..4212efecb3 100644 --- a/src/dstack/_internal/core/backends/oci/models.py +++ b/src/dstack/_internal/core/backends/oci/models.py @@ -14,16 +14,16 @@ class OCIClientCreds(CoreModel): Field( description="Path to the user's private PEM key. Either this or `key_content` should be set" ), - ] + ] = None key_content: Annotated[ Optional[str], Field( description="Content of the user's private PEM key. Either this or `key_file` should be set" ), - ] + ] = None pass_phrase: Annotated[ Optional[str], Field(description="Passphrase for the private PEM key if it is encrypted") - ] + ] = None fingerprint: Annotated[str, Field(description="User's public key fingerprint")] region: Annotated[ str, Field(description="Name or key of any region the tenancy is subscribed to") @@ -84,4 +84,4 @@ class OCIStoredConfig(OCIBackendConfig): class OCIConfig(OCIStoredConfig): - creds: AnyOCICreds + creds: Annotated[AnyOCICreds, Field(discriminator="type")] diff --git a/src/dstack/_internal/core/backends/runpod/configurator.py b/src/dstack/_internal/core/backends/runpod/configurator.py index df023f7179..c2c3221318 100644 --- a/src/dstack/_internal/core/backends/runpod/configurator.py +++ b/src/dstack/_internal/core/backends/runpod/configurator.py @@ -52,9 +52,9 @@ def get_backend(self, record: BackendRecord) -> RunpodBackend: return RunpodBackend(config=config) def _get_config(self, record: BackendRecord) -> RunpodConfig: - return RunpodConfig( + return RunpodConfig.__response__( **json.loads(record.config), - creds=RunpodCreds.parse_raw(record.auth), + creds=RunpodCreds.__response__.parse_raw(record.auth), ) def _validate_runpod_api_key(self, api_key: str): diff --git a/src/dstack/_internal/core/backends/vastai/configurator.py b/src/dstack/_internal/core/backends/vastai/configurator.py index e854a7ea0f..cab9dbb10a 100644 --- a/src/dstack/_internal/core/backends/vastai/configurator.py +++ b/src/dstack/_internal/core/backends/vastai/configurator.py @@ -60,7 +60,7 @@ def get_backend(self, record: BackendRecord) -> VastAIBackend: def _get_config(self, record: BackendRecord) -> VastAIConfig: return VastAIConfig.__response__( **json.loads(record.config), - creds=VastAICreds.parse_raw(record.auth), + creds=VastAICreds.__response__.parse_raw(record.auth), ) def _validate_vastai_creds(self, api_key: str): diff --git a/src/dstack/_internal/core/backends/verda/configurator.py b/src/dstack/_internal/core/backends/verda/configurator.py index 64b0dec034..274c96638d 100644 --- a/src/dstack/_internal/core/backends/verda/configurator.py +++ b/src/dstack/_internal/core/backends/verda/configurator.py @@ -58,7 +58,7 @@ def get_backend(self, record: BackendRecord) -> VerdaBackend: def _get_config(self, record: BackendRecord) -> VerdaConfig: return VerdaConfig.__response__( **json.loads(record.config), - creds=VerdaCreds.parse_raw(record.auth), + creds=VerdaCreds.__response__.parse_raw(record.auth), ) def _validate_creds(self, creds: VerdaCreds): diff --git a/src/dstack/_internal/core/backends/vultr/configurator.py b/src/dstack/_internal/core/backends/vultr/configurator.py index 39f98a03e8..23bde1c381 100644 --- a/src/dstack/_internal/core/backends/vultr/configurator.py +++ b/src/dstack/_internal/core/backends/vultr/configurator.py @@ -62,7 +62,7 @@ def get_backend(self, record: BackendRecord) -> VultrBackend: def _get_config(self, record: BackendRecord) -> VultrConfig: return VultrConfig.__response__( **json.loads(record.config), - creds=VultrCreds.parse_raw(record.auth), + creds=VultrCreds.__response__.parse_raw(record.auth), ) def _validate_vultr_api_key(self, api_key: str): diff --git a/src/dstack/_internal/core/models/common.py b/src/dstack/_internal/core/models/common.py index 70578e272a..42c56765d2 100644 --- a/src/dstack/_internal/core/models/common.py +++ b/src/dstack/_internal/core/models/common.py @@ -153,7 +153,7 @@ class EntityReference(CoreModel): project: Annotated[ Optional[str], Field(description="The project name. If unspecified, refers to the current project"), - ] + ] = None name: Annotated[str, Field(description="The entity name")] @classmethod diff --git a/src/dstack/_internal/core/models/config.py b/src/dstack/_internal/core/models/config.py index a0497401d9..0c068a3f54 100644 --- a/src/dstack/_internal/core/models/config.py +++ b/src/dstack/_internal/core/models/config.py @@ -9,7 +9,7 @@ class ProjectConfig(CoreModel): name: str url: str token: str - default: Optional[bool] + default: Optional[bool] = None # Not used since 0.20.0. Can be removed when most users update their `config.yml` (it's updated diff --git a/src/dstack/_internal/core/models/configurations.py b/src/dstack/_internal/core/models/configurations.py index cc241ed246..17cf55f71c 100644 --- a/src/dstack/_internal/core/models/configurations.py +++ b/src/dstack/_internal/core/models/configurations.py @@ -845,7 +845,7 @@ class ReplicaGroup(CoreModel): Field( description="The name of the replica group. If not provided, defaults to '0', '1', etc. based on position." ), - ] + ] = None count: Annotated[ Range[int], Field( @@ -1015,15 +1015,20 @@ class ServiceConfigurationParams(CoreModel): ) ), ] = STRIP_PREFIX_DEFAULT + # A discriminator cannot read `format` off the documented `model: ` shorthand, so it + # would reject it. This works only because the `convert_model` pre-validator below expands a + # bare string into an `OpenAIChatModel` first — leaving the union something that carries + # `format` either way: an attribute on the expanded model, or a key in a user-supplied mapping. model: Annotated[ Optional[AnyModel], Field( + discriminator="format", description=( "Mapping of the model for the OpenAI-compatible endpoint provided by `dstack`." " Can be a full model format definition or just a model name." " If it's a name, the service is expected to expose an OpenAI-compatible" " API at the `/v1` path" - ) + ), ), ] = None https: Annotated[ diff --git a/src/dstack/_internal/core/models/events.py b/src/dstack/_internal/core/models/events.py index f2efb80d0e..4d1f6494b6 100644 --- a/src/dstack/_internal/core/models/events.py +++ b/src/dstack/_internal/core/models/events.py @@ -39,7 +39,7 @@ class EventTarget(CoreModel): " or `null` for target types not bound to a project (e.g., users)" ) ), - ] + ] = None project_name: Annotated[ Optional[str], Field( @@ -48,7 +48,7 @@ class EventTarget(CoreModel): " or `null` for target types not bound to a project (e.g., users)" ) ), - ] + ] = None is_project_deleted: Annotated[ Optional[bool], Field( @@ -74,7 +74,7 @@ class Event(CoreModel): " or `null` if the action was performed by the system" ) ), - ] + ] = None actor_user: Annotated[ Optional[str], Field( @@ -83,7 +83,7 @@ class Event(CoreModel): " or `null` if the action was performed by the system" ) ), - ] + ] = None is_actor_user_deleted: Annotated[ Optional[bool], Field( diff --git a/src/dstack/_internal/core/models/gateways.py b/src/dstack/_internal/core/models/gateways.py index 191bfc81e0..19ed9e9235 100644 --- a/src/dstack/_internal/core/models/gateways.py +++ b/src/dstack/_internal/core/models/gateways.py @@ -97,8 +97,9 @@ class GatewayConfiguration(CoreModel): certificate: Annotated[ Optional[AnyGatewayCertificate], Field( + discriminator="type", description="The SSL certificate configuration." - " Set to `null` to disable. Defaults to `type: lets-encrypt`" + " Set to `null` to disable. Defaults to `type: lets-encrypt`", ), ] = LetsEncryptGatewayCertificate() replicas: Annotated[ @@ -148,13 +149,13 @@ class Gateway(CoreModel): configuration: GatewayConfiguration created_at: datetime.datetime status: GatewayStatus - status_message: Optional[str] - hostname: Optional[str] + status_message: Optional[str] = None + hostname: Optional[str] = None """Hostname of the load balancer. Unset if there is no load balancer, in which case users are expected to point the gateway's wildcard domain name to `replicas[i].hostname`. """ - wildcard_domain: Optional[str] + wildcard_domain: Optional[str] = None default: bool replicas: list[GatewayReplica] = [] backend: Optional[BackendType] = None @@ -203,7 +204,7 @@ class GatewayComputeConfiguration(CoreModel): instance_type: Optional[str] = None public_ip: bool ssh_key_pub: str - certificate: Optional[AnyGatewayCertificate] = None + certificate: Annotated[Optional[AnyGatewayCertificate], Field(discriminator="type")] = None tags: Optional[Dict[str, str]] = None router: Optional[AnyGatewayRouterConfig] = None diff --git a/src/dstack/_internal/core/models/repos/__init__.py b/src/dstack/_internal/core/models/repos/__init__.py index 25a3d06aca..4de532e71d 100644 --- a/src/dstack/_internal/core/models/repos/__init__.py +++ b/src/dstack/_internal/core/models/repos/__init__.py @@ -28,7 +28,7 @@ class RepoHead(CoreModel): class RepoHeadWithCreds(RepoHead): - repo_creds: Optional[RemoteRepoCreds] + repo_creds: Optional[RemoteRepoCreds] = None AnyRepoHead = Union[RepoHeadWithCreds, RepoHead] diff --git a/src/dstack/_internal/core/models/resources.py b/src/dstack/_internal/core/models/resources.py index ff2a173a5d..72febf8eb9 100644 --- a/src/dstack/_internal/core/models/resources.py +++ b/src/dstack/_internal/core/models/resources.py @@ -19,8 +19,8 @@ class Range(GenericModel, Generic[T]): - min: Optional[T] - max: Optional[T] + min: Optional[T] = None + max: Optional[T] = None class Config: extra = "forbid" diff --git a/src/dstack/_internal/core/models/runs.py b/src/dstack/_internal/core/models/runs.py index 04f4c326d8..d373fb23bc 100644 --- a/src/dstack/_internal/core/models/runs.py +++ b/src/dstack/_internal/core/models/runs.py @@ -53,7 +53,7 @@ class AppSpec(CoreModel): port: int - map_to_port: Optional[int] + map_to_port: Optional[int] = None app_name: str url_path: Optional[str] = None url_query_params: Optional[Dict[str, str]] = None @@ -263,24 +263,24 @@ class JobSpec(CoreModel): jobs_per_replica: int = 1 """`jobs_per_replica` uses a default value for backward compatibility.""" replica_group: str = DEFAULT_REPLICA_GROUP_NAME - app_specs: Optional[List[AppSpec]] + app_specs: Optional[List[AppSpec]] = None user: Optional[UnixUser] = None """`user` uses a default value for backward compatibility.""" commands: List[str] env: Dict[str, str] - home_dir: Optional[str] + home_dir: Optional[str] = None image_name: str privileged: bool = False single_branch: Optional[bool] = None - max_duration: Optional[int] + max_duration: Optional[int] = None stop_duration: Optional[int] = None utilization_policy: Optional[UtilizationPolicy] = None - registry_auth: Optional[RegistryAuth] + registry_auth: Optional[RegistryAuth] = None requirements: Requirements - retry: Optional[Retry] + retry: Optional[Retry] = None volumes: Optional[List[MountPoint]] = None ssh_key: Optional[JobSSHKey] = None - working_dir: Optional[str] + working_dir: Optional[str] = None repo_data: Annotated[Optional[AnyRunRepoData], Field(discriminator="repo_type")] = None """`repo_data` is optional for client compatibility with pre-0.19.17 servers and for jobs submitted before 0.19.17. All new jobs are expected to have non-`None` `repo_data`. @@ -443,7 +443,7 @@ def duration(self) -> timedelta: class JobConnectionInfo(CoreModel): ide_name: Annotated[ Optional[str], Field(description="Dev environment IDE name for UI, human-readable.") - ] + ] = None attached_ide_url: Annotated[ Optional[str], Field( @@ -453,7 +453,7 @@ class JobConnectionInfo(CoreModel): " Only works if the user is attached to the run via CLI or Python API." ) ), - ] + ] = None proxied_ide_url: Annotated[ Optional[str], Field( @@ -462,7 +462,7 @@ class JobConnectionInfo(CoreModel): " Not set if the job has hot started yet or sshproxy is not configured." ) ), - ] + ] = None attached_ssh_command: Annotated[ Optional[list[str]], Field( @@ -471,7 +471,7 @@ class JobConnectionInfo(CoreModel): " Only works if the user is attached to the run via CLI or Python API." ) ), - ] + ] = None proxied_ssh_command: Annotated[ Optional[list[str]], Field( @@ -480,7 +480,7 @@ class JobConnectionInfo(CoreModel): " Not set if sshproxy is not configured." ) ), - ] + ] = None sshproxy_hostname: Annotated[ Optional[str], Field(description="sshproxy hostname. Not set if sshproxy is not configured."), @@ -709,7 +709,7 @@ class JobPlan(CoreModel): job_spec: JobSpec offers: List[InstanceOfferWithAvailability] total_offers: int - max_price: Optional[float] + max_price: Optional[float] = None class RunPlan(CoreModel): diff --git a/src/dstack/_internal/core/models/server.py b/src/dstack/_internal/core/models/server.py index cb570b042e..16e3bdd3fc 100644 --- a/src/dstack/_internal/core/models/server.py +++ b/src/dstack/_internal/core/models/server.py @@ -4,4 +4,4 @@ class ServerInfo(CoreModel): - server_version: Optional[str] + server_version: Optional[str] = None diff --git a/src/dstack/_internal/core/models/users.py b/src/dstack/_internal/core/models/users.py index 8e70e092d6..78c5ddc9bd 100644 --- a/src/dstack/_internal/core/models/users.py +++ b/src/dstack/_internal/core/models/users.py @@ -27,7 +27,7 @@ class User(CoreModel): username: str created_at: Optional[datetime] = None global_role: GlobalRole - email: Optional[str] + email: Optional[str] = None active: bool permissions: UserPermissions ssh_public_key: Optional[str] = None diff --git a/src/dstack/_internal/core/models/volumes.py b/src/dstack/_internal/core/models/volumes.py index 1b96331903..f786804bfc 100644 --- a/src/dstack/_internal/core/models/volumes.py +++ b/src/dstack/_internal/core/models/volumes.py @@ -288,7 +288,7 @@ class VolumePlan(CoreModel): project_name: str user: str spec: VolumeSpec - current_resource: Optional[Volume] + current_resource: Optional[Volume] = None def _split_mount_point(mount_point: str) -> Tuple[str, str]: diff --git a/src/dstack/_internal/proxy/gateway/schemas/config.py b/src/dstack/_internal/proxy/gateway/schemas/config.py index 576a82d352..09cfc2101f 100644 --- a/src/dstack/_internal/proxy/gateway/schemas/config.py +++ b/src/dstack/_internal/proxy/gateway/schemas/config.py @@ -4,6 +4,6 @@ class ConfigRequest(BaseModel): - acme_server: Optional[AnyHttpUrl] - acme_eab_kid: Optional[str] - acme_eab_hmac_key: Optional[str] + acme_server: Optional[AnyHttpUrl] = None + acme_eab_kid: Optional[str] = None + acme_eab_hmac_key: Optional[str] = None diff --git a/src/dstack/_internal/proxy/gateway/schemas/registry.py b/src/dstack/_internal/proxy/gateway/schemas/registry.py index 1dc354d1fa..beb6242f05 100644 --- a/src/dstack/_internal/proxy/gateway/schemas/registry.py +++ b/src/dstack/_internal/proxy/gateway/schemas/registry.py @@ -54,10 +54,10 @@ class RegisterReplicaRequest(BaseModel): app_port: int ssh_host: str ssh_port: int - ssh_proxy: Optional[SSHConnectionParams] - ssh_proxy_private_key: Optional[str] - ssh_head_proxy: Optional[SSHConnectionParams] - ssh_head_proxy_private_key: Optional[str] + ssh_proxy: Optional[SSHConnectionParams] = None + ssh_proxy_private_key: Optional[str] = None + ssh_head_proxy: Optional[SSHConnectionParams] = None + ssh_head_proxy_private_key: Optional[str] = None internal_ip: Optional[str] = None diff --git a/src/dstack/_internal/proxy/gateway/services/nginx.py b/src/dstack/_internal/proxy/gateway/services/nginx.py index 55b3d23675..9bc750b997 100644 --- a/src/dstack/_internal/proxy/gateway/services/nginx.py +++ b/src/dstack/_internal/proxy/gateway/services/nginx.py @@ -62,7 +62,7 @@ class LimitReqConfig(BaseModel): class LocationConfig(BaseModel): prefix: str - limit_req: Optional[LimitReqConfig] + limit_req: Optional[LimitReqConfig] = None class ServiceConfig(SiteConfig): diff --git a/src/dstack/_internal/proxy/lib/models.py b/src/dstack/_internal/proxy/lib/models.py index f41e87a426..a6128412d9 100644 --- a/src/dstack/_internal/proxy/lib/models.py +++ b/src/dstack/_internal/proxy/lib/models.py @@ -23,7 +23,7 @@ class Replica(ImmutableModel): app_port: int ssh_destination: str ssh_port: int - ssh_proxy: Optional[SSHConnectionParams] + ssh_proxy: Optional[SSHConnectionParams] = None ssh_proxy_private_key: Optional[str] = None "`None` means same as service project's key" # Optional outer proxy, a head node/bastion @@ -54,8 +54,8 @@ class RateLimit(ImmutableModel): class Service(ImmutableModel): project_name: str run_name: str - domain: Optional[str] # only used on gateways - https: Optional[bool] # only used on gateways + domain: Optional[str] = None # only used on gateways + https: Optional[bool] = None # only used on gateways rate_limits: tuple[RateLimit, ...] = () # only used on gateways auth: bool client_max_body_size: int # only enforced on gateways diff --git a/src/dstack/_internal/proxy/lib/schemas/model_proxy.py b/src/dstack/_internal/proxy/lib/schemas/model_proxy.py index e8853a375e..632a490dc4 100644 --- a/src/dstack/_internal/proxy/lib/schemas/model_proxy.py +++ b/src/dstack/_internal/proxy/lib/schemas/model_proxy.py @@ -36,7 +36,7 @@ class ChatCompletionsChoice(CoreModel): class ChatCompletionsChunkChoice(CoreModel): delta: object logprobs: object = {} - finish_reason: Optional[str] + finish_reason: Optional[str] = None index: int diff --git a/src/dstack/_internal/server/models.py b/src/dstack/_internal/server/models.py index 68903caeca..b0a64c9761 100644 --- a/src/dstack/_internal/server/models.py +++ b/src/dstack/_internal/server/models.py @@ -87,7 +87,7 @@ class DecryptedString(generate_dual_core_model(DecryptedStringConfig)): This is useful so that application code can have custom handling of failed decrypts (e.g. ignoring). """ - plaintext: Optional[str] + plaintext: Optional[str] = None """ `plaintext` should not be read directly to avoid ignoring errors accidentally. Unpack with `get_plaintext_or_error()`. diff --git a/src/dstack/_internal/server/schemas/auth.py b/src/dstack/_internal/server/schemas/auth.py index 942f1fb388..519988484d 100644 --- a/src/dstack/_internal/server/schemas/auth.py +++ b/src/dstack/_internal/server/schemas/auth.py @@ -80,4 +80,4 @@ class OAuthGetNextRedirectResponse(CoreModel): " If `null`, there is no next redirect." ) ), - ] + ] = None diff --git a/src/dstack/_internal/server/schemas/fleets.py b/src/dstack/_internal/server/schemas/fleets.py index 4bb25d50bb..f3edd3eaed 100644 --- a/src/dstack/_internal/server/schemas/fleets.py +++ b/src/dstack/_internal/server/schemas/fleets.py @@ -25,7 +25,7 @@ class ListProjectFleetsRequest(CoreModel): class GetFleetRequest(CoreModel): - name: Optional[str] + name: Optional[str] = None id: Optional[UUID] = None def get_name_or_id(self) -> EntityNameOrID: diff --git a/src/dstack/_internal/server/schemas/repos.py b/src/dstack/_internal/server/schemas/repos.py index b58a33d199..a3bc717538 100644 --- a/src/dstack/_internal/server/schemas/repos.py +++ b/src/dstack/_internal/server/schemas/repos.py @@ -13,11 +13,11 @@ class GetRepoRequest(RepoRequest): class SaveRepoCredsRequest(RepoRequest): - repo_info: AnyRepoInfo + repo_info: Annotated[AnyRepoInfo, Field(discriminator="repo_type")] repo_creds: Annotated[ Optional[RemoteRepoCreds], Field(description="The repo creds for accessing private remote repo"), - ] + ] = None class DeleteReposRequest(CoreModel): diff --git a/src/dstack/_internal/server/schemas/runner.py b/src/dstack/_internal/server/schemas/runner.py index c1ad0407d0..3d366e6574 100644 --- a/src/dstack/_internal/server/schemas/runner.py +++ b/src/dstack/_internal/server/schemas/runner.py @@ -101,9 +101,9 @@ class SubmitBody(CoreModel): } ), ] - cluster_info: Annotated[Optional[ClusterInfo], Field(include=True)] - secrets: Annotated[Optional[Dict[str, str]], Field(include=True)] - repo_credentials: Annotated[Optional[RemoteRepoCreds], Field(include=True)] + cluster_info: Annotated[Optional[ClusterInfo], Field(include=True)] = None + secrets: Annotated[Optional[Dict[str, str]], Field(include=True)] = None + repo_credentials: Annotated[Optional[RemoteRepoCreds], Field(include=True)] = None log_quota_hour: Annotated[Optional[int], Field(include=True)] = None """Maximum bytes of log output per hour. None means unlimited.""" # TODO: remove `run_spec` once instances deployed with 0.19.8 or earlier are no longer supported. @@ -282,4 +282,4 @@ class JobResult(CoreModel): class LegacyPullResponse(CoreModel): state: str - result: Optional[JobResult] + result: Optional[JobResult] = None diff --git a/src/dstack/_internal/server/schemas/users.py b/src/dstack/_internal/server/schemas/users.py index 574d5b093e..b95fcd9cd4 100644 --- a/src/dstack/_internal/server/schemas/users.py +++ b/src/dstack/_internal/server/schemas/users.py @@ -57,7 +57,7 @@ class GetUserRequest(CoreModel): class CreateUserRequest(CoreModel): username: str global_role: GlobalRole - email: Optional[str] + email: Optional[str] = None active: bool = True diff --git a/src/dstack/_internal/server/schemas/volumes.py b/src/dstack/_internal/server/schemas/volumes.py index ff1b106f9f..5f726d20a3 100644 --- a/src/dstack/_internal/server/schemas/volumes.py +++ b/src/dstack/_internal/server/schemas/volumes.py @@ -9,10 +9,10 @@ class ListVolumesRequest(CoreModel): - project_name: Optional[str] + project_name: Optional[str] = None only_active: bool = False - prev_created_at: Optional[datetime] - prev_id: Optional[UUID] + prev_created_at: Optional[datetime] = None + prev_id: Optional[UUID] = None limit: int = Field(100, ge=0, le=100) ascending: bool = False diff --git a/src/dstack/_internal/server/testing/common.py b/src/dstack/_internal/server/testing/common.py index 4371175e52..de8e310542 100644 --- a/src/dstack/_internal/server/testing/common.py +++ b/src/dstack/_internal/server/testing/common.py @@ -442,7 +442,7 @@ async def create_job( ) -> JobModel: if deployment_num is None: deployment_num = run.deployment_num - run_spec = RunSpec.parse_raw(run.run_spec) + run_spec = RunSpec.__response__.parse_raw(run.run_spec) job_spec = ( await get_job_specs_from_run_spec(run_spec=run_spec, secrets={}, replica_num=replica_num) )[0] diff --git a/src/dstack/_internal/utils/json_schema.py b/src/dstack/_internal/utils/json_schema.py index 19bcd0bc62..aec695c16d 100644 --- a/src/dstack/_internal/utils/json_schema.py +++ b/src/dstack/_internal/utils/json_schema.py @@ -3,6 +3,11 @@ def add_extra_schema_types(schema_property: dict, extra_types: list[dict]): refs = [schema_property.pop("allOf")[0]] elif "anyOf" in schema_property: refs = schema_property.pop("anyOf") + elif "oneOf" in schema_property: + nested = {"oneOf": schema_property.pop("oneOf")} + if "discriminator" in schema_property: + nested["discriminator"] = schema_property.pop("discriminator") + refs = [nested] elif "type" in schema_property: refs = [{"type": schema_property.pop("type")}] else: diff --git a/src/dstack/api/server/_backends.py b/src/dstack/api/server/_backends.py index ba9db2bc00..08e5cd597d 100644 --- a/src/dstack/api/server/_backends.py +++ b/src/dstack/api/server/_backends.py @@ -4,6 +4,7 @@ from dstack._internal.core.backends.models import ( AnyBackendConfigWithCreds, + AnyBackendConfigWithCredsResponse, ) from dstack._internal.core.models.backends.base import BackendType from dstack._internal.server.schemas.backends import DeleteBackendsRequest @@ -13,19 +14,25 @@ class BackendsAPIClient(APIClientGroup): def list_backend_types(self) -> List[BackendType]: resp = self._request("/api/backends/list_types") - return parse_obj_as(List[BackendType], resp.json()) + backend_types = [] + for value in parse_obj_as(List[str], resp.json()): + try: + backend_types.append(BackendType(value)) + except ValueError: + continue + return backend_types def create( self, project_name: str, config: AnyBackendConfigWithCreds ) -> AnyBackendConfigWithCreds: resp = self._request(f"/api/project/{project_name}/backends/create", body=config.json()) - return parse_obj_as(AnyBackendConfigWithCreds, resp.json()) + return parse_obj_as(AnyBackendConfigWithCredsResponse, resp.json()) def update( self, project_name: str, config: AnyBackendConfigWithCreds ) -> AnyBackendConfigWithCreds: resp = self._request(f"/api/project/{project_name}/backends/update", body=config.json()) - return parse_obj_as(AnyBackendConfigWithCreds, resp.json()) + return parse_obj_as(AnyBackendConfigWithCredsResponse, resp.json()) def delete(self, project_name: str, backends_names: List[BackendType]): body = DeleteBackendsRequest(backends_names=backends_names) @@ -35,4 +42,4 @@ def config_info( self, project_name: str, backend_name: BackendType ) -> AnyBackendConfigWithCreds: resp = self._request(f"/api/project/{project_name}/backends/{backend_name}/config_info") - return parse_obj_as(AnyBackendConfigWithCreds, resp.json()) + return parse_obj_as(AnyBackendConfigWithCredsResponse, resp.json()) diff --git a/src/dstack/api/server/_gpus.py b/src/dstack/api/server/_gpus.py index 068538c94e..78886d4d4e 100644 --- a/src/dstack/api/server/_gpus.py +++ b/src/dstack/api/server/_gpus.py @@ -28,4 +28,4 @@ def list_gpus( f"/api/project/{project_name}/gpus/list", body=body.json(exclude=get_list_gpus_excludes(body)), ) - return parse_obj_as(ListGpusResponse, resp.json()).gpus + return parse_obj_as(ListGpusResponse.__response__, resp.json()).gpus diff --git a/src/dstack/api/server/_projects.py b/src/dstack/api/server/_projects.py index 96a1f511f7..6c45f63364 100644 --- a/src/dstack/api/server/_projects.py +++ b/src/dstack/api/server/_projects.py @@ -82,7 +82,7 @@ def list( resp_json = resp.json() if isinstance(resp_json, list): return parse_obj_as(List[Project.__response__], resp_json) - return parse_obj_as(ProjectsInfoList, resp_json) + return parse_obj_as(ProjectsInfoList.__response__, resp_json) def create(self, project_name: str, is_public: bool = False) -> Project: body = CreateProjectRequest(project_name=project_name, is_public=is_public) diff --git a/src/dstack/api/server/_secrets.py b/src/dstack/api/server/_secrets.py index 9a2a2763f1..1efcdac36e 100644 --- a/src/dstack/api/server/_secrets.py +++ b/src/dstack/api/server/_secrets.py @@ -19,7 +19,7 @@ def list(self, project_name: str) -> List[Secret]: def get(self, project_name: str, name: str) -> Secret: body = GetSecretRequest(name=name) resp = self._request(f"/api/project/{project_name}/secrets/get", body=body.json()) - return parse_obj_as(Secret, resp.json()) + return parse_obj_as(Secret.__response__, resp.json()) def create_or_update(self, project_name: str, name: str, value: str) -> Secret: body = CreateOrUpdateSecretRequest( diff --git a/src/dstack/api/server/_users.py b/src/dstack/api/server/_users.py index 885eae54a2..ff1bab3d48 100644 --- a/src/dstack/api/server/_users.py +++ b/src/dstack/api/server/_users.py @@ -55,7 +55,7 @@ def list( resp_json = resp.json() if isinstance(resp_json, list): return parse_obj_as(List[User.__response__], resp_json) - return parse_obj_as(UsersInfoList, resp_json) + return parse_obj_as(UsersInfoList.__response__, resp_json) def get_my_user(self) -> UserWithCreds: resp = self._request("/api/users/get_my_user") diff --git a/src/tests/_internal/utils/test_json_schema.py b/src/tests/_internal/utils/test_json_schema.py new file mode 100644 index 0000000000..f6229e5354 --- /dev/null +++ b/src/tests/_internal/utils/test_json_schema.py @@ -0,0 +1,81 @@ +import json + +from dstack._internal.core.models.configurations import DstackConfiguration, ServiceConfiguration +from dstack._internal.core.models.profiles import ProfilesConfig +from dstack._internal.utils.json_schema import add_extra_schema_types + + +class TestAddExtraSchemaTypes: + def test_ref_becomes_any_of(self): + prop = {"$ref": "#/definitions/Foo"} + add_extra_schema_types(prop, extra_types=[{"type": "string"}]) + assert prop == {"anyOf": [{"$ref": "#/definitions/Foo"}, {"type": "string"}]} + + def test_all_of_keeps_first_ref_only(self): + prop = {"allOf": [{"$ref": "#/definitions/Foo"}]} + add_extra_schema_types(prop, extra_types=[{"type": "integer"}]) + assert prop == {"anyOf": [{"$ref": "#/definitions/Foo"}, {"type": "integer"}]} + + def test_any_of_is_extended_in_place(self): + prop = {"anyOf": [{"type": "integer"}]} + add_extra_schema_types(prop, extra_types=[{"type": "string"}]) + assert prop == {"anyOf": [{"type": "integer"}, {"type": "string"}]} + + def test_type_is_wrapped(self): + prop = {"type": "integer"} + add_extra_schema_types(prop, extra_types=[{"type": "string"}]) + assert prop == {"anyOf": [{"type": "integer"}, {"type": "string"}]} + + def test_other_keys_are_preserved(self): + prop = {"title": "Model", "description": "d", "$ref": "#/definitions/Foo"} + add_extra_schema_types(prop, extra_types=[{"type": "string"}]) + assert prop["title"] == "Model" + assert prop["description"] == "d" + + def test_discriminated_one_of_stays_grouped_with_its_discriminator(self): + # A `Field(discriminator=...)` union renders as `oneOf` plus a sibling `discriminator`. + # The two must move into the same `anyOf` member: a `discriminator` only applies to a + # keyword whose every member carries the tag, so flattening the extra types in beside + # the refs would produce an invalid schema. + prop = { + "title": "Model", + "oneOf": [{"$ref": "#/definitions/Foo"}, {"$ref": "#/definitions/Bar"}], + "discriminator": {"propertyName": "format", "mapping": {}}, + } + add_extra_schema_types(prop, extra_types=[{"type": "string"}]) + assert prop == { + "title": "Model", + "anyOf": [ + { + "oneOf": [{"$ref": "#/definitions/Foo"}, {"$ref": "#/definitions/Bar"}], + "discriminator": {"propertyName": "format", "mapping": {}}, + }, + {"type": "string"}, + ], + } + + def test_one_of_without_discriminator(self): + prop = {"oneOf": [{"$ref": "#/definitions/Foo"}]} + add_extra_schema_types(prop, extra_types=[{"type": "string"}]) + assert prop == {"anyOf": [{"oneOf": [{"$ref": "#/definitions/Foo"}]}, {"type": "string"}]} + + +class TestSchemaGeneration: + """ + Guards the schemas CI generates and the docs build consumes. Nothing else in the suite + exercises `schema_json()`, so a `schema_extra` hook that cannot handle the shape pydantic + emits for a field fails only in CI. + """ + + def test_dstack_configuration_schema_is_generated(self): + assert json.loads(DstackConfiguration.schema_json())["definitions"] + + def test_profiles_config_schema_is_generated(self): + assert json.loads(ProfilesConfig.schema_json())["definitions"] + + def test_service_model_accepts_both_the_shorthand_and_the_tagged_forms(self): + prop = json.loads(ServiceConfiguration.schema_json())["properties"]["model"] + tagged, shorthand = prop["anyOf"] + assert shorthand == {"type": "string"} + assert tagged["discriminator"]["propertyName"] == "format" + assert tagged["oneOf"]