From 863f0c9d21b5071107dfb30b5d31b48a1aff1543 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Sun, 23 Aug 2026 14:24:58 +0200 Subject: [PATCH 1/9] Support `dstack preset push` and `dstack preset pull` Adds the client half of the preset registry: a preset is pushed to a project as `/` and pulled anywhere else by name or ID. Push carries the verified service, its benchmark, the hardware it was verified on, and the preset's files; the creation session stays local. Stored presets are now tagged by status, `verified` for a local creation and `pulled` for a registry copy, and the served model repository is reported as `repo` instead of `model`. Presets written by earlier versions are upgraded when read. Co-Authored-By: Claude Opus 5 (1M context) --- mkdocs/docs/reference/cli/dstack/preset.md | 39 +- src/dstack/_internal/cli/commands/preset.py | 76 +- .../_internal/cli/models/preset_agent.py | 2 +- src/dstack/_internal/cli/models/presets.py | 202 +---- .../cli/services/configurators/preset.py | 3 +- .../_internal/cli/services/presets/build.py | 19 +- .../_internal/cli/services/presets/create.py | 18 +- .../_internal/cli/services/presets/export.py | 11 +- .../_internal/cli/services/presets/output.py | 65 +- .../cli/services/presets/registry.py | 291 +++++++ .../_internal/cli/services/presets/session.py | 2 +- .../_internal/cli/services/presets/store.py | 92 ++- .../_internal/cli/services/presets/verify.py | 18 +- .../_internal/core/models/configurations.py | 339 +++++++- src/dstack/_internal/core/models/presets.py | 562 ++++++-------- .../_internal/server/schemas/presets.py | 54 ++ src/dstack/api/server/__init__.py | 6 + src/dstack/api/server/_presets.py | 33 + .../_internal/cli/commands/test_preset.py | 90 ++- src/tests/_internal/cli/common.py | 16 +- .../_internal/cli/models/test_presets.py | 2 +- .../cli/services/presets/test_agent.py | 2 +- .../cli/services/presets/test_build.py | 2 +- .../cli/services/presets/test_create.py | 2 +- .../cli/services/presets/test_export.py | 12 + .../cli/services/presets/test_output.py | 37 +- .../cli/services/presets/test_registry.py | 728 ++++++++++++++++++ .../cli/services/presets/test_store.py | 179 ++++- .../cli/services/presets/test_verify.py | 20 +- .../core/models/test_configurations.py | 108 ++- .../_internal/core/models/test_presets.py | 220 +++--- 31 files changed, 2544 insertions(+), 706 deletions(-) create mode 100644 src/dstack/_internal/cli/services/presets/registry.py create mode 100644 src/dstack/_internal/server/schemas/presets.py create mode 100644 src/dstack/api/server/_presets.py create mode 100644 src/tests/_internal/cli/services/presets/test_registry.py diff --git a/mkdocs/docs/reference/cli/dstack/preset.md b/mkdocs/docs/reference/cli/dstack/preset.md index bff3a597d..3d8bc9d2d 100644 --- a/mkdocs/docs/reference/cli/dstack/preset.md +++ b/mkdocs/docs/reference/cli/dstack/preset.md @@ -1,7 +1,12 @@ # dstack preset The `dstack preset` commands create, list, export, and delete local -[presets](../../../concepts/presets.md). +[presets](../../../concepts/presets.md), and push them to and pull them from a +registry. + +The commands that take a preset — `get`, `export`, and `delete` — accept its ID +or name. A pulled preset's name includes the project it came from, e.g. +`main/qwen38-27b-mi300x`. ## dstack preset list @@ -130,6 +135,38 @@ $ dstack preset export --help +## dstack preset push + +The `dstack preset push` command pushes a local preset to the registry as +`/`. + +##### Usage + +
+ +```shell +$ dstack preset push --help +#GENERATE# +``` + +
+ +## dstack preset pull + +The `dstack preset pull` command pulls `/` or `/` +from the registry and stores it locally. + +##### Usage + +
+ +```shell +$ dstack preset pull --help +#GENERATE# +``` + +
+ ## dstack preset delete The `dstack preset delete` command deletes one local preset by ID or name, or diff --git a/src/dstack/_internal/cli/commands/preset.py b/src/dstack/_internal/cli/commands/preset.py index d4ed0128f..dfcaea1d0 100644 --- a/src/dstack/_internal/cli/commands/preset.py +++ b/src/dstack/_internal/cli/commands/preset.py @@ -12,9 +12,9 @@ from dstack._internal.cli.commands import BaseCommand from dstack._internal.cli.models.presets import ( - Preset, + AnyStoredPreset, PresetListOutput, - VerifiedPreset, + UnverifiedPreset, ) from dstack._internal.cli.services.completion import ProjectNameCompleter from dstack._internal.cli.services.configurators import APPLY_STDIN_NAME @@ -32,6 +32,10 @@ ) from dstack._internal.cli.services.presets.export import export_preset from dstack._internal.cli.services.presets.output import get_presets_table, print_presets +from dstack._internal.cli.services.presets.registry import ( + pull_preset_from_registry, + push_preset_to_registry, +) from dstack._internal.cli.services.presets.session import ( get_presets_dir, list_preset_sessions, @@ -57,7 +61,7 @@ warn, ) from dstack._internal.core.errors import CLIError -from dstack._internal.core.models.presets import PresetConfiguration +from dstack._internal.core.models.configurations import PresetConfiguration from dstack.api import Client @@ -176,6 +180,31 @@ def _register(self) -> None: export_parser.add_argument("--force", action="store_true", help="Overwrite existing files") export_parser.set_defaults(subfunc=self._export) + push_parser = preset_subparsers.add_parser( + "push", + help="Push a preset to the registry", + formatter_class=self._parser.formatter_class, + ) + push_parser.add_argument("preset", metavar="ID", help="The local preset ID or name") + push_parser.add_argument( + "ref", + metavar="PROJECT/NAME", + help="The name to push it under in the registry, prefixed by the project", + ) + push_parser.set_defaults(subfunc=self._push) + + pull_parser = preset_subparsers.add_parser( + "pull", + help="Pull a preset from the registry", + formatter_class=self._parser.formatter_class, + ) + pull_parser.add_argument( + "ref", + metavar="PROJECT/NAME", + help="The preset name or ID in the registry, prefixed by the project", + ) + pull_parser.set_defaults(subfunc=self._pull) + delete_parser = preset_subparsers.add_parser( "delete", help="Delete presets", @@ -259,12 +288,12 @@ def _list(self, args: argparse.Namespace) -> None: def _list_presets_and_sessions( self, *, base: str | None, repo: str | None - ) -> tuple[list[VerifiedPreset], list[dict]]: + ) -> tuple[list[AnyStoredPreset], list[dict]]: self._reconcile() presets = PresetStore().list() sessions = list_preset_sessions() if base or repo: - repo_to_base = {preset.model: preset.base for preset in presets} + repo_to_base = {preset.repo: preset.base for preset in presets} presets = _filter_presets(presets, base=base, repo=repo) sessions = [ session @@ -335,7 +364,9 @@ def _stop(self, args: argparse.Namespace) -> None: def _get(self, args: argparse.Namespace) -> None: self._reconcile() preset = PresetStore().find_by_id_or_name(args.preset) - if preset is None: + if preset is None and "/" not in args.preset: + # A qualified `/` ref never names a creation + # session, only a pulled copy. preset = _get_unfinished_preset(args.preset) if preset is None: raise CLIError(f"Preset {args.preset!r} does not exist") @@ -346,18 +377,20 @@ def _export(self, args: argparse.Namespace) -> None: preset = store.find_by_id_or_name(args.preset) if preset is None: raise CLIError(f"Preset {args.preset!r} does not exist") - written = export_preset( + export_preset( preset, preset_dir=store.root / preset.id, destination=Path(args.destination), force=args.force, name=args.name, ) - console.print( - f"Preset [code]{preset.id}[/] exported to [code]{args.destination}[/]" - f" ({len(written)} files). Deploy it with" - f" [code]dstack apply -f {args.destination}[/]" - ) + console.print("OK") + + def _push(self, args: argparse.Namespace) -> None: + push_preset_to_registry(PresetStore(), args.preset, args.ref) + + def _pull(self, args: argparse.Namespace) -> None: + pull_preset_from_registry(PresetStore(), args.ref) def _delete(self, args: argparse.Namespace) -> None: store = PresetStore() @@ -373,6 +406,11 @@ def _delete(self, args: argparse.Namespace) -> None: if preset is not None: preset_ids = [preset.id] description = f"preset [code]{preset.id}[/] for [code]{preset.base}[/]" + elif "/" in args.preset: + # A qualified ref only ever names a pulled copy; it can + # never be a creation session, so the session fallback + # would just manufacture a confusing error. + raise CLIError(f"Preset {args.preset!r} does not exist") else: preset_ids = [_creation_id(args.preset)] description = f"preset [code]{preset_ids[0]}[/]" @@ -398,7 +436,7 @@ def _delete(self, args: argparse.Namespace) -> None: with suppress(CLIError): remove_agent_workspace(load_preset_session(preset_id)) store.delete(preset_id) - console.print(f"Deleted {description}") + console.print("OK") def _creation_id(ref: str) -> str: @@ -431,7 +469,7 @@ def _check_creation_not_in_use(preset_id: str) -> None: ) -def _get_unfinished_preset(ref: str) -> Optional[Preset]: +def _get_unfinished_preset(ref: str) -> Optional[UnverifiedPreset]: """The preset as its creation session knows it, for any state but verified.""" try: session = load_preset_session(resolve_session_ref(ref)) @@ -440,12 +478,12 @@ def _get_unfinished_preset(ref: str) -> Optional[Preset]: state = session.read_state() if state is None or state.status == "success": return None - return Preset( + return UnverifiedPreset( status=state.status, id=state.id, name=state.name, configuration=load_session_configuration(session), - submitted_at=state.created_at, + created_at=state.created_at, ) @@ -508,15 +546,15 @@ def _add_list_args(parser: argparse.ArgumentParser) -> None: def _filter_presets( - presets: list[VerifiedPreset], + presets: list[AnyStoredPreset], *, base: str | None, repo: str | None, -) -> list[VerifiedPreset]: +) -> list[AnyStoredPreset]: return [ preset for preset in presets - if (base is None or preset.base == base) and (repo is None or preset.model == repo) + if (base is None or preset.base == base) and (repo is None or preset.repo == repo) ] diff --git a/src/dstack/_internal/cli/models/preset_agent.py b/src/dstack/_internal/cli/models/preset_agent.py index 24629d1a4..6bd4e622b 100644 --- a/src/dstack/_internal/cli/models/preset_agent.py +++ b/src/dstack/_internal/cli/models/preset_agent.py @@ -11,9 +11,9 @@ WrapValidator, ) -from dstack._internal.cli.models.presets import PresetBenchmark from dstack._internal.core.models.common import CoreModel from dstack._internal.core.models.configurations import ServiceConfiguration +from dstack._internal.core.models.presets import PresetBenchmark class PresetAgentInvalidService(CoreModel): diff --git a/src/dstack/_internal/cli/models/presets.py b/src/dstack/_internal/cli/models/presets.py index c5ff005d2..75d469d51 100644 --- a/src/dstack/_internal/cli/models/presets.py +++ b/src/dstack/_internal/cli/models/presets.py @@ -1,185 +1,47 @@ -import re from datetime import datetime -from typing import Annotated, Literal, Optional, Union +from typing import List, Literal, Optional, Union -from pydantic import ( - Field, - PositiveFloat, - PositiveInt, - field_validator, - model_validator, -) -from typing_extensions import Self +from pydantic import Field, PositiveInt +from typing_extensions import Annotated from dstack._internal.core.models.common import CoreModel -from dstack._internal.core.models.configurations import ServiceConfiguration -from dstack._internal.core.models.presets import PresetConfiguration -from dstack._internal.core.models.profiles import ProfileParams -from dstack._internal.core.models.resources import Range, ResourcesSpec - -# The service name, the gateway, and the profile parameters are chosen by whoever -# runs `dstack apply` with the preset, so a preset never carries them. -PRESET_EXCLUDED_FIELDS = ("name", "gateway", *ProfileParams.model_fields) - - -class PresetWorkload(CoreModel): - api: Literal["chat_completions", "completions"] - dataset: str - num_requests: PositiveInt - input_tokens: PositiveInt - output_tokens: Annotated[int, Field(ge=2)] - concurrency: PositiveInt - - -class PresetRandomWorkload(PresetWorkload): - dataset: Literal["random"] = "random" - shared_prefix_tokens: Annotated[int, Field(ge=0)] = 0 - - -class PresetBenchmarkLatency(CoreModel): - mean: Annotated[float, Field(ge=0)] - p50: Annotated[float, Field(ge=0)] - p99: Annotated[float, Field(ge=0)] - - -class PresetBenchmarkMetrics(CoreModel): - successful_requests: Annotated[int, Field(ge=0)] - failed_requests: Annotated[int, Field(ge=0)] - duration_seconds: PositiveFloat - total_input_tokens: Annotated[int, Field(ge=0)] - total_output_tokens: Annotated[int, Field(ge=0)] - # Stored as reported, but never read back: `effective_*` recomputes both - # from the totals rather than trusting self-reported rates. - output_tok_per_s: PositiveFloat - per_user_tok_per_s: PositiveFloat - ttft_ms: PresetBenchmarkLatency - tpot_ms: PresetBenchmarkLatency - - -class PresetBenchmark(CoreModel): - """The agent reports its benchmark in exactly this shape, and is forced to by - the schema generated from it. Changing a field here means also changing the - `## Benchmark` section of the system prompt, which tells it what to put there.""" - - tool: Annotated[str, Field(min_length=1)] - tool_version: Annotated[str, Field(min_length=1)] - command: Annotated[str, Field(min_length=1)] - # The subclass first: a report without `dataset` is a random workload, and a - # base-typed field would reject its `shared_prefix_tokens` as unknown. - workload: Union[PresetRandomWorkload, PresetWorkload] - metrics: PresetBenchmarkMetrics - - @property - def effective_output_tok_per_s(self) -> float: - return self.metrics.total_output_tokens / self.metrics.duration_seconds - - @property - def effective_per_user_tok_per_s(self) -> float: - return 1000 / self.metrics.tpot_ms.mean - - @field_validator("command") - @classmethod - def validate_command_has_no_bearer_token(cls, value: str) -> str: - for match in re.finditer(r"(?i)\bbearer\s+([^\s\"']+)", value): - token = match.group(1) - if token.startswith("$") or "redacted" in token.lower() or set(token) == {"*"}: - continue - # Prose such as "auth via bearer header from env" is not a - # credential: only credential-shaped values are rejected. - if len(token) < 16 or not any(char.isdigit() for char in token): - continue - raise ValueError("command must not contain a bearer token value") - return value - - @model_validator(mode="after") - def validate_metrics(self) -> Self: - if self.metrics.failed_requests != 0: - raise ValueError("benchmark must not include failed requests") - if self.metrics.successful_requests != self.workload.num_requests: - raise ValueError("benchmark request count must match workload.num_requests") - return self - - -class PresetVerificationReplicaGroup(CoreModel): - # The service replica group this was measured for. - name: str - # One entry per replica that was running: its actual resources. - replicas: list[ResourcesSpec] - - -class Preset(CoreModel): - status: Literal["running", "interrupted", "failed", "verified"] +from dstack._internal.core.models.configurations import PresetConfiguration +from dstack._internal.core.models.presets import PortablePreset + + +class BasePreset(CoreModel): id: str name: Optional[str] = None + # When the preset was created where it came from: the creation session + # locally, the registry for a pulled one. + created_at: datetime + + +class UnverifiedPreset(BasePreset): + """A preset whose creation has not passed verification: running, + interrupted, or failed.""" + + status: Literal["running", "interrupted", "failed"] configuration: PresetConfiguration - submitted_at: datetime -class VerifiedPreset(Preset): +class VerifiedPreset(BasePreset, PortablePreset): + """A preset whose creation passed verification, with that session attached.""" + status: Literal["verified"] = "verified" - base: Annotated[str, Field(min_length=1)] - model: Annotated[str, Field(min_length=1)] - # The largest context the service was verified to serve. - context_length: PositiveInt + configuration: PresetConfiguration # The session's `trials/` that won verification and became this preset. best_trial: PositiveInt - # The verified run's spec configuration, not the agent's files. The - # validator below keeps `name`, `gateway`, and profile params unset (the - # deployer's choices) and requires `model` and resources. Env keys the - # user declared as passthroughs hold `EnvSentinel` references, not the - # resolved secrets; other env values are stored as-is. `files` paths are - # stored relative to the preset directory, absolute after load. - service: ServiceConfiguration - benchmark: PresetBenchmark - # The hardware it was verified on: the actual resources of every running - # replica, by service replica group. - verified_on: list[PresetVerificationReplicaGroup] - - @model_validator(mode="after") - def validate_preset(self) -> Self: - service = self.service - if service.model is None: - raise ValueError("preset service must specify model") - if any(group.resources is None for group in service.replica_groups): - raise ValueError("preset service must specify resources") - for field in PRESET_EXCLUDED_FIELDS: - if getattr(service, field) is not None: - raise ValueError(f"preset service must not specify {field}") - if [group.name for group in self.verified_on] != [ - group.name for group in service.replica_groups - ]: - raise ValueError("preset verification replica groups must match the service's") - for replica_group in self.verified_on: - if not replica_group.replicas: - raise ValueError("preset verification replica groups must not be empty") - for resources in replica_group.replicas: - _validate_exact_resources(resources) - return self + + +class PulledPreset(BasePreset, PortablePreset): + """A portable preset pulled from the registry.""" + + status: Literal["pulled"] = "pulled" + + +AnyStoredPreset = Annotated[Union[VerifiedPreset, PulledPreset], Field(discriminator="status")] class PresetListOutput(CoreModel): - presets: list[VerifiedPreset] - - -def _validate_exact_resources(resources: ResourcesSpec) -> None: - cpu = resources.cpu - if not _is_exact(cpu.count) or not _is_exact(resources.memory): - raise ValueError("preset verification resources must be exact") - if resources.disk is None or not _is_exact(resources.disk.size): - raise ValueError("preset verification resources must be exact") - gpu = resources.gpu - if gpu is None or not _is_exact(gpu.count): - raise ValueError("preset verification resources must be exact") - if gpu.count.min == 0: - return - if gpu.name is None or len(gpu.name) != 1 or not _is_exact(gpu.memory): - raise ValueError("preset verification resources must be exact") - - -def _is_exact(value: Optional[Range]) -> bool: - return ( - value is not None - and value.min is not None - and value.max is not None - and value.min == value.max - ) + presets: List[AnyStoredPreset] diff --git a/src/dstack/_internal/cli/services/configurators/preset.py b/src/dstack/_internal/cli/services/configurators/preset.py index 620092b0a..9c23b099e 100644 --- a/src/dstack/_internal/cli/services/configurators/preset.py +++ b/src/dstack/_internal/cli/services/configurators/preset.py @@ -25,8 +25,7 @@ ) from dstack._internal.cli.utils.common import confirm_ask, console from dstack._internal.core.errors import CLIError, ConfigurationError, ServerClientError -from dstack._internal.core.models.configurations import ApplyConfigurationType -from dstack._internal.core.models.presets import PresetConfiguration +from dstack._internal.core.models.configurations import ApplyConfigurationType, PresetConfiguration from dstack._internal.core.models.profiles import ProfileParams from dstack._internal.core.services import validate_dstack_resource_name diff --git a/src/dstack/_internal/cli/services/presets/build.py b/src/dstack/_internal/cli/services/presets/build.py index ab695710e..1c3137f7a 100644 --- a/src/dstack/_internal/cli/services/presets/build.py +++ b/src/dstack/_internal/cli/services/presets/build.py @@ -3,16 +3,15 @@ import gpuhunt -from dstack._internal.cli.models.presets import ( +from dstack._internal.cli.models.presets import VerifiedPreset +from dstack._internal.core.models.configurations import PresetConfiguration, ServiceConfiguration +from dstack._internal.core.models.envs import Env +from dstack._internal.core.models.instances import Resources +from dstack._internal.core.models.presets import ( PRESET_EXCLUDED_FIELDS, PresetBenchmark, PresetVerificationReplicaGroup, - VerifiedPreset, ) -from dstack._internal.core.models.configurations import ServiceConfiguration -from dstack._internal.core.models.envs import Env -from dstack._internal.core.models.instances import Resources -from dstack._internal.core.models.presets import PresetConfiguration from dstack._internal.core.models.resources import ( CPUSpec, DiskSpec, @@ -31,14 +30,14 @@ def build_preset( service: ServiceConfiguration, verification_replica_groups: list[PresetVerificationReplicaGroup], base_model: str, - model: str, + repo: str, context_length: int, benchmark: PresetBenchmark, configuration: PresetConfiguration, best_trial: int, preset_id: str, name: Optional[str], - submitted_at: datetime, + created_at: datetime, ) -> VerifiedPreset: service = _without_excluded_fields(service) configuration = _without_excluded_fields(configuration) @@ -48,11 +47,11 @@ def build_preset( name=name, base=base_model, id=preset_id, - model=model, + repo=repo, context_length=context_length, best_trial=best_trial, configuration=configuration, - submitted_at=submitted_at, + created_at=created_at, service=service, benchmark=benchmark, verified_on=verification_replica_groups, diff --git a/src/dstack/_internal/cli/services/presets/create.py b/src/dstack/_internal/cli/services/presets/create.py index 02fbfd92b..f4157f722 100644 --- a/src/dstack/_internal/cli/services/presets/create.py +++ b/src/dstack/_internal/cli/services/presets/create.py @@ -22,7 +22,7 @@ PresetSessionStatus, PresetSessionWorkspace, ) -from dstack._internal.cli.models.presets import Preset +from dstack._internal.cli.models.presets import VerifiedPreset from dstack._internal.cli.services.presets.agent import ( ClaudeAuth, PresetAgentProcessOutput, @@ -73,12 +73,14 @@ from dstack._internal.cli.utils.common import NO_OFFERS_WARNING, confirm_ask, console, warn from dstack._internal.cli.utils.offers import print_offers_table from dstack._internal.core.errors import CLIError, ConfigurationError -from dstack._internal.core.models.configurations import TaskConfiguration +from dstack._internal.core.models.configurations import ( + DEFAULT_DATASET, + PresetConfiguration, + TaskConfiguration, +) from dstack._internal.core.models.envs import Env, EnvSentinel from dstack._internal.core.models.fleets import FleetStatus from dstack._internal.core.models.presets import ( - DEFAULT_DATASET, - PresetConfiguration, PresetConstraints, PresetDatasetConstraints, PresetRandomConstraints, @@ -92,7 +94,7 @@ @dataclass(frozen=True) class PresetCreateResult: - preset: Preset + preset: VerifiedPreset path: Path final_run_id: uuid.UUID final_run_name: str @@ -596,7 +598,7 @@ async def _create_preset( ) env: dict[str, str] = {} report: Optional[PresetAgentSuccess] = None - preset: Optional[Preset] = None + preset: Optional[VerifiedPreset] = None preset_path: Optional[Path] = None creation_succeeded = False interrupted = False @@ -672,7 +674,7 @@ async def _create_preset( session_path=session.path, preset_id=session.preset_id, name=_read_claimed_name(session), - submitted_at=session.created_at, + created_at=session.created_at, ) if contains_redacted_value(preset.model_dump(mode="json"), redacted_values): raise CLIError("Generated preset contains a secret value") @@ -828,7 +830,7 @@ class PresetNameHolders: creation sessions claiming it (excluding the holder preset's own session).""" name: str - preset: Optional[Preset] + preset: Optional[VerifiedPreset] sessions: list[PresetSession] @property diff --git a/src/dstack/_internal/cli/services/presets/export.py b/src/dstack/_internal/cli/services/presets/export.py index 237f5ce59..bf7b638dc 100644 --- a/src/dstack/_internal/cli/services/presets/export.py +++ b/src/dstack/_internal/cli/services/presets/export.py @@ -3,14 +3,14 @@ import yaml -from dstack._internal.cli.models.presets import VerifiedPreset +from dstack._internal.cli.models.presets import AnyStoredPreset from dstack._internal.core.errors import CLIError, ServerClientError from dstack._internal.core.services import validate_dstack_resource_name # TODO: Human-readable service serialization: short syntax, defaults dropped def export_preset( - preset: VerifiedPreset, + preset: AnyStoredPreset, *, preset_dir: Path, destination: Path, @@ -19,13 +19,18 @@ def export_preset( ) -> list[Path]: """Writes the exact dump of the service at `destination`, changing only `name` (from `name` or the preset's name) and the `files` paths; `gateway` - and profile params are unset by `VerifiedPreset`, not stripped here. + and profile params are unset by `PortablePreset`, not stripped here. Files under `preset_dir` are copied next to `destination` at their `preset_dir`-relative paths and `files` is rewritten to match; other files pass through absolute. Fails before any write: invalid name, or existing targets without `force`. Returns written paths.""" if name is None: name = preset.name + if name is not None and "/" in name: + # A pulled copy's local name is the qualified `/`; + # the registry name after the `/` is a valid resource name by + # construction, while the qualified form never is. + name = name.split("/", 1)[1] if name is not None: try: validate_dstack_resource_name(name) diff --git a/src/dstack/_internal/cli/services/presets/output.py b/src/dstack/_internal/cli/services/presets/output.py index 1d86ba5e4..0882d31bc 100644 --- a/src/dstack/_internal/cli/services/presets/output.py +++ b/src/dstack/_internal/cli/services/presets/output.py @@ -1,18 +1,18 @@ from collections import defaultdict from datetime import datetime -from typing import Any, Optional +from typing import Any, Optional, Sequence from rich.table import Table -from dstack._internal.cli.models.presets import ( - VerifiedPreset, -) +from dstack._internal.cli.models.presets import AnyStoredPreset, VerifiedPreset from dstack._internal.cli.utils.common import add_row_from_dict, console -from dstack._internal.core.models.presets import DEFAULT_DATASET +from dstack._internal.core.models.configurations import DEFAULT_DATASET, PresetConfiguration +from dstack._internal.core.models.presets import PresetWorkload from dstack._internal.utils.common import pretty_date, pretty_resources _STATUS_DISPLAY = { - "ready": ("verified", "secondary"), + "verified": ("verified", "secondary"), + "pulled": ("pulled", "secondary"), "running": ("trialing", "bold sea_green3"), "verifying": ("verifying", "bold deep_sky_blue1"), "interrupted": ("interrupted", "secondary"), @@ -83,7 +83,7 @@ def _format_trial_progress(session: Optional[dict[str, Any]], *, in_flight: bool def print_presets( - presets: list[VerifiedPreset], + presets: Sequence[AnyStoredPreset], sessions: Optional[list[dict[str, Any]]] = None, verbose: bool = False, all_presets: bool = False, @@ -98,7 +98,7 @@ def print_presets( def get_presets_table( - presets: list[VerifiedPreset], + presets: Sequence[AnyStoredPreset], sessions: Optional[list[dict[str, Any]]] = None, verbose: bool = False, all_presets: bool = False, @@ -115,11 +115,11 @@ def get_presets_table( table.add_column("", no_wrap=True) table.add_column("STATUS") table.add_column("SUBMITTED", style="secondary") - presets_by_base: dict[str, list[VerifiedPreset]] = defaultdict(list) + presets_by_base: dict[str, list[AnyStoredPreset]] = defaultdict(list) repo_to_base: dict[str, str] = {} for preset in presets: presets_by_base[preset.base].append(preset) - repo_to_base[preset.model] = preset.base + repo_to_base[preset.repo] = preset.base sessions_by_model: dict[str, list[dict[str, Any]]] = defaultdict(list) creations_by_id: dict[str, dict[str, Any]] = {} for session in sessions or []: @@ -134,7 +134,7 @@ def get_presets_table( # so different models interleave); active-only by default, else the latest row. rows: list[tuple[str, Any, bool]] = [] for preset_list in presets_by_base.values(): - rows += [(preset.submitted_at.isoformat(), preset, True) for preset in preset_list] + rows += [(preset.created_at.isoformat(), preset, True) for preset in preset_list] for session_list in sessions_by_model.values(): rows += [ (str(session.get("created_at") or ""), session, False) for session in session_list @@ -248,7 +248,7 @@ def _add_session(table: Table, session: dict[str, Any], *, verbose: bool = False def _add_preset( table: Table, - preset: VerifiedPreset, + preset: AnyStoredPreset, *, verbose: bool, creation: Optional[dict[str, Any]] = None, @@ -259,17 +259,17 @@ def _add_preset( "NAME": preset.name or "", "RESOURCES": _format_resources(groups[0].resources, verbose=verbose), "BASE": preset.base, - "STATUS": _format_status("ready") + _format_trial_progress(creation), + "STATUS": _format_status(preset.status) + _format_trial_progress(creation), "": _format_trial_spark(creation), "CONSTRAINTS": format_preset_objective( preset, verbose=verbose, ), "BENCHMARK": format_preset_benchmark(preset, verbose=verbose), - "SUBMITTED": pretty_date(preset.submitted_at), + "SUBMITTED": pretty_date(preset.created_at), } - if verbose and preset.model != preset.base: - row["BASE"] = f"[secondary] repo={preset.model}[/]" + if verbose and preset.repo != preset.base: + row["BASE"] = f"[secondary] repo={preset.repo}[/]" add_row_from_dict(table, row) if len(groups) > 1: for group in groups: @@ -284,12 +284,34 @@ def _add_preset( def format_preset_objective( - preset: VerifiedPreset, + preset: AnyStoredPreset, *, verbose: bool = False, ) -> str: - configuration = preset.configuration workload = preset.benchmark.workload + if isinstance(preset, VerifiedPreset): + return _format_creation_objective(preset.configuration, workload, verbose=verbose) + # A pulled preset carries no creation context; the measured workload is its + # honest record of the conditions the numbers hold for. + parts = [] + if workload.dataset != DEFAULT_DATASET: + parts.append(f"data={workload.dataset}") + else: + parts.append( + f"io={_format_token_count(workload.input_tokens)}" + f"/{_format_token_count(workload.output_tokens)}" + ) + shared_prefix_tokens = getattr(workload, "shared_prefix_tokens", 0) + share = round(100 * shared_prefix_tokens / workload.input_tokens) + if share: + parts.append(f"prefix={share}%") + parts.append(f"c={workload.concurrency}") + return f"[secondary]{' '.join(parts)}[/]" + + +def _format_creation_objective( + configuration: PresetConfiguration, workload: PresetWorkload, *, verbose: bool +) -> str: parts = [] if configuration.effective_dataset != DEFAULT_DATASET: parts.append(f"data={configuration.effective_dataset}") @@ -310,7 +332,10 @@ def format_preset_objective( return f"[secondary]{' '.join(parts)}[/]" -def _breaches_constraints(preset: VerifiedPreset) -> bool: +def _breaches_constraints(preset: AnyStoredPreset) -> bool: + if not isinstance(preset, VerifiedPreset): + # A pulled preset carries no requested constraints to breach. + return False configuration = preset.configuration metrics = preset.benchmark.metrics if configuration.max_ttft is not None and metrics.ttft_ms.p50 > configuration.max_ttft: @@ -320,7 +345,7 @@ def _breaches_constraints(preset: VerifiedPreset) -> bool: ) -def format_preset_benchmark(preset: VerifiedPreset, *, verbose: bool = False) -> str: +def format_preset_benchmark(preset: AnyStoredPreset, *, verbose: bool = False) -> str: benchmark = preset.benchmark metrics = benchmark.metrics parts = [ diff --git a/src/dstack/_internal/cli/services/presets/registry.py b/src/dstack/_internal/cli/services/presets/registry.py new file mode 100644 index 000000000..1f1d4bbf6 --- /dev/null +++ b/src/dstack/_internal/cli/services/presets/registry.py @@ -0,0 +1,291 @@ +import io +import os +import shutil +import tarfile +import tempfile +import uuid +from pathlib import Path, PurePosixPath + +from dstack._internal.cli.models.presets import PulledPreset +from dstack._internal.cli.services.presets.store import PresetStore +from dstack._internal.cli.utils.common import console, error_console +from dstack._internal.core.errors import ( + CLIError, + MethodNotAllowedError, + ResourceNotExistsError, + ServerClientError, + URLNotFoundError, +) +from dstack._internal.core.models.presets import ( + PortablePreset, + PresetArchiveMapping, + PresetSpec, + validate_preset_spec_files, + validate_preset_spec_limits, +) +from dstack._internal.core.services import validate_dstack_resource_name +from dstack._internal.core.services.configs import ConfigManager +from dstack._internal.utils.files import create_file_archive +from dstack.api.server import APIClient + +SKY_BASE_URL = "https://sky.dstack.ai" + +# The kill switch for the Sky fallback: with it set, only projects from the +# local config resolve. +NO_SKY_FALLBACK_ENV = "DSTACK_NO_SKY_FALLBACK" + + +def parse_registry_ref(ref: str) -> tuple[str, str]: + """Splits `/` on the first `/`. A ref without `/` is not a + registry ref; callers decide local vs. registry by its presence.""" + project, separator, rest = ref.partition("/") + if not separator or not project or not rest: + # CLIError text is escaped when printed, so backticks, not rich markup. + raise CLIError(f"Invalid registry reference {ref!r}: expected `/`") + return project, rest + + +def resolve_registry_client(project: str) -> APIClient: + """The server hosting the registry a ref points at: the config entry with + the project's name, or + Sky with any configured Sky token (dstack tokens are user tokens, so one Sky + entry authenticates any Sky project the user is a member of).""" + projects = ConfigManager().list_project_configs() + for entry in projects: + if entry.name == project: + return APIClient(base_url=entry.url, token=entry.token) + if not os.getenv(NO_SKY_FALLBACK_ENV): + for entry in projects: + if entry.url.rstrip("/") == SKY_BASE_URL: + # The one surprising resolution — a project the config does not + # know going to a remote default — is the one worth announcing. + _print_registry_server(SKY_BASE_URL, project) + return APIClient(base_url=SKY_BASE_URL, token=entry.token) + raise CLIError( + f"No server is configured for project {project!r}. Log in with `dstack project add`" + ) + + +def push_preset_to_registry(store: PresetStore, local_ref: str, registry_ref: str) -> None: + project, name = parse_registry_ref(registry_ref) + _validate_registry_name(name) + preset = store.find_by_id_or_name(local_ref) + if preset is None: + raise CLIError(f"Preset {local_ref!r} does not exist") + if preset.service.registry_auth is not None: + raise CLIError( + "The preset service contains registry_auth credentials and cannot be pushed." + " Remove them from the preset; deployers supply their own registry credentials" + " at apply time" + ) + preset_dir = store.root / preset.id + # The pushed document is the portable preset only: no identity, no + # creation-session context. + artifact = PortablePreset( + **{field: getattr(preset, field) for field in PortablePreset.model_fields} + ).model_copy(deep=True) + sources: dict[str, str] = {} + for mapping in artifact.service.files: + relative = _relative_pushed_path(mapping.local_path, preset_dir) + sources.setdefault(relative, mapping.local_path) + mapping.local_path = relative + client = resolve_registry_client(project) + # File contents travel as file archives, the same mechanism run `files` + # use: content-addressed, deduplicated per user, off-loaded to blob storage + # where the server has one. + spec = PresetSpec( + preset=artifact, + file_archives=[ + PresetArchiveMapping(id=_upload_archive(client, local_path), path=relative) + for relative, local_path in sources.items() + ], + ) + # The same rules the server enforces, checked here to fail before pushing. + # The spec is only whole once the archives are uploaded, and the size limit + # must measure the object the server measures. + try: + validate_preset_spec_files(spec) + validate_preset_spec_limits(spec) + except ValueError as e: + raise CLIError(f"Preset {local_ref!r} cannot be pushed: {e}") from e + try: + client.presets.push(project, name=name, spec=spec) + except (URLNotFoundError, MethodNotAllowedError): + raise _registry_not_supported_error(project, client) + console.print("OK") + + +def pull_preset_from_registry(store: PresetStore, registry_ref: str) -> None: + project, name_or_id = parse_registry_ref(registry_ref) + client = resolve_registry_client(project) + try: + remote = client.presets.get(project, name_or_id) + except (URLNotFoundError, MethodNotAllowedError): + raise _registry_not_supported_error(project, client) + except ResourceNotExistsError as e: + # The server's detail names the bare ref; the qualified one reads better. + raise CLIError(f"Preset {registry_ref!r} does not exist") from e + # `remote.spec` already validated with `extra="ignore"` by the API client, so + # a preset written by a newer server pulls into an older client unchanged. + portable = remote.spec.preset + # The local identity of a pulled preset is its registry id, so re-pulling + # the same preset overwrites its own copy in place. + preset_id = str(remote.id) + file_archives = remote.spec.file_archives + # The same rules the server enforces on push, re-checked before anything is + # written locally: relative POSIX, no traversal, no reserved names, no + # file/directory conflicts, and files and references matching exactly. + try: + validate_preset_spec_files(remote.spec) + except ValueError as e: + raise CLIError(f"Preset {registry_ref!r} cannot be pulled: {e}") from e + # The local name is the qualified ref. It can never collide with a locally + # created preset (local names cannot contain `/`), so the only possible + # holder is an earlier pull; the name silently moves to the fresh copy, + # Docker-style. A non-current preset (pulled by id after the name was + # repointed) must not take the name from the current one — like a Docker + # pull by digest, it lands untagged. + qualified_name = f"{project}/{remote.name}" + local_name = qualified_name if remote.is_current else None + holder = store.find_by_name(qualified_name) + if local_name is not None: + if holder is not None and holder.id != preset_id: + store.release_name(qualified_name) + elif holder is not None and holder.id == preset_id: + # This copy holds the name from an earlier pull, when it was current. + # Re-pulling it by id must not strip the name off the local store + # entirely, or local refs to it would stop resolving. + local_name = qualified_name + pulled = PulledPreset( + id=preset_id, + name=local_name, + # A pulled preset is dated by the registry it came from. + created_at=remote.created_at, + **{field: getattr(portable, field) for field in PortablePreset.model_fields}, + ) + directory = store.root / preset_id + saved = False + try: + # Re-pulling replaces the copy wholesale: a file the preset no longer + # carries must not survive into the next push. + shutil.rmtree(directory, ignore_errors=True) + directory.mkdir(parents=True) + for mapping in file_archives: + # Downloaded by id, not by the pulled ref: a name may repoint + # between requests, and the files must be this preset's. + blob = client.presets.get_file(project, preset_id, mapping.path) + _extract_archive(blob, directory / PurePosixPath(mapping.path)) + # Absolute paths under the preset directory, as after a load; save + # re-relativizes them so the stored file stays portable. + for mapping in pulled.service.files: + mapping.local_path = str(directory / mapping.local_path) + store.save(pulled) + saved = True + except (OSError, tarfile.TarError) as e: + raise CLIError(f"Failed to save preset {registry_ref!r}: {e}") from e + finally: + # Any failure - a download error, a rejected archive member, an + # interrupt - must not leave a directory without its preset document. + if not saved: + shutil.rmtree(directory, ignore_errors=True) + if local_name is not None: + console.print("OK") + else: + console.print( + f"Pulled [code]{preset_id}[/]; [code]{qualified_name}[/] now names a newer preset" + ) + + +def _upload_archive(client: APIClient, local_path: str) -> uuid.UUID: + with tempfile.TemporaryFile("w+b") as fp: + try: + archive_hash = create_file_archive(local_path, fp) + except (OSError, ValueError) as e: + raise CLIError(f"Failed to archive preset file {local_path}: {e}") from e + fp.seek(0) + archive = client.files.upload_archive(hash=archive_hash, fp=fp) + return archive.id + + +def _extract_archive(blob: bytes, target: Path) -> None: + """Extracts an archive produced by `create_file_archive` — its members are + rooted at the archived path's basename — so the file or directory + materializes exactly at `target`. + + The server stores archives as opaque blobs, so their members are untrusted + input from whoever pushed the preset: every member is checked here rather + than relying on the extraction filter, which older Pythons do not have.""" + target.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(fileobj=io.BytesIO(blob)) as archive: + members = archive.getmembers() + for member in members: + _check_archive_member(member, target) + try: + archive.extractall(target.parent, members=members, filter="data") + except TypeError: + # Python < 3.10.12 / < 3.11.4 has no extraction filter; the member + # checks above are what make this safe. + archive.extractall(target.parent, members=members) # nosec B202 + + +def _check_archive_member(member: tarfile.TarInfo, target: Path) -> None: + """Rejects anything that could write outside `target`: a member the archive + should not carry, a traversing or absolute path, or a link of any kind.""" + + def reject(reason: str) -> CLIError: + return CLIError( + f"Preset file archive for {target.name!r} carries {reason}: {member.name!r}" + ) + + if not (member.isfile() or member.isdir()): + # Symlinks and hardlinks can point outside the directory, and their + # targets are followed by later writes; devices and fifos are never + # part of a preset. + raise reject("an unsupported member type") + name = member.name.replace("\\", "/") + parts = PurePosixPath(name).parts + if PurePosixPath(name).is_absolute() or ".." in parts or not parts: + raise reject("an unsafe member path") + if parts[0] != target.name: + raise reject("an unexpected member") + + +def _print_registry_server(url: str, project: str) -> None: + # stderr, so `--json` output stays parseable. + error_console.print(f"Using [code]{url}[/] for [code]{project}[/]") + + +def _registry_not_supported_error(project: str, client: APIClient) -> CLIError: + # Naming the server matters: the likely cause is a ref pointing at a project + # configured against a server that has no registry. + return CLIError( + f"The server at {client.base_url} (project {project!r})" + " does not support the preset registry" + ) + + +def _relative_pushed_path(local_path: str, preset_dir: Path) -> str: + path = Path(local_path) + for base in (preset_dir, preset_dir.resolve()): + try: + return path.relative_to(base).as_posix() + except ValueError: + continue + raise CLIError( + f"Preset file {local_path} is outside the preset directory and cannot be pushed." + " Move it under the preset directory first" + ) + + +def _validate_registry_name(name: str) -> None: + """The same rules the server enforces, checked before uploading: a valid + resource name that id-first ref resolution can never mistake for an id.""" + try: + validate_dstack_resource_name(name) + except ServerClientError as e: + raise CLIError(str(e)) from e + try: + uuid.UUID(name) + except ValueError: + return + raise CLIError("Preset name must not be a UUID") diff --git a/src/dstack/_internal/cli/services/presets/session.py b/src/dstack/_internal/cli/services/presets/session.py index 6c0588919..68bd44446 100644 --- a/src/dstack/_internal/cli/services/presets/session.py +++ b/src/dstack/_internal/cli/services/presets/session.py @@ -29,7 +29,7 @@ from dstack._internal.compat import IS_WINDOWS from dstack._internal.core.errors import CLIError from dstack._internal.core.models.common import validate_extra_ignore -from dstack._internal.core.models.presets import PresetConfiguration +from dstack._internal.core.models.configurations import PresetConfiguration from dstack._internal.utils.common import get_dstack_dir if TYPE_CHECKING: diff --git a/src/dstack/_internal/cli/services/presets/store.py b/src/dstack/_internal/cli/services/presets/store.py index 25e01e004..21f262b16 100644 --- a/src/dstack/_internal/cli/services/presets/store.py +++ b/src/dstack/_internal/cli/services/presets/store.py @@ -2,20 +2,21 @@ import shutil import tempfile from pathlib import Path -from typing import TextIO +from typing import Any, TextIO import yaml -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError -from dstack._internal.cli.models.presets import PRESET_EXCLUDED_FIELDS, VerifiedPreset +from dstack._internal.cli.models.presets import AnyStoredPreset from dstack._internal.cli.utils.common import warn from dstack._internal.core.errors import CLIError, ConfigurationError -from dstack._internal.core.models.configurations import ServiceConfiguration -from dstack._internal.core.models.presets import ( +from dstack._internal.core.models.configurations import ( MAX_PROMPT_LENGTH, PresetConfiguration, PresetPromptFile, + ServiceConfiguration, ) +from dstack._internal.core.models.presets import PRESET_EXCLUDED_FIELDS from dstack._internal.utils.common import get_dstack_dir @@ -24,13 +25,18 @@ class EarlierVersionPresetError(CLIError): record the winning trial.""" +# `status` tags which shape a stored document is: `verified` for a local +# creation, `pulled` for a copy from the registry. +_STORED_PRESET_ADAPTER: TypeAdapter = TypeAdapter(AnyStoredPreset) + + class PresetStore: """One `//preset.yml` per preset.""" def __init__(self, root: Path | None = None) -> None: self.root = root or get_dstack_dir() / "presets" - def list(self) -> list[VerifiedPreset]: + def list(self) -> list[AnyStoredPreset]: if not self.root.exists(): return [] presets = [] @@ -60,7 +66,7 @@ def list(self) -> list[VerifiedPreset]: ) return sorted(presets, key=lambda preset: (preset.base.lower(), preset.id)) - def get(self, preset_id: str) -> VerifiedPreset | None: + def get(self, preset_id: str) -> AnyStoredPreset | None: _validate_preset_id(preset_id) if not self.root.exists(): return None @@ -72,7 +78,7 @@ def get(self, preset_id: str) -> VerifiedPreset | None: raise CLIError(f"Preset file {path} does not match its path") return preset - def save(self, preset: VerifiedPreset) -> Path: + def save(self, preset: AnyStoredPreset) -> Path: _validate_preset_id(preset.id) directory = self.root / preset.id directory.mkdir(parents=True, exist_ok=True) @@ -102,16 +108,21 @@ def save(self, preset: VerifiedPreset) -> Path: pass return path - def find_by_name(self, name: str) -> VerifiedPreset | None: + def find_by_name(self, name: str) -> AnyStoredPreset | None: for preset in self.list(): if preset.name == name: return preset return None - def find_by_id_or_name(self, ref: str) -> VerifiedPreset | None: - return self.get(ref) or self.find_by_name(ref) + def find_by_id_or_name(self, ref: str) -> AnyStoredPreset | None: + # Only an id-shaped ref is looked up as an id, so a name that could + # never be one — e.g. a pulled copy's qualified `/` — + # reaches the name lookup instead of failing id validation. A corrupt + # preset file for a valid id still raises (deletion relies on that). + preset = self.get(ref) if _is_valid_preset_id(ref) else None + return preset or self.find_by_name(ref) - def release_name(self, name: str) -> VerifiedPreset | None: + def release_name(self, name: str) -> AnyStoredPreset | None: preset = self.find_by_name(name) if preset is None: return None @@ -131,7 +142,7 @@ def delete(self, preset_id: str) -> bool: shutil.rmtree(directory) return True - def _load(self, path: Path) -> VerifiedPreset: + def _load(self, path: Path) -> AnyStoredPreset: data = None try: with path.open(encoding="utf-8") as f: @@ -141,7 +152,10 @@ def _load(self, path: Path) -> VerifiedPreset: # stores `verified_on`. if isinstance(data, dict) and "validations" in data: upgraded = _upgrade_pre_0_21_2_preset(data, preset_id=path.parent.name) - preset = VerifiedPreset.model_validate(upgraded) + upgraded = _upgrade_model_field(upgraded) + upgraded = _upgrade_submitted_at(upgraded) + upgraded = _upgrade_untagged_preset(upgraded) + preset = _STORED_PRESET_ADAPTER.validate_python(upgraded) except (OSError, ValidationError, yaml.YAMLError) as e: if isinstance(data, dict) and "validations" in data: raise _earlier_version_preset_error(path.parent.name) from e @@ -155,6 +169,39 @@ def _load(self, path: Path) -> VerifiedPreset: return preset +# TODO: Remove in 0.22 +def _upgrade_submitted_at(data: Any) -> Any: + """Presets written before the date was named for what it is store it as + `submitted_at`.""" + if not isinstance(data, dict) or "submitted_at" not in data or "created_at" in data: + return data + data = dict(data) + data["created_at"] = data.pop("submitted_at") + return data + + +# TODO: Remove in 0.22 +def _upgrade_model_field(data: Any) -> Any: + """Presets written before the field was renamed store the served repo as + `model`, which now names nothing (the client-facing name is `service.model`).""" + if not isinstance(data, dict) or "model" not in data or "repo" in data: + return data + data = dict(data) + data["repo"] = data.pop("model") + return data + + +# TODO: Remove in 0.22 +def _upgrade_untagged_preset(data: Any) -> Any: + """A preset file written before `status` tagged the stored union carries no + `status`, so the tag is inferred from the shape: only a local creation has + the creation context, a pulled copy is the bare artifact.""" + if not isinstance(data, dict) or "status" in data: + return data + status = "verified" if "configuration" in data else "pulled" + return {**data, "status": status} + + # TODO: Remove in 0.22 def _upgrade_pre_0_21_2_preset(data: dict, *, preset_id: str) -> dict: """A preset file from before 0.21.2 stored the service as `service`, the @@ -208,12 +255,13 @@ def _upgrade_pre_0_21_2_preset(data: dict, *, preset_id: str) -> dict: "name": data.get("name"), "configuration": configuration, "base": data["base"], - "model": data["model"], + # The old format called the exact loaded repo `model`. + "repo": data["model"], "context_length": data["context_length"], "best_trial": data["trial"], # The old format stamped this at save time; the closest fact it # recorded for the submission moment. - "submitted_at": data["created_at"], + "created_at": data["created_at"], "service": service, "benchmark": benchmark, "verified_on": verified_on, @@ -241,10 +289,18 @@ def _relative_to_preset_dir(local_path: str, directory: Path) -> str: return local_path -def _validate_preset_id(preset_id: str) -> None: +def _is_valid_preset_id(preset_id: str) -> bool: # `:` is rejected so a Windows drive-relative reference (`D:x`) cannot name a # directory outside the store, or another one inside it. - if not preset_id or preset_id.startswith(".") or any(char in preset_id for char in "/\\:"): + return bool( + preset_id + and not preset_id.startswith(".") + and not any(char in preset_id for char in "/\\:") + ) + + +def _validate_preset_id(preset_id: str) -> None: + if not _is_valid_preset_id(preset_id): raise CLIError(f"Invalid preset ID: {preset_id!r}") diff --git a/src/dstack/_internal/cli/services/presets/verify.py b/src/dstack/_internal/cli/services/presets/verify.py index 3a763577b..5ca56c670 100644 --- a/src/dstack/_internal/cli/services/presets/verify.py +++ b/src/dstack/_internal/cli/services/presets/verify.py @@ -9,10 +9,7 @@ AnyPresetAgentResult, PresetAgentSuccess, ) -from dstack._internal.cli.models.presets import ( - PresetVerificationReplicaGroup, - VerifiedPreset, -) +from dstack._internal.cli.models.presets import VerifiedPreset from dstack._internal.cli.services.presets.agent import ( PresetAgentProcessOutput, ) @@ -28,9 +25,9 @@ PresetAgentWorkspace, ) from dstack._internal.core.errors import CLIError -from dstack._internal.core.models.configurations import ServiceConfiguration +from dstack._internal.core.models.configurations import PresetConfiguration, ServiceConfiguration from dstack._internal.core.models.envs import EnvSentinel -from dstack._internal.core.models.presets import PresetConfiguration +from dstack._internal.core.models.presets import PresetVerificationReplicaGroup from dstack._internal.core.models.runs import JobStatus, Run, RunStatus @@ -87,7 +84,7 @@ def build_verified_preset( session_path: Path, preset_id: str, name: Optional[str], - submitted_at: datetime, + created_at: datetime, ) -> VerifiedPreset: """Cross-checks the agent's self-reported final report against the actual run and service state before trusting it to build a preset. The preset's service is @@ -108,13 +105,16 @@ def build_verified_preset( ), verification_replica_groups=_get_verification_replica_groups(run, service), base_model=report.base, - model=report.model, + # The agent reports the served repo as `model`, the name the system + # prompt has always used; the preset document calls it `repo`, so that + # it cannot be confused with the service's client-facing model name. + repo=report.model, context_length=report.context_length, benchmark=report.benchmark, best_trial=report.trial, configuration=preset_configuration, preset_id=preset_id, - submitted_at=submitted_at, + created_at=created_at, ) diff --git a/src/dstack/_internal/core/models/configurations.py b/src/dstack/_internal/core/models/configurations.py index 61f7dd3a8..4378849bc 100644 --- a/src/dstack/_internal/core/models/configurations.py +++ b/src/dstack/_internal/core/models/configurations.py @@ -10,6 +10,7 @@ ConfigDict, Field, GetCoreSchemaHandler, + PositiveInt, RootModel, SerializerFunctionWrapHandler, ValidationError, @@ -36,7 +37,6 @@ from dstack._internal.core.models.files import FilePathMapping from dstack._internal.core.models.fleets import FleetConfiguration from dstack._internal.core.models.gateways import GatewayConfiguration -from dstack._internal.core.models.presets import PresetConfiguration from dstack._internal.core.models.profiles import ( ProfileParams, SpotPolicy, @@ -1587,6 +1587,343 @@ def replica_groups(self) -> List[ReplicaGroup]: ] +# Preset configurations + + +DEFAULT_INPUT_TOKENS = 1024 +DEFAULT_OUTPUT_TOKENS = 1024 +DEFAULT_BASELINE = True +DEFAULT_DATASET = "random" + + +class PresetModelRepo(CoreModel): + repo: Annotated[str, Field(description="The exact model repo or path to deploy")] + name: Annotated[ + Optional[str], Field(description="The client-facing model name. Defaults to `repo`") + ] = None + + @property + def api_model_name(self) -> str: + return self.name or self.repo + + @property + def exact_repo(self) -> str: + return self.repo + + @property + def allows_variant_selection(self) -> bool: + return False + + @field_validator("repo") + @classmethod + def validate_repo(cls, value: str) -> str: + return _validate_model(value, field="repo") + + @field_validator("name") + @classmethod + def validate_name(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + return _validate_model(value, field="name") + + +class PresetModelBase(CoreModel): + base: Annotated[ + str, + Field(description="The base model for which the agent may select a compatible variant"), + ] + + @property + def api_model_name(self) -> str: + return self.base + + @property + def exact_repo(self) -> None: + return None + + @property + def allows_variant_selection(self) -> bool: + return True + + @field_validator("base") + @classmethod + def validate_base(cls, value: str) -> str: + return _validate_model(value, field="base") + + +PresetModelSpec = Union[PresetModelRepo, PresetModelBase] + +MAX_PROMPT_LENGTH = 10_000 + + +class PresetPromptFile(CoreModel): + path: Annotated[ + str, + Field(description="The path to a prompt file, relative to the configuration file"), + ] + + @field_validator("path") + @classmethod + def validate_path(cls, value: str) -> str: + if not value.strip(): + raise ValueError("Prompt path must be a non-empty string") + return value + + +def _drop_model_from_required(schema: dict) -> None: + # `model` is synthesized from the top-level `base`/`repo` shorthand by a + # before-validator, which JSON Schema consumers never run. + required = [field for field in schema.get("required", []) if field != "model"] + if required: + schema["required"] = required + else: + schema.pop("required", None) + + +class PresetConfiguration( + ProfileParams, +): + model_config = ConfigDict(json_schema_extra=_drop_model_from_required) + + type: Annotated[Literal["preset"], Field(description="The configuration type")] = "preset" + # TODO: Generate a random name when omitted, like runs and fleets do + name: Annotated[ + Optional[str], + Field(description="The preset name"), + ] = None + model: Annotated[ + PresetModelSpec, + Field( + description=( + "The model to serve. Use a string or `repo` for an exact repo/path, " + "or `base` to allow compatible model variants. " + "Prefer the top-level `base`/`repo` shorthand unless a custom " + "client-facing model name is needed" + ) + ), + ] + base: Annotated[ + Optional[str], + Field( + description=( + "The base model repo; compatible variants are allowed. Shorthand for `model.base`" + ) + ), + ] = None + repo: Annotated[ + Optional[str], + Field(description="The exact model repo/path to serve. Shorthand for `model.repo`"), + ] = None + prompt: Annotated[ + Optional[Union[str, PresetPromptFile]], + Field( + description=( + "Additional instructions for the preset creation agent, inline or as a file `path`" + ) + ), + ] = None + min_context_length: Annotated[ + Optional[PositiveInt], + Field(description="The minimum required context length. Required for creation"), + ] = None + max_ttft: Annotated[ + Optional[PositiveInt], + Field( + description=( + "The maximum p50 time to first token, in milliseconds, that any benchmark" + " may report. Required for creation" + ) + ), + ] = None + trials: Annotated[ + Optional[PositiveInt], + Field( + description=( + "The number of benchmarked trials during preset creation" + " before the best one is promoted. Required for creation" + ) + ), + ] = None + previous: Annotated[ + Optional[list[str]], + Field( + description=( + "The IDs of previous presets whose creation results the agent" + " analyzes and improves on" + ) + ), + ] = None + concurrency: Annotated[ + Optional[PositiveInt], + Field( + description=( + "The number of simultaneous requests used for benchmarks during preset" + " creation. Required for creation" + ) + ), + ] = None + input_tokens: Annotated[ + Optional[PositiveInt], + Field( + description=( + "The number of input tokens per request used for benchmarks during" + f" preset creation. Defaults to `{DEFAULT_INPUT_TOKENS}`" + ) + ), + ] = None + output_tokens: Annotated[ + Optional[Annotated[int, Field(ge=2)]], + Field( + description=( + "The number of output tokens per request used for benchmarks during" + f" preset creation. Defaults to `{DEFAULT_OUTPUT_TOKENS}`" + ) + ), + ] = None + shared_prefix_tokens: Annotated[ + Optional[Annotated[int, Field(ge=0)]], + Field( + description=( + "How many of `input_tokens` are a prefix identical in every benchmark request," + " as a repeated system prompt or conversation history would be. Defaults to `0`," + " meaning every request is fully unique" + ) + ), + ] = None + dataset: Annotated[ + Optional[str], + Field( + description=( + "The benchmark dataset used during preset creation: `random` for synthetic" + " prompts shaped by `input_tokens` and `output_tokens`, a benchmark tool's" + " dataset name (e.g. `sharegpt`, `spec_bench`), or a Hugging Face dataset ID." + " Defaults to `random`" + ) + ), + ] = None + baseline: Annotated[ + Optional[bool], + Field( + description=( + "Whether the first trial must be a baseline that serves the model with the" + " serving framework's recommended defaults instead of an optimization attempt." + " Defaults to `true`" + ) + ), + ] = None + gateway: Annotated[ + Optional[Union[bool, EntityReference, str]], + Field( + union_mode="left_to_right", # preserving pydantic v1 parsing behavior + description=( + "The name of the gateway. Specify boolean `false` to run without a gateway." + " Specify boolean `true` to run with the default gateway." + " Omit to run with the default gateway if there is one, or without a gateway otherwise" + ), + ), + ] = None + env: Annotated[Env, Field(description="The mapping or the list of environment variables")] = ( + Env() + ) + + @property + def effective_input_tokens(self) -> int: + return self.input_tokens if self.input_tokens is not None else DEFAULT_INPUT_TOKENS + + @property + def effective_output_tokens(self) -> int: + return self.output_tokens if self.output_tokens is not None else DEFAULT_OUTPUT_TOKENS + + @property + def effective_baseline(self) -> bool: + return self.baseline if self.baseline is not None else DEFAULT_BASELINE + + @property + def effective_dataset(self) -> str: + return self.dataset if self.dataset is not None else DEFAULT_DATASET + + @field_validator("dataset") + @classmethod + def validate_dataset_name(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + # Stripped because the agent reports the dataset it actually loaded, and + # the two are compared for equality when the preset is verified. + value = value.strip() + if not value: + raise ValueError("dataset must be a non-empty string") + return value + + @model_validator(mode="after") + def validate_dataset(self) -> Self: + if self.dataset in (None, DEFAULT_DATASET): + return self + set_fields = [ + name + for name in ("input_tokens", "output_tokens", "shared_prefix_tokens") + if getattr(self, name) is not None + ] + if set_fields: + raise ValueError( + f"{', '.join(set_fields)} can only be set with the `random` dataset;" + " a custom dataset defines its own request shape" + ) + return self + + @model_validator(mode="after") + def validate_shared_prefix_tokens(self) -> Self: + # The prefix is carved out of the request, so something has to be left + # to differ between requests. + if self.shared_prefix_tokens is None: + return self + input_tokens = self.input_tokens or DEFAULT_INPUT_TOKENS + if self.shared_prefix_tokens >= input_tokens: + raise ValueError( + f"shared_prefix_tokens must be less than input_tokens ({input_tokens})" + ) + return self + + @model_validator(mode="before") + @classmethod + def apply_model_shorthand(cls, values: Any) -> Any: + if not isinstance(values, dict): + return values + base, repo = values.get("base"), values.get("repo") + if base and repo: + raise ValueError("`base` and `repo` are mutually exclusive") + if base or repo: + if values.get("model") is not None: + raise ValueError("`model` cannot be combined with the `base`/`repo` shorthand") + values = dict(values) + values.pop("base", None) + values.pop("repo", None) + values["model"] = {"base": base} if base else {"repo": repo} + return values + + @field_validator("model", mode="before", json_schema_input_type=Union[PresetModelSpec, str]) + @classmethod + def parse_model(cls, value: Any) -> Any: + if isinstance(value, str): + return {"repo": _validate_model(value, field="model")} + return value + + @field_validator("prompt") + @classmethod + def validate_prompt(cls, value: Any) -> Any: + if isinstance(value, str): + if not value.strip(): + raise ValueError("Prompt must be a non-empty string") + if len(value) > MAX_PROMPT_LENGTH: + raise ValueError(f"Prompt must be at most {MAX_PROMPT_LENGTH} characters") + return value + + +def _validate_model(value: Any, *, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Preset model {field} must be a non-empty string") + return value + + AnyRunConfiguration = Union[DevEnvironmentConfiguration, TaskConfiguration, ServiceConfiguration] diff --git a/src/dstack/_internal/core/models/presets.py b/src/dstack/_internal/core/models/presets.py index 53cbed34e..6e578281d 100644 --- a/src/dstack/_internal/core/models/presets.py +++ b/src/dstack/_internal/core/models/presets.py @@ -1,347 +1,295 @@ -from typing import Annotated, Any, Literal, Optional, Union - -from pydantic import ( - ConfigDict, - Field, - PositiveInt, - field_validator, - model_validator, -) -from typing_extensions import Self +import re +from typing import Any, List, Literal, Optional, Sequence, Union +from uuid import UUID + +from pydantic import Field, PositiveFloat, PositiveInt, field_validator, model_validator +from typing_extensions import Annotated, Self -from dstack._internal.core.models.common import ( - CoreModel, - EntityReference, +from dstack._internal.core.models.common import CoreModel +from dstack._internal.core.models.configurations import ( + PresetModelSpec, + ServiceConfiguration, ) -from dstack._internal.core.models.envs import Env from dstack._internal.core.models.profiles import ProfileParams +from dstack._internal.core.models.resources import Range, ResourcesSpec -DEFAULT_INPUT_TOKENS = 1024 -DEFAULT_OUTPUT_TOKENS = 1024 -DEFAULT_BASELINE = True -DEFAULT_DATASET = "random" +# These models cannot live in `core/models/presets.py`: `core/models/configurations.py` +# imports `PresetConfiguration` from it, so importing `ServiceConfiguration` back would +# be a cycle. Nothing imports this sibling module from `configurations.py`. +# Enforced by the server; the client checks before pushing to fail fast. File +# contents travel as file archives (the same mechanism run `files` use), so +# their sizes are governed by the files service, not here. +MAX_PRESET_SPEC_SIZE = 1 * 1024 * 1024 +MAX_PRESET_FILES = 100 -class PresetModelRepo(CoreModel): - repo: Annotated[str, Field(description="The exact model repo or path to deploy")] - name: Annotated[ - Optional[str], Field(description="The client-facing model name. Defaults to `repo`") - ] = None +# The service name, the gateway, and the profile parameters are chosen by whoever +# runs `dstack apply` with the preset, so a preset never carries them. +PRESET_EXCLUDED_FIELDS = ("name", "gateway", *ProfileParams.model_fields) - @property - def api_model_name(self) -> str: - return self.name or self.repo +# The local store keeps the preset document at this path inside the preset +# directory, so a pushed file must never claim it. +_RESERVED_PRESET_FILE_PATHS = frozenset({"preset.yml"}) - @property - def exact_repo(self) -> str: - return self.repo - @property - def allows_variant_selection(self) -> bool: - return False +class PresetWorkload(CoreModel): + # A Literal, not a str: the agent-facing JSON schema is generated from this + # model, so the allowed values must be part of it. + api: Literal["chat_completions", "completions"] + dataset: str + num_requests: PositiveInt + input_tokens: PositiveInt + output_tokens: Annotated[int, Field(ge=2)] + concurrency: PositiveInt - @field_validator("repo") - @classmethod - def validate_repo(cls, value: str) -> str: - return _validate_model(value, field="repo") - @field_validator("name") - @classmethod - def validate_name(cls, value: Optional[str]) -> Optional[str]: - if value is None: - return None - return _validate_model(value, field="name") +class PresetRandomWorkload(PresetWorkload): + # A Literal, not a defaulted str: `PresetBenchmark.workload` is a union with + # the base model, and only a literal `dataset` discriminates it. + dataset: Literal["random"] = "random" + shared_prefix_tokens: Annotated[int, Field(ge=0)] = 0 -class PresetModelBase(CoreModel): - base: Annotated[ - str, - Field(description="The base model for which the agent may select a compatible variant"), - ] +class PresetBenchmarkLatency(CoreModel): + mean: Annotated[float, Field(ge=0)] + p50: Annotated[float, Field(ge=0)] + p99: Annotated[float, Field(ge=0)] - @property - def api_model_name(self) -> str: - return self.base + +class PresetBenchmarkMetrics(CoreModel): + successful_requests: Annotated[int, Field(ge=0)] + failed_requests: Annotated[int, Field(ge=0)] + duration_seconds: PositiveFloat + total_input_tokens: Annotated[int, Field(ge=0)] + total_output_tokens: Annotated[int, Field(ge=0)] + # Stored as reported, but never read back: `effective_*` recomputes both + # from the totals rather than trusting self-reported rates. + output_tok_per_s: PositiveFloat + per_user_tok_per_s: PositiveFloat + ttft_ms: PresetBenchmarkLatency + tpot_ms: PresetBenchmarkLatency + + +class PresetBenchmark(CoreModel): + """The agent reports its benchmark in exactly this shape, and is forced to by + the schema generated from it. Changing a field here means also changing the + `## Benchmark` section of the system prompt, which tells it what to put there.""" + + tool: Annotated[str, Field(min_length=1)] + tool_version: Annotated[str, Field(min_length=1)] + command: Annotated[str, Field(min_length=1)] + # The subclass first: a report without `dataset` is a random workload, and a + # base-typed field would reject its `shared_prefix_tokens` as unknown. + workload: Union[PresetRandomWorkload, PresetWorkload] + metrics: PresetBenchmarkMetrics @property - def exact_repo(self) -> None: - return None + def effective_output_tok_per_s(self) -> float: + return self.metrics.total_output_tokens / self.metrics.duration_seconds @property - def allows_variant_selection(self) -> bool: - return True + def effective_per_user_tok_per_s(self) -> float: + return 1000 / self.metrics.tpot_ms.mean - @field_validator("base") + @field_validator("command") @classmethod - def validate_base(cls, value: str) -> str: - return _validate_model(value, field="base") + def validate_command_has_no_bearer_token(cls, value: str) -> str: + for match in re.finditer(r"(?i)\bbearer\s+([^\s\"']+)", value): + token = match.group(1) + if token.startswith("$") or "redacted" in token.lower() or set(token) == {"*"}: + continue + # Prose such as "auth via bearer header from env" is not a + # credential: only credential-shaped values are rejected. + if len(token) < 16 or not any(char.isdigit() for char in token): + continue + raise ValueError("command must not contain a bearer token value") + return value + @model_validator(mode="after") + def validate_metrics(self) -> Self: + if self.metrics.failed_requests != 0: + raise ValueError("benchmark must not include failed requests") + if self.metrics.successful_requests != self.workload.num_requests: + raise ValueError("benchmark request count must match workload.num_requests") + return self -PresetModelSpec = Union[PresetModelRepo, PresetModelBase] -MAX_PROMPT_LENGTH = 10_000 +class PresetVerificationReplicaGroup(CoreModel): + # The service replica group this was measured for. + name: str + # One entry per replica that was running: its actual resources. + replicas: List[ResourcesSpec] + + +class PortablePreset(CoreModel): + """A preset that carries everything needed to deploy it and nothing tied to + where it is stored.""" + + # The base model family. + base: Annotated[str, Field(min_length=1)] + # The exact repo/path the service loads, which `base` is a variant of. Not + # the client-facing API model name — that is `service.model`. + repo: Annotated[str, Field(min_length=1)] + # The largest context the service was verified to serve. + context_length: PositiveInt + # The verified run's spec configuration. The validator below keeps `name`, + # `gateway`, and profile params unset (the deployer's choices) and requires + # `model` and resources. Env keys the user declared as passthroughs hold + # `EnvSentinel` references, not the resolved secrets; other env values are + # stored as-is. `files` paths are stored relative to the preset directory, + # absolute after load. + service: ServiceConfiguration + benchmark: PresetBenchmark + # The hardware it was verified on: the actual resources of every running + # replica, by service replica group. + verified_on: List[PresetVerificationReplicaGroup] + @model_validator(mode="after") + def validate_artifact(self) -> Self: + service = self.service + if service.model is None: + raise ValueError("preset service must specify model") + if any(group.resources is None for group in service.replica_groups): + raise ValueError("preset service must specify resources") + for field in PRESET_EXCLUDED_FIELDS: + if getattr(service, field) is not None: + raise ValueError(f"preset service must not specify {field}") + if [group.name for group in self.verified_on] != [ + group.name for group in service.replica_groups + ]: + raise ValueError("preset verification replica groups must match the service's") + for replica_group in self.verified_on: + if not replica_group.replicas: + raise ValueError("preset verification replica groups must not be empty") + for resources in replica_group.replicas: + _validate_exact_resources(resources) + return self -class PresetPromptFile(CoreModel): - path: Annotated[ - str, - Field(description="The path to a prompt file, relative to the configuration file"), - ] - @field_validator("path") - @classmethod - def validate_path(cls, value: str) -> str: - if not value.strip(): - raise ValueError("Prompt path must be a non-empty string") - return value +def _validate_exact_resources(resources: ResourcesSpec) -> None: + cpu = resources.cpu + if not _is_exact(cpu.count) or not _is_exact(resources.memory): + raise ValueError("preset verification resources must be exact") + if resources.disk is None or not _is_exact(resources.disk.size): + raise ValueError("preset verification resources must be exact") + gpu = resources.gpu + if gpu is None or not _is_exact(gpu.count): + raise ValueError("preset verification resources must be exact") + if gpu.count.min == 0: + return + if gpu.name is None or len(gpu.name) != 1 or not _is_exact(gpu.memory): + raise ValueError("preset verification resources must be exact") + + +def _is_exact(value: Optional[Range]) -> bool: + return ( + value is not None + and value.min is not None + and value.max is not None + and value.min == value.max + ) -def _drop_model_from_required(schema: dict) -> None: - # `model` is synthesized from the top-level `base`/`repo` shorthand by a - # before-validator, which JSON Schema consumers never run. - required = [field for field in schema.get("required", []) if field != "model"] - if required: - schema["required"] = required - else: - schema.pop("required", None) - - -class PresetConfiguration( - ProfileParams, -): - model_config = ConfigDict(json_schema_extra=_drop_model_from_required) - - type: Annotated[Literal["preset"], Field(description="The configuration type")] = "preset" - # TODO: Generate a random name when omitted, like runs and fleets do - name: Annotated[ - Optional[str], - Field(description="The preset name"), - ] = None - model: Annotated[ - PresetModelSpec, +def validate_preset_file_path(path: str) -> None: + """Raises ValueError. The push (server) and pull (client) sides share these + rules verbatim, so a preset the server accepts can always be pulled.""" + if ( + not path + or path.startswith("/") + or "\\" in path + # `:` covers Windows drive letters and is invalid on Windows targets. + or ":" in path + or any(part in ("", ".", "..") for part in path.split("/")) + ): + raise ValueError(f"Invalid preset file path {path!r}: must be a relative POSIX path") + if path in _RESERVED_PRESET_FILE_PATHS: + raise ValueError(f"Invalid preset file path {path!r}: the name is reserved") + + +def validate_preset_file_paths(paths: Sequence[str]) -> None: + """Raises ValueError: per-path rules, duplicates, and file/directory prefix + conflicts (`a` and `a/b` cannot both materialize on one filesystem).""" + seen = set() + directories = set() + for path in paths: + validate_preset_file_path(path) + if path in seen: + raise ValueError(f"Duplicate preset file path {path!r}") + seen.add(path) + parts = path.split("/") + for index in range(1, len(parts)): + directories.add("/".join(parts[:index])) + conflicts = seen & directories + if conflicts: + raise ValueError( + f"PortablePreset file path {sorted(conflicts)[0]!r} is both a file and a directory" + ) + + +class PresetArchiveMapping(CoreModel): + """One file (or directory) of the preset, stored as a file archive — the + same mechanism run `files` use.""" + + id: Annotated[UUID, Field(description="The file archive ID")] + path: Annotated[ + str, Field( description=( - "The model to serve. Use a string or `repo` for an exact repo/path, " - "or `base` to allow compatible model variants. " - "Prefer the top-level `base`/`repo` shorthand unless a custom " - "client-facing model name is needed" + "The preset-directory-relative POSIX path," + " as referenced by the preset service's `files`" ) ), ] - base: Annotated[ - Optional[str], - Field( - description=( - "The base model repo; compatible variants are allowed. Shorthand for `model.base`" - ) - ), - ] = None - repo: Annotated[ - Optional[str], - Field(description="The exact model repo/path to serve. Shorthand for `model.repo`"), - ] = None - prompt: Annotated[ - Optional[Union[str, PresetPromptFile]], - Field( - description=( - "Additional instructions for the preset creation agent, inline or as a file `path`" - ) - ), - ] = None - min_context_length: Annotated[ - Optional[PositiveInt], - Field(description="The minimum required context length. Required for creation"), - ] = None - max_ttft: Annotated[ - Optional[PositiveInt], - Field( - description=( - "The maximum p50 time to first token, in milliseconds, that any benchmark" - " may report. Required for creation" - ) - ), - ] = None - trials: Annotated[ - Optional[PositiveInt], - Field( - description=( - "The number of benchmarked trials during preset creation" - " before the best one is promoted. Required for creation" - ) - ), - ] = None - previous: Annotated[ - Optional[list[str]], - Field( - description=( - "The IDs of previous presets whose creation results the agent" - " analyzes and improves on" - ) - ), - ] = None - concurrency: Annotated[ - Optional[PositiveInt], - Field( - description=( - "The number of simultaneous requests used for benchmarks during preset" - " creation. Required for creation" - ) - ), - ] = None - input_tokens: Annotated[ - Optional[PositiveInt], - Field( - description=( - "The number of input tokens per request used for benchmarks during" - f" preset creation. Defaults to `{DEFAULT_INPUT_TOKENS}`" - ) - ), - ] = None - output_tokens: Annotated[ - Optional[Annotated[int, Field(ge=2)]], - Field( - description=( - "The number of output tokens per request used for benchmarks during" - f" preset creation. Defaults to `{DEFAULT_OUTPUT_TOKENS}`" - ) - ), - ] = None - shared_prefix_tokens: Annotated[ - Optional[Annotated[int, Field(ge=0)]], - Field( - description=( - "How many of `input_tokens` are a prefix identical in every benchmark request," - " as a repeated system prompt or conversation history would be. Defaults to `0`," - " meaning every request is fully unique" - ) - ), - ] = None - dataset: Annotated[ - Optional[str], - Field( - description=( - "The benchmark dataset used during preset creation: `random` for synthetic" - " prompts shaped by `input_tokens` and `output_tokens`, a benchmark tool's" - " dataset name (e.g. `sharegpt`, `spec_bench`), or a Hugging Face dataset ID." - " Defaults to `random`" - ) - ), - ] = None - baseline: Annotated[ - Optional[bool], - Field( - description=( - "Whether the first trial must be a baseline that serves the model with the" - " serving framework's recommended defaults instead of an optimization attempt." - " Defaults to `true`" - ) - ), - ] = None - gateway: Annotated[ - Optional[Union[bool, EntityReference, str]], - Field( - union_mode="left_to_right", # preserving pydantic v1 parsing behavior - description=( - "The name of the gateway. Specify boolean `false` to run without a gateway." - " Specify boolean `true` to run with the default gateway." - " Omit to run with the default gateway if there is one, or without a gateway otherwise" - ), - ), - ] = None - env: Annotated[Env, Field(description="The mapping or the list of environment variables")] = ( - Env() - ) - - @property - def effective_input_tokens(self) -> int: - return self.input_tokens if self.input_tokens is not None else DEFAULT_INPUT_TOKENS - @property - def effective_output_tokens(self) -> int: - return self.output_tokens if self.output_tokens is not None else DEFAULT_OUTPUT_TOKENS - @property - def effective_baseline(self) -> bool: - return self.baseline if self.baseline is not None else DEFAULT_BASELINE - - @property - def effective_dataset(self) -> str: - return self.dataset if self.dataset is not None else DEFAULT_DATASET - - @field_validator("dataset") - @classmethod - def validate_dataset_name(cls, value: Optional[str]) -> Optional[str]: - if value is None: - return None - # Stripped because the agent reports the dataset it actually loaded, and - # the two are compared for equality when the preset is verified. - value = value.strip() - if not value: - raise ValueError("dataset must be a non-empty string") - return value - - @model_validator(mode="after") - def validate_dataset(self) -> Self: - if self.dataset in (None, DEFAULT_DATASET): - return self - set_fields = [ - name - for name in ("input_tokens", "output_tokens", "shared_prefix_tokens") - if getattr(self, name) is not None - ] - if set_fields: - raise ValueError( - f"{', '.join(set_fields)} can only be set with the `random` dataset;" - " a custom dataset defines its own request shape" - ) - return self +class PresetSpec(CoreModel): + """A preset with its files, as `RunSpec` carries a configuration with its + file archives.""" - @model_validator(mode="after") - def validate_shared_prefix_tokens(self) -> Self: - # The prefix is carved out of the request, so something has to be left - # to differ between requests. - if self.shared_prefix_tokens is None: - return self - input_tokens = self.input_tokens or DEFAULT_INPUT_TOKENS - if self.shared_prefix_tokens >= input_tokens: - raise ValueError( - f"shared_prefix_tokens must be less than input_tokens ({input_tokens})" + preset: PortablePreset + file_archives: Annotated[ + List[PresetArchiveMapping], + Field( + description=( + "The files referenced by the preset service's `files`, as uploaded file archives" ) - return self - - @model_validator(mode="before") - @classmethod - def apply_model_shorthand(cls, values: Any) -> Any: - if not isinstance(values, dict): - return values - base, repo = values.get("base"), values.get("repo") - if base and repo: - raise ValueError("`base` and `repo` are mutually exclusive") - if base or repo: - if values.get("model") is not None: - raise ValueError("`model` cannot be combined with the `base`/`repo` shorthand") - values = dict(values) - values.pop("base", None) - values.pop("repo", None) - values["model"] = {"base": base} if base else {"repo": repo} - return values - - @field_validator("model", mode="before", json_schema_input_type=Union[PresetModelSpec, str]) - @classmethod - def parse_model(cls, value: Any) -> Any: - if isinstance(value, str): - return {"repo": _validate_model(value, field="model")} - return value - - @field_validator("prompt") - @classmethod - def validate_prompt(cls, value: Any) -> Any: - if isinstance(value, str): - if not value.strip(): - raise ValueError("Prompt must be a non-empty string") - if len(value) > MAX_PROMPT_LENGTH: - raise ValueError(f"Prompt must be at most {MAX_PROMPT_LENGTH} characters") - return value + ), + ] = [] + + +def validate_preset_spec_files(spec: PresetSpec) -> None: + """Raises ValueError. The single owner of the file rules: push (server and + client) and pull all call this, so what one side accepts the other can + always materialize.""" + validate_preset_file_paths([mapping.path for mapping in spec.file_archives]) + referenced_paths = set() + for mapping in spec.preset.service.files: + local_path = mapping.local_path + validate_preset_file_path(local_path) + referenced_paths.add(local_path) + pushed_paths = {mapping.path for mapping in spec.file_archives} + missing_paths = referenced_paths - pushed_paths + if missing_paths: + raise ValueError( + f"Files referenced by the preset are missing from the push: {sorted(missing_paths)}" + ) + unreferenced_paths = pushed_paths - referenced_paths + if unreferenced_paths: + raise ValueError( + f"Pushed files are not referenced by the preset: {sorted(unreferenced_paths)}" + ) + + +def validate_preset_spec_limits(spec: PresetSpec) -> None: + """Raises ValueError. Both sides measure the same object, so a spec the + client accepts is never rejected by the server for size.""" + if len(spec.model_dump_json().encode("utf-8")) > MAX_PRESET_SPEC_SIZE: + raise ValueError(f"PortablePreset spec exceeds the {MAX_PRESET_SPEC_SIZE}-byte limit") + if len(spec.file_archives) > MAX_PRESET_FILES: + raise ValueError(f"PortablePreset has more than {MAX_PRESET_FILES} files") + + +# Creation constraints class PresetConstraints(CoreModel): diff --git a/src/dstack/_internal/server/schemas/presets.py b/src/dstack/_internal/server/schemas/presets.py new file mode 100644 index 000000000..da6e0fdfc --- /dev/null +++ b/src/dstack/_internal/server/schemas/presets.py @@ -0,0 +1,54 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import Field +from typing_extensions import Annotated + +from dstack._internal.core.models.common import CoreModel +from dstack._internal.core.models.presets import PresetSpec + + +class PushPresetRequest(CoreModel): + name: Annotated[str, Field(description="The registry preset name")] + spec: Annotated[PresetSpec, Field(description="The preset to push")] + + +class GetPresetRequest(CoreModel): + name_or_id: Annotated[ + str, + Field(description=("The preset id, or a preset name resolving to its current version")), + ] + + +class GetPresetFileRequest(CoreModel): + name_or_id: Annotated[str, Field(description="The preset id or name")] + path: Annotated[ + str, Field(description="The preset-directory-relative path of the file to download") + ] + + +class PushPresetResponse(CoreModel): + """What `push` returns: the record the registry minted.""" + + id: UUID + name: str + base: str + repo: str + created_at: datetime + pushed_by: Annotated[str, Field(description="The username of the pusher")] + is_current: Annotated[ + bool, + Field( + description=( + "Whether the name currently resolves to this preset." + " Derived when read: a later push under the same name takes it over" + ) + ), + ] + + +class GetPresetResponse(PushPresetResponse): + """What `get` returns: the record plus the stored spec. File contents are + downloaded separately per archive mapping.""" + + spec: PresetSpec diff --git a/src/dstack/api/server/__init__.py b/src/dstack/api/server/__init__.py index 4b344ed27..d20d99cb6 100644 --- a/src/dstack/api/server/__init__.py +++ b/src/dstack/api/server/__init__.py @@ -26,6 +26,7 @@ from dstack.api.server._imports import ImportsAPIClient from dstack.api.server._logs import LogsAPIClient from dstack.api.server._metrics import MetricsAPIClient +from dstack.api.server._presets import PresetsAPIClient from dstack.api.server._projects import ProjectsAPIClient from dstack.api.server._repos import ReposAPIClient from dstack.api.server._runs import RunsAPIClient @@ -53,6 +54,7 @@ class APIClient: logs: operations with logs gateways: operations with gateways volumes: operations with volumes + presets: operations with registry presets exports: operations with exports files: operations with files """ @@ -132,6 +134,10 @@ def gateways(self) -> GatewaysAPIClient: def volumes(self) -> VolumesAPIClient: return VolumesAPIClient(self._request, self._logger) + @property + def presets(self) -> PresetsAPIClient: + return PresetsAPIClient(self._request, self._logger) + @property def exports(self) -> ExportsAPIClient: return ExportsAPIClient(self._request, self._logger) diff --git a/src/dstack/api/server/_presets.py b/src/dstack/api/server/_presets.py new file mode 100644 index 000000000..a097f6942 --- /dev/null +++ b/src/dstack/api/server/_presets.py @@ -0,0 +1,33 @@ +from dstack._internal.core.models.common import validate_extra_ignore +from dstack._internal.core.models.presets import PresetSpec +from dstack._internal.server.schemas.presets import ( + GetPresetFileRequest, + GetPresetRequest, + GetPresetResponse, + PushPresetRequest, + PushPresetResponse, +) +from dstack.api.server._group import APIClientGroup + + +class PresetsAPIClient(APIClientGroup): + def push(self, project_name: str, name: str, spec: PresetSpec) -> PushPresetResponse: + body = PushPresetRequest(name=name, spec=spec) + resp = self._request( + f"/api/project/{project_name}/presets/push", body=body.model_dump_json() + ) + return validate_extra_ignore(PushPresetResponse, resp.json()) + + def get(self, project_name: str, name_or_id: str) -> GetPresetResponse: + body = GetPresetRequest(name_or_id=name_or_id) + resp = self._request( + f"/api/project/{project_name}/presets/get", body=body.model_dump_json() + ) + return validate_extra_ignore(GetPresetResponse, resp.json()) + + def get_file(self, project_name: str, name_or_id: str, path: str) -> bytes: + body = GetPresetFileRequest(name_or_id=name_or_id, path=path) + resp = self._request( + f"/api/project/{project_name}/presets/get_file", body=body.model_dump_json() + ) + return resp.content diff --git a/src/tests/_internal/cli/commands/test_preset.py b/src/tests/_internal/cli/commands/test_preset.py index 326a56741..4bc098c80 100644 --- a/src/tests/_internal/cli/commands/test_preset.py +++ b/src/tests/_internal/cli/commands/test_preset.py @@ -183,7 +183,7 @@ def test_lists_presets_without_api_client(self, tmp_path): preset = get_preset() PresetStore(tmp_path / ".dstack" / "presets").save(preset) - output = self._list_output(tmp_path, ["preset", "list"], created_at=preset.submitted_at) + output = self._list_output(tmp_path, ["preset", "list"], created_at=preset.created_at) assert "Qwen/Qwen3.5-27B" in output assert "8f3a12c4" in output @@ -212,7 +212,7 @@ def test_verbose_list_adds_repo(self, tmp_path): joined_verbose = "".join( self._list_output( - tmp_path, ["preset", "list", "-v"], created_at=preset.submitted_at + tmp_path, ["preset", "list", "-v"], created_at=preset.created_at ).split() ) @@ -238,7 +238,7 @@ def test_deletes_an_interrupted_creation_that_never_saved_a_preset(self, tmp_pat assert run_dstack_cli(["preset", "delete", "smoke", "-y"], home_dir=tmp_path) == 0 assert not session_dir.exists() - assert "Deleted preset ab12cd34" in capsys.readouterr().out + assert "OK" in capsys.readouterr().out def test_refuses_to_delete_a_running_creation(self, tmp_path, capsys): session_dir = self._session(tmp_path, status="running") @@ -344,7 +344,7 @@ def test_gets_complete_preset_as_json_without_api_client(self, tmp_path, capsys) data = json.loads(capsys.readouterr().out) assert data["id"] == preset.id - assert data["submitted_at"] == render_datetime_as_api(preset.submitted_at) + assert data["created_at"] == render_datetime_as_api(preset.created_at) assert data["context_length"] == 32768 assert data["benchmark"]["metrics"]["total_output_tokens"] == 2048 @@ -365,11 +365,11 @@ def test_lists_complete_presets_as_json(self, tmp_path, capsys, args): assert len(output["presets"]) == 1 data = output["presets"][0] assert data["id"] == preset.id - assert data["submitted_at"] == render_datetime_as_api(preset.submitted_at) + assert data["created_at"] == render_datetime_as_api(preset.created_at) assert data["context_length"] == 32768 assert data["benchmark"]["metrics"]["total_output_tokens"] == 2048 - @pytest.mark.parametrize("flag_attribute", [("--base", "base"), ("--repo", "model")]) + @pytest.mark.parametrize("flag_attribute", [("--base", "base"), ("--repo", "repo")]) def test_deletes_all_presets_of_model_keeping_others_without_api_client( self, tmp_path, flag_attribute ): @@ -381,7 +381,7 @@ def test_deletes_all_presets_of_model_keeping_others_without_api_client( # A preset of a different model must survive the delete. store.save( preset.model_copy( - update={"id": "89abcdef", "base": "meta/Llama-4", "model": "meta/Llama-4"} + update={"id": "89abcdef", "base": "meta/Llama-4", "repo": "meta/Llama-4"} ) ) @@ -397,7 +397,7 @@ def test_deletes_all_presets_of_model_keeping_others_without_api_client( assert [remaining.id for remaining in store.list()] == ["89abcdef"] - @pytest.mark.parametrize("flag_attribute", [("--base", "base"), ("--repo", "model")]) + @pytest.mark.parametrize("flag_attribute", [("--base", "base"), ("--repo", "repo")]) def test_lists_presets_filtered_by_model(self, tmp_path, capsys, flag_attribute): flag, attribute = flag_attribute preset = get_preset() @@ -405,7 +405,7 @@ def test_lists_presets_filtered_by_model(self, tmp_path, capsys, flag_attribute) store.save(preset) store.save( preset.model_copy( - update={"id": "01234567", "base": "meta/Llama-4", "model": "meta/Llama-4"} + update={"id": "01234567", "base": "meta/Llama-4", "repo": "meta/Llama-4"} ) ) @@ -558,6 +558,37 @@ def test_get_and_delete_resolve_names(self, tmp_path, capsys): assert run_dstack_cli(["preset", "delete", "qwen", "-y"], home_dir=tmp_path) == 0 assert PresetStore(tmp_path / ".dstack" / "presets").list() == [] + def test_delete_of_a_qualified_ref_removes_only_the_pulled_copy(self, tmp_path): + store = PresetStore(tmp_path / ".dstack" / "presets") + local = get_preset() + local_dir = store.save(local).parent + (local_dir / "patch").mkdir() + (local_dir / "patch" / "a.txt").write_text("keep") + # A pulled copy: UUID-named directory, qualified `/` name. + pulled = get_preset(preset_id="0b2b7b1e-9c1a-4a58-9d5a-3f6a1b2c3d4e").model_copy( + update={"name": "main/qwen"} + ) + pulled_dir = store.save(pulled).parent + + assert run_dstack_cli(["preset", "delete", "main/qwen", "-y"], home_dir=tmp_path) == 0 + + assert not pulled_dir.exists() + assert local_dir.exists() + assert (local_dir / "patch" / "a.txt").read_text() == "keep" + assert store.get(local.id) is not None + + def test_delete_of_an_unknown_qualified_ref_reports_it_does_not_exist(self, tmp_path, capsys): + store = PresetStore(tmp_path / ".dstack" / "presets") + store.save(get_preset()) + + exit_code = run_dstack_cli(["preset", "delete", "main/unknown", "-y"], home_dir=tmp_path) + + # A qualified ref never names a creation session, so the session + # fallback must not turn this into a confusing error. + assert exit_code != 0 + assert "does not exist" in capsys.readouterr().out + assert store.list() != [] + def test_create_always_asks_even_without_a_name_conflict(self, tmp_path): configuration_path = tmp_path / "preset.dstack.yml" configuration_path.write_text( @@ -582,6 +613,47 @@ def test_create_always_asks_even_without_a_name_conflict(self, tmp_path): create.assert_not_called() +class TestPresetRegistryCommands: + """`push` and `pull` are the only commands that touch the network; the + command layer's whole job is handing the refs to the registry service.""" + + def test_push_passes_the_local_and_registry_refs_through(self, tmp_path): + with patch("dstack._internal.cli.commands.preset.push_preset_to_registry") as push: + exit_code = run_dstack_cli( + ["preset", "push", "8f3a12c4", "main/qwen"], home_dir=tmp_path + ) + + assert exit_code == 0 + store, local_ref, registry_ref = push.call_args.args + assert isinstance(store, PresetStore) + assert local_ref == "8f3a12c4" + assert registry_ref == "main/qwen" + + def test_pull_passes_the_registry_ref_through(self, tmp_path): + with patch("dstack._internal.cli.commands.preset.pull_preset_from_registry") as pull: + exit_code = run_dstack_cli(["preset", "pull", "main/qwen"], home_dir=tmp_path) + + assert exit_code == 0 + store, registry_ref = pull.call_args.args + assert isinstance(store, PresetStore) + assert registry_ref == "main/qwen" + + @pytest.mark.parametrize( + "args", + [ + ["preset", "push", "8f3a12c4", "qwen"], + ["preset", "pull", "qwen"], + ], + ) + def test_rejects_an_unqualified_ref(self, tmp_path, capsys, args): + # The ref is parsed before anything is uploaded, downloaded, or written, + # so an unqualified one never reaches a server. + exit_code = run_dstack_cli(args, home_dir=tmp_path) + + assert exit_code != 0 + assert "Invalid registry reference" in capsys.readouterr().out + + class TestApplyPresetConfiguration: _CONFIGURATION = """type: preset name: file-name diff --git a/src/tests/_internal/cli/common.py b/src/tests/_internal/cli/common.py index 182c8d6a2..86b15c666 100644 --- a/src/tests/_internal/cli/common.py +++ b/src/tests/_internal/cli/common.py @@ -17,18 +17,18 @@ PresetSessionState, PresetSessionWorkspace, ) -from dstack._internal.cli.models.presets import ( - PresetBenchmark, - PresetVerificationReplicaGroup, - VerifiedPreset, -) +from dstack._internal.cli.models.presets import VerifiedPreset from dstack._internal.compat import IS_WINDOWS from dstack._internal.core.models.configurations import ( DEFAULT_REPLICA_GROUP_NAME, + PresetConfiguration, ServiceConfiguration, ) from dstack._internal.core.models.instances import Disk, Gpu, Resources -from dstack._internal.core.models.presets import PresetConfiguration +from dstack._internal.core.models.presets import ( + PresetBenchmark, + PresetVerificationReplicaGroup, +) from dstack._internal.core.models.resources import ResourcesSpec from dstack._internal.core.models.runs import JobStatus, Run, RunStatus, ServiceSpec @@ -132,8 +132,8 @@ def get_preset( ), base="Qwen/Qwen3.5-27B", id=preset_id, - model="community/Qwen3.5-27B-GPTQ-Int4", - submitted_at=datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc), + repo="community/Qwen3.5-27B-GPTQ-Int4", + created_at=datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc), service=ServiceConfiguration.model_validate( { "image": "vllm/vllm-openai:v0.11.0", diff --git a/src/tests/_internal/cli/models/test_presets.py b/src/tests/_internal/cli/models/test_presets.py index 5564aabe8..ca3d05f54 100644 --- a/src/tests/_internal/cli/models/test_presets.py +++ b/src/tests/_internal/cli/models/test_presets.py @@ -1,7 +1,7 @@ import pytest from pydantic import ValidationError -from dstack._internal.cli.models.presets import ( +from dstack._internal.core.models.presets import ( PresetBenchmark, ) from tests._internal.cli.common import get_preset_benchmark diff --git a/src/tests/_internal/cli/services/presets/test_agent.py b/src/tests/_internal/cli/services/presets/test_agent.py index 9182b92b4..f101258c5 100644 --- a/src/tests/_internal/cli/services/presets/test_agent.py +++ b/src/tests/_internal/cli/services/presets/test_agent.py @@ -48,7 +48,7 @@ ) from dstack._internal.compat import IS_WINDOWS from dstack._internal.core.errors import CLIError -from dstack._internal.core.models.presets import PresetConfiguration +from dstack._internal.core.models.configurations import PresetConfiguration from dstack._internal.core.services.configs import ConfigManager from tests._internal.cli.common import get_session_run, get_session_state diff --git a/src/tests/_internal/cli/services/presets/test_build.py b/src/tests/_internal/cli/services/presets/test_build.py index f215ae315..44e7ecc0d 100644 --- a/src/tests/_internal/cli/services/presets/test_build.py +++ b/src/tests/_internal/cli/services/presets/test_build.py @@ -1,12 +1,12 @@ import gpuhunt import pytest -from dstack._internal.cli.models.presets import PresetVerificationReplicaGroup from dstack._internal.cli.services.presets.build import set_service_gpu_vendor_from_verification from dstack._internal.core.models.configurations import ( DEFAULT_REPLICA_GROUP_NAME, ServiceConfiguration, ) +from dstack._internal.core.models.presets import PresetVerificationReplicaGroup from dstack._internal.core.models.resources import ResourcesSpec pytestmark = pytest.mark.windows diff --git a/src/tests/_internal/cli/services/presets/test_create.py b/src/tests/_internal/cli/services/presets/test_create.py index 683fb55a9..ebf962014 100644 --- a/src/tests/_internal/cli/services/presets/test_create.py +++ b/src/tests/_internal/cli/services/presets/test_create.py @@ -50,8 +50,8 @@ remove_agent_workspace, ) from dstack._internal.core.errors import CLIError +from dstack._internal.core.models.configurations import PresetConfiguration from dstack._internal.core.models.envs import EnvSentinel -from dstack._internal.core.models.presets import PresetConfiguration from dstack._internal.core.models.runs import Run, RunStatus from tests._internal.cli.common import ( get_preset, diff --git a/src/tests/_internal/cli/services/presets/test_export.py b/src/tests/_internal/cli/services/presets/test_export.py index 123b8c62f..40c2b11d7 100644 --- a/src/tests/_internal/cli/services/presets/test_export.py +++ b/src/tests/_internal/cli/services/presets/test_export.py @@ -61,6 +61,18 @@ def test_names_the_service_after_the_preset(self, tmp_path: Path): data = yaml.safe_load(destination.read_text()) assert data["name"] == "qwen-fast" + def test_defaults_a_qualified_preset_name_to_its_registry_name(self, tmp_path: Path): + store = PresetStore(tmp_path / "presets") + # A pulled copy is named `/`, which is not a valid + # resource name; the registry name after the `/` is. + preset = get_preset().model_copy(update={"name": "main-sky/qwen-fast"}) + preset_dir = store.save(preset).parent + destination = tmp_path / "qwen.dstack.yml" + + export_preset(preset, preset_dir=preset_dir, destination=destination, force=False) + + assert yaml.safe_load(destination.read_text())["name"] == "qwen-fast" + def test_names_the_service_after_the_name_option(self, tmp_path: Path): store = PresetStore(tmp_path / "presets") preset = get_preset().model_copy(update={"name": "qwen-fast"}) diff --git a/src/tests/_internal/cli/services/presets/test_output.py b/src/tests/_internal/cli/services/presets/test_output.py index de71b9ffb..ce70c66f4 100644 --- a/src/tests/_internal/cli/services/presets/test_output.py +++ b/src/tests/_internal/cli/services/presets/test_output.py @@ -1,13 +1,14 @@ import json -from datetime import timedelta +from datetime import datetime, timedelta, timezone from io import StringIO import pytest from rich.table import Table -from dstack._internal.cli.models.presets import PresetWorkload +from dstack._internal.cli.models.presets import PulledPreset from dstack._internal.cli.services.presets import output as output_module from dstack._internal.cli.services.presets.output import _add_session, _format_number +from dstack._internal.core.models.presets import PortablePreset, PresetWorkload from tests._internal.cli.common import get_preset, plain_console pytestmark = pytest.mark.windows @@ -85,6 +86,36 @@ def test_renders_the_requested_workload_not_the_measured_one(self): ) +def _get_pulled_preset() -> PulledPreset: + verified = get_preset() + return PulledPreset( + id=verified.id, + name="main/qwen", + created_at=datetime(2026, 2, 3, 4, 5, tzinfo=timezone.utc), + **{field: getattr(verified, field) for field in PortablePreset.model_fields}, + ) + + +class TestRemotePresetRow: + def test_objective_falls_back_to_the_measured_workload(self): + # A pulled preset carries no creation context; the benchmark workload is + # its record of the conditions the numbers hold for. + assert output_module.format_preset_objective(_get_pulled_preset()) == ( + "[secondary]io=1K/128 c=1[/]" + ) + + def test_renders_a_pulled_preset_row(self, monkeypatch): + output = StringIO() + monkeypatch.setattr(output_module, "console", plain_console(output, width=200)) + + output_module.print_presets([_get_pulled_preset()]) + + text = output.getvalue() + assert "main/qwen" in text + assert "io=1K/128" in text + assert "ctx=32K" in text + + class TestPrintPresets: def test_preserves_constraints_and_benchmark_at_narrow_width(self, monkeypatch): output = StringIO() @@ -237,7 +268,7 @@ def test_sorts_all_rows_newest_first_without_grouping(self, monkeypatch): monkeypatch.setattr(output_module, "console", plain_console(buffer, width=200)) old = get_preset() new = old.model_copy( - update={"id": "11aa22bb", "submitted_at": old.submitted_at + timedelta(days=2)} + update={"id": "11aa22bb", "created_at": old.created_at + timedelta(days=2)} ) sessions = [ { diff --git a/src/tests/_internal/cli/services/presets/test_registry.py b/src/tests/_internal/cli/services/presets/test_registry.py new file mode 100644 index 000000000..d9c6f342b --- /dev/null +++ b/src/tests/_internal/cli/services/presets/test_registry.py @@ -0,0 +1,728 @@ +import io +import logging +import tarfile +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace +from typing import Optional +from uuid import uuid4 + +import pytest +import yaml + +from dstack._internal.cli.models.presets import PulledPreset +from dstack._internal.cli.services.presets import registry as registry_module +from dstack._internal.cli.services.presets.registry import ( + parse_registry_ref, + pull_preset_from_registry, + push_preset_to_registry, + resolve_registry_client, +) +from dstack._internal.cli.services.presets.store import PresetStore +from dstack._internal.core.errors import ( + CLIError, + MethodNotAllowedError, + ResourceNotExistsError, + URLNotFoundError, +) +from dstack._internal.core.models.common import RegistryAuth +from dstack._internal.core.models.files import FileArchive, FilePathMapping +from dstack._internal.core.models.presets import ( + PortablePreset, + PresetArchiveMapping, + PresetSpec, +) +from dstack._internal.server.schemas.presets import ( + GetPresetResponse, + PushPresetRequest, + PushPresetResponse, +) +from dstack._internal.utils.files import create_file_archive +from dstack.api.server._presets import PresetsAPIClient +from tests._internal.cli.common import get_preset + +pytestmark = pytest.mark.windows + + +class FakeFilesAPIClient: + def __init__(self): + self.uploads: list[SimpleNamespace] = [] + + def upload_archive(self, hash, fp): + archive = FileArchive(id=uuid4(), hash=hash) + self.uploads.append(SimpleNamespace(hash=hash, content=fp.read(), archive=archive)) + return archive + + +class FakePresetsAPIClient: + def __init__(self, remote: Optional[GetPresetResponse] = None): + self.remote = remote + self.push_requests: list[SimpleNamespace] = [] + self.file_requests: list[SimpleNamespace] = [] + # Blobs served by `get_file`, keyed by the archive mapping path. + self.file_blobs: dict[str, bytes] = {} + self.files = FakeFilesAPIClient() + self.error: Optional[Exception] = None + + def push(self, project_name, name, spec): + self._raise_if_failing() + self.push_requests.append(SimpleNamespace(project_name=project_name, name=name, spec=spec)) + return _registry_preset_info(name=name) + + def get(self, project_name, name_or_id): + self._raise_if_failing() + assert self.remote is not None + # The real client parses a fresh response object per request, so a caller + # that re-roots the returned document cannot leak into the next pull. + return self.remote.model_copy(deep=True) + + def get_file(self, project_name, name_or_id, path): + self._raise_if_failing() + self.file_requests.append( + SimpleNamespace(project_name=project_name, name_or_id=name_or_id, path=path) + ) + return self.file_blobs[path] + + def _raise_if_failing(self): + if self.error is not None: + raise self.error + + +def _registry_preset_info(*, name: str = "qwen38") -> PushPresetResponse: + return PushPresetResponse( + id=uuid4(), + name=name, + base="Qwen/Qwen3.5-27B", + repo="community/Qwen3.5-27B-GPTQ-Int4", + created_at=datetime(2026, 8, 20, 12, 0), + pushed_by="alice", + is_current=True, + ) + + +def _portable_preset() -> PortablePreset: + verified = get_preset() + return PortablePreset( + **{field: getattr(verified, field) for field in PortablePreset.model_fields} + ).model_copy(deep=True) + + +def _registry_preset( + *, + name: str = "qwen38", + file_archives: Optional[list[PresetArchiveMapping]] = None, + file_mappings: Optional[list[FilePathMapping]] = None, + is_current: bool = True, +) -> GetPresetResponse: + document = _portable_preset() + if file_mappings is not None: + document.service.files = file_mappings + info = _registry_preset_info(name=name).model_copy(update={"is_current": is_current}) + return GetPresetResponse( + **info.model_dump(), + spec=PresetSpec(preset=document, file_archives=file_archives or []), + ) + + +def _file_archive_blob(tmp_path: Path, arcname: str, content: str) -> bytes: + """A real archive, as `create_file_archive` produces on push: members rooted + at the archived path's basename.""" + source_dir = tmp_path / "blob-sources" / uuid4().hex + source_dir.mkdir(parents=True) + source = source_dir / arcname + source.write_text(content, encoding="utf-8") + buffer = io.BytesIO() + create_file_archive(source, buffer) + return buffer.getvalue() + + +def _hostile_archive_blob(*members: tarfile.TarInfo) -> bytes: + """A hand-built archive, as a malicious pusher can store one: the registry + keeps archives as opaque blobs, so their members are untrusted.""" + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w") as archive: + for member in members: + if member.isfile(): + archive.addfile(member, io.BytesIO(b"pwned")) + else: + archive.addfile(member) + return buffer.getvalue() + + +def _file_member(name: str) -> tarfile.TarInfo: + member = tarfile.TarInfo(name) + member.type = tarfile.REGTYPE + member.size = len(b"pwned") + return member + + +def _symlink_member(name: str, linkname: str) -> tarfile.TarInfo: + member = tarfile.TarInfo(name) + member.type = tarfile.SYMTYPE + member.linkname = linkname + return member + + +def _archive_member_texts(blob: bytes) -> dict[str, str]: + with tarfile.open(fileobj=io.BytesIO(blob)) as archive: + return { + member.name: archive.extractfile(member).read().decode("utf-8") + for member in archive.getmembers() + if member.isfile() + } + + +@pytest.fixture +def stub_client(monkeypatch: pytest.MonkeyPatch): + fake = FakePresetsAPIClient() + client = SimpleNamespace(presets=fake, files=fake.files, base_url="http://test-server") + monkeypatch.setattr(registry_module, "resolve_registry_client", lambda project: client) + return fake + + +class TestParseRegistryRef: + def test_splits_on_the_first_slash(self): + assert parse_registry_ref("main/qwen") == ("main", "qwen") + assert parse_registry_ref("main/a/b") == ("main", "a/b") + + @pytest.mark.parametrize("ref", ["main", "main/", "/qwen", "/"]) + def test_rejects_incomplete_refs(self, ref): + with pytest.raises(CLIError, match="Invalid registry reference"): + parse_registry_ref(ref) + + +class TestResolveRegistryClient: + def _configure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, projects: list[dict]): + dstack_dir = tmp_path / ".dstack" + dstack_dir.mkdir(parents=True, exist_ok=True) + (dstack_dir / "config.yml").write_text(yaml.safe_dump({"projects": projects})) + monkeypatch.setattr( + "dstack._internal.core.services.configs.get_dstack_dir", lambda: dstack_dir + ) + + def test_uses_the_matching_config_entry(self, tmp_path, monkeypatch): + self._configure( + tmp_path, + monkeypatch, + [{"name": "main", "url": "http://my-server", "token": "t1", "default": True}], + ) + + client = resolve_registry_client("main") + + assert client.base_url == "http://my-server" + assert client.token == "t1" + + def test_falls_back_to_sky_with_a_sky_entry_token(self, tmp_path, monkeypatch): + self._configure( + tmp_path, + monkeypatch, + [ + {"name": "local", "url": "http://localhost:3000", "token": "t1"}, + {"name": "mine", "url": "https://sky.dstack.ai", "token": "sky-token"}, + ], + ) + + client = resolve_registry_client("someone-elses-project") + + assert client.base_url == "https://sky.dstack.ai" + assert client.token == "sky-token" + + def test_no_sky_fallback_env_disables_the_fallback(self, tmp_path, monkeypatch): + self._configure( + tmp_path, + monkeypatch, + [{"name": "mine", "url": "https://sky.dstack.ai", "token": "sky-token"}], + ) + monkeypatch.setenv("DSTACK_NO_SKY_FALLBACK", "1") + + with pytest.raises(CLIError, match="dstack project add"): + resolve_registry_client("someone-elses-project") + + def test_errors_without_any_usable_entry(self, tmp_path, monkeypatch): + self._configure( + tmp_path, + monkeypatch, + [{"name": "local", "url": "http://localhost:3000", "token": "t1"}], + ) + + with pytest.raises(CLIError, match="dstack project add"): + resolve_registry_client("main") + + +class TestPresetsAPIClient: + """The wire layer: what the client sends, and what it accepts back.""" + + def _client(self, response) -> tuple[PresetsAPIClient, list[SimpleNamespace]]: + calls: list[SimpleNamespace] = [] + + def request(path, body=None, **kwargs): + calls.append(SimpleNamespace(path=path, body=body)) + return response + + return PresetsAPIClient(request, logging.getLogger(__name__)), calls + + def test_push_sends_the_name_and_the_typed_spec(self): + info = _registry_preset_info(name="qwen") + client, calls = self._client(SimpleNamespace(json=lambda: info.model_dump(mode="json"))) + spec = PresetSpec( + preset=_portable_preset(), + file_archives=[PresetArchiveMapping(id=uuid4(), path="patch/a.txt")], + ) + + pushed = client.push("main", name="qwen", spec=spec) + + (call,) = calls + assert call.path == "/api/project/main/presets/push" + sent = PushPresetRequest.model_validate_json(call.body) + assert sent.name == "qwen" + assert sent.spec == spec + assert pushed.name == "qwen" + assert pushed.repo == "community/Qwen3.5-27B-GPTQ-Int4" + + def test_get_returns_a_typed_spec_and_ignores_what_a_newer_server_adds(self): + remote = _registry_preset() + payload = remote.model_dump(mode="json") + # A field only a newer server knows, at both levels of the response: an + # older client must pull the preset unchanged rather than reject it. + payload["a_newer_servers_field"] = "ignored" + payload["spec"]["a_newer_servers_field"] = "ignored" + payload["spec"]["preset"]["a_newer_servers_field"] = "ignored" + client, _ = self._client(SimpleNamespace(json=lambda: payload)) + + got = client.get("main", "qwen38") + + assert isinstance(got.spec, PresetSpec) + assert got.spec.preset.repo == "community/Qwen3.5-27B-GPTQ-Int4" + assert got.id == remote.id + + def test_get_file_returns_the_raw_archive_bytes(self): + client, calls = self._client(SimpleNamespace(content=b"tar-bytes")) + + blob = client.get_file("main", "qwen38", "patch/a.txt") + + (call,) = calls + assert call.path == "/api/project/main/presets/get_file" + assert blob == b"tar-bytes" + + +class TestPushPresetToRegistry: + def _save_preset_with_file(self, store: PresetStore) -> str: + preset = get_preset() + preset_dir = store.root / preset.id + (preset_dir / "patch").mkdir(parents=True) + (preset_dir / "patch" / "a.txt").write_text("hello", encoding="utf-8") + preset.service.files = [ + FilePathMapping(local_path=str(preset_dir / "patch" / "a.txt"), path="/app/a.txt") + ] + store.save(preset) + return preset.id + + def test_pushes_a_relativized_document_without_the_local_name(self, tmp_path, stub_client): + store = PresetStore(tmp_path / "presets") + preset_id = self._save_preset_with_file(store) + + push_preset_to_registry(store, preset_id, "main/qwen") + + (request,) = stub_client.push_requests + assert request.project_name == "main" + assert request.name == "qwen" + # File contents travel as uploaded archives, referenced by id. + (upload,) = stub_client.files.uploads + assert request.spec.file_archives == [ + PresetArchiveMapping(id=upload.archive.id, path="patch/a.txt") + ] + assert _archive_member_texts(upload.content) == {"a.txt": "hello"} + document = request.spec.preset.model_dump(mode="json") + # The pushed document is the bare artifact: no identity, no creation + # context. + assert "id" not in document + assert "name" not in document + assert "configuration" not in document + assert "best_trial" not in document + assert document["service"]["files"] == [ + {"local_path": "patch/a.txt", "path": "/app/a.txt"} + ] + + def test_rejects_a_file_outside_the_preset_directory(self, tmp_path, stub_client): + store = PresetStore(tmp_path / "presets") + outside = tmp_path / "outside.txt" + outside.write_text("secret") + preset = get_preset() + preset.service.files = [FilePathMapping(local_path=str(outside), path="/app/a.txt")] + store.save(preset) + + with pytest.raises(CLIError, match="outside the preset directory"): + push_preset_to_registry(store, preset.id, "main/qwen") + assert stub_client.push_requests == [] + assert stub_client.files.uploads == [] + + def test_rejects_registry_auth_credentials_without_echoing_them(self, tmp_path, stub_client): + store = PresetStore(tmp_path / "presets") + preset = get_preset() + preset.service.registry_auth = RegistryAuth( + username="bot", password="canary-9f2kq-1234567890" + ) + store.save(preset) + + with pytest.raises(CLIError, match="registry_auth") as excinfo: + push_preset_to_registry(store, preset.id, "main/qwen") + + # The refusal must not become the leak it prevents. + assert "canary-9f2kq-1234567890" not in str(excinfo.value) + assert stub_client.push_requests == [] + assert stub_client.files.uploads == [] + + @pytest.mark.parametrize("name", ["Qwen", "-qwen", "q wen", "q"]) + def test_rejects_an_invalid_registry_name(self, tmp_path, stub_client, name): + store = PresetStore(tmp_path / "presets") + preset = get_preset() + store.save(preset) + + with pytest.raises(CLIError, match="Resource name"): + push_preset_to_registry(store, preset.id, f"main/{name}") + assert stub_client.push_requests == [] + + def test_rejects_a_uuid_registry_name(self, tmp_path, stub_client): + # A UUID name could later shadow another preset's id in ref resolution. + store = PresetStore(tmp_path / "presets") + preset = get_preset() + store.save(preset) + + with pytest.raises(CLIError, match="must not be a UUID"): + push_preset_to_registry(store, preset.id, "main/ab12cd34-ab12-4b12-8b12-ab12cd34ef56") + assert stub_client.push_requests == [] + + def test_rejects_a_missing_local_preset(self, tmp_path, stub_client): + with pytest.raises(CLIError, match="'nope' does not exist"): + push_preset_to_registry(PresetStore(tmp_path / "presets"), "nope", "main/qwen") + + @pytest.mark.parametrize( + "error", + [ + URLNotFoundError("Status code 404"), + # A server whose routes match the path with another method. + MethodNotAllowedError("Status code 405"), + ], + ids=["404", "405"], + ) + def test_maps_a_missing_endpoint_to_a_registry_support_error( + self, tmp_path, stub_client, error + ): + store = PresetStore(tmp_path / "presets") + preset_id = self._save_preset_with_file(store) + stub_client.error = error + + with pytest.raises(CLIError, match="does not support the preset registry"): + push_preset_to_registry(store, preset_id, "main/qwen") + + +class TestPullPresetFromRegistry: + def test_materializes_the_preset_under_its_qualified_name(self, tmp_path, stub_client): + store = PresetStore(tmp_path / "presets") + stub_client.remote = _registry_preset( + file_archives=[PresetArchiveMapping(id=uuid4(), path="patch/a.txt")], + file_mappings=[FilePathMapping(local_path="patch/a.txt", path="/app/a.txt")], + ) + stub_client.file_blobs["patch/a.txt"] = _file_archive_blob(tmp_path, "a.txt", "hello") + preset_id = str(stub_client.remote.id) + + pull_preset_from_registry(store, "main/qwen38") + + pulled = store.get(preset_id) + assert pulled is not None + # A pulled copy is the bare artifact plus its local identity. + assert isinstance(pulled, PulledPreset) + # The local name is the qualified ref, so it can never collide with a + # locally created preset. + assert pulled.name == "main/qwen38" + assert pulled.service.files[0].local_path == str( + store.root / preset_id / "patch" / "a.txt" + ) + assert (store.root / preset_id / "patch" / "a.txt").read_text() == "hello" + # Files are downloaded by id, not by the pulled ref: a name may repoint + # between requests. + (file_request,) = stub_client.file_requests + assert file_request.name_or_id == preset_id + assert file_request.path == "patch/a.txt" + # The saved file keeps the path relative, like any locally created preset. + saved = yaml.safe_load((store.root / preset_id / "preset.yml").read_text()) + assert saved["service"]["files"] == [{"local_path": "patch/a.txt", "path": "/app/a.txt"}] + + def test_does_not_touch_a_local_preset_with_the_unqualified_name(self, tmp_path, stub_client): + store = PresetStore(tmp_path / "presets") + local = get_preset().model_copy(update={"name": "qwen38"}) + store.save(local) + stub_client.remote = _registry_preset() + + pull_preset_from_registry(store, "main/qwen38") + + assert store.get(local.id).name == "qwen38" + assert store.get(str(stub_client.remote.id)).name == "main/qwen38" + + def test_repointing_a_name_releases_it_from_the_earlier_pull(self, tmp_path, stub_client): + store = PresetStore(tmp_path / "presets") + stub_client.remote = _registry_preset() + first_id = str(stub_client.remote.id) + pull_preset_from_registry(store, "main/qwen38") + + # The registry name now points at a newer version; re-pulling moves the + # qualified name to the fresh copy silently, Docker-style. + stub_client.remote = _registry_preset() + second_id = str(stub_client.remote.id) + pull_preset_from_registry(store, "main/qwen38") + + assert store.get(first_id).name is None + assert store.get(second_id).name == "main/qwen38" + assert store.find_by_name("main/qwen38").id == second_id + + def test_a_non_current_version_pulled_by_id_does_not_take_the_name( + self, tmp_path, stub_client + ): + store = PresetStore(tmp_path / "presets") + stub_client.remote = _registry_preset() + current_id = str(stub_client.remote.id) + pull_preset_from_registry(store, "main/qwen38") + + # An older version pulled by id lands untagged, like a Docker pull by + # digest: the qualified name stays on the current version's copy. + stub_client.remote = _registry_preset(is_current=False) + old_id = str(stub_client.remote.id) + pull_preset_from_registry(store, f"main/{old_id}") + + assert store.get(old_id).name is None + assert store.get(current_id).name == "main/qwen38" + assert store.find_by_name("main/qwen38").id == current_id + + def test_repulling_a_copy_by_id_keeps_the_name_it_already_holds(self, tmp_path, stub_client): + # This copy took the qualified name when it was current. Re-pulling it by + # id must not strip the name off the store, or local refs to it would + # stop resolving. + store = PresetStore(tmp_path / "presets") + stub_client.remote = _registry_preset() + preset_id = str(stub_client.remote.id) + pull_preset_from_registry(store, "main/qwen38") + + stub_client.remote = _registry_preset(is_current=False).model_copy( + update={"id": stub_client.remote.id} + ) + pull_preset_from_registry(store, f"main/{preset_id}") + + assert store.get(preset_id).name == "main/qwen38" + assert store.find_by_name("main/qwen38").id == preset_id + + def test_repulling_the_same_version_overwrites_in_place(self, tmp_path, stub_client): + store = PresetStore(tmp_path / "presets") + stub_client.remote = _registry_preset( + file_archives=[PresetArchiveMapping(id=uuid4(), path="patch/a.txt")], + file_mappings=[FilePathMapping(local_path="patch/a.txt", path="/app/a.txt")], + ) + stub_client.file_blobs["patch/a.txt"] = _file_archive_blob(tmp_path, "a.txt", "hello") + preset_id = str(stub_client.remote.id) + pull_preset_from_registry(store, "main/qwen38") + + stub_client.file_blobs["patch/a.txt"] = _file_archive_blob(tmp_path, "a.txt", "fresh") + pull_preset_from_registry(store, "main/qwen38") + + assert store.get(preset_id).name == "main/qwen38" + assert [path.parent.name for path in store.root.glob("*/preset.yml")] == [preset_id] + assert (store.root / preset_id / "patch" / "a.txt").read_text() == "fresh" + + def test_repulling_a_shrunken_file_set_does_not_keep_the_stale_file( + self, tmp_path, stub_client + ): + # The directory is replaced wholesale, so a file the preset no longer + # carries must not survive into the next push. + store = PresetStore(tmp_path / "presets") + remote_id = uuid4() + stub_client.remote = _registry_preset( + file_archives=[ + PresetArchiveMapping(id=uuid4(), path="patch/a.txt"), + PresetArchiveMapping(id=uuid4(), path="patch/b.txt"), + ], + file_mappings=[ + FilePathMapping(local_path="patch/a.txt", path="/app/a.txt"), + FilePathMapping(local_path="patch/b.txt", path="/app/b.txt"), + ], + ).model_copy(update={"id": remote_id}) + stub_client.file_blobs["patch/a.txt"] = _file_archive_blob(tmp_path, "a.txt", "hello") + stub_client.file_blobs["patch/b.txt"] = _file_archive_blob(tmp_path, "b.txt", "stale") + pull_preset_from_registry(store, "main/qwen38") + preset_id = str(remote_id) + assert (store.root / preset_id / "patch" / "b.txt").exists() + + stub_client.remote = _registry_preset( + file_archives=[PresetArchiveMapping(id=uuid4(), path="patch/a.txt")], + file_mappings=[FilePathMapping(local_path="patch/a.txt", path="/app/a.txt")], + ).model_copy(update={"id": remote_id}) + pull_preset_from_registry(store, "main/qwen38") + + assert (store.root / preset_id / "patch" / "a.txt").read_text() == "hello" + assert not (store.root / preset_id / "patch" / "b.txt").exists() + saved = yaml.safe_load((store.root / preset_id / "preset.yml").read_text()) + assert saved["service"]["files"] == [{"local_path": "patch/a.txt", "path": "/app/a.txt"}] + + def test_rejects_a_traversing_file_path_before_writing(self, tmp_path, stub_client): + store = PresetStore(tmp_path / "presets") + stub_client.remote = _registry_preset( + file_archives=[PresetArchiveMapping(id=uuid4(), path="../evil.txt")], + file_mappings=[FilePathMapping(local_path="../evil.txt", path="/app/a.txt")], + ) + + with pytest.raises(CLIError, match="Invalid preset file path"): + pull_preset_from_registry(store, "main/qwen38") + assert not (store.root / str(stub_client.remote.id)).exists() + assert not (tmp_path / "evil.txt").exists() + assert stub_client.file_requests == [] + + @pytest.mark.parametrize( + ("paths", "error"), + [ + # The store keeps the preset document at this path. + (["preset.yml"], "reserved"), + # `a` and `a/b` cannot both materialize on one filesystem. + (["patch", "patch/a.txt"], "both a file and a directory"), + # `:` covers Windows drive letters and is invalid on Windows targets. + (["patch:a.txt"], "Invalid preset file path"), + (["patch/../a.txt"], "Invalid preset file path"), + (["/etc/a.txt"], "Invalid preset file path"), + ], + ) + def test_rejects_invalid_pulled_file_paths_before_writing( + self, tmp_path, stub_client, paths, error + ): + store = PresetStore(tmp_path / "presets") + stub_client.remote = _registry_preset( + file_archives=[PresetArchiveMapping(id=uuid4(), path=path) for path in paths], + ) + + with pytest.raises(CLIError, match=error): + pull_preset_from_registry(store, "main/qwen38") + assert not (store.root / str(stub_client.remote.id)).exists() + assert stub_client.file_requests == [] + + def test_rejects_a_mapping_not_included_in_the_pulled_files(self, tmp_path, stub_client): + store = PresetStore(tmp_path / "presets") + stub_client.remote = _registry_preset( + file_mappings=[FilePathMapping(local_path="patch/a.txt", path="/app/a.txt")], + ) + + with pytest.raises(CLIError, match="missing from the push"): + pull_preset_from_registry(store, "main/qwen38") + + def test_rejects_a_pulled_file_the_preset_does_not_reference(self, tmp_path, stub_client): + store = PresetStore(tmp_path / "presets") + stub_client.remote = _registry_preset( + file_archives=[PresetArchiveMapping(id=uuid4(), path="patch/a.txt")], + ) + + with pytest.raises(CLIError, match="not referenced"): + pull_preset_from_registry(store, "main/qwen38") + assert not (store.root / str(stub_client.remote.id)).exists() + + def test_maps_a_missing_preset_to_a_qualified_does_not_exist_error( + self, tmp_path, stub_client + ): + # The server answers 400 + `resource_not_exists`, which the shared client + # maps; the CLI names the qualified ref the user typed, not the bare one. + stub_client.error = ResourceNotExistsError("Preset 'qwen38' does not exist") + + with pytest.raises(CLIError, match="'main/qwen38' does not exist"): + pull_preset_from_registry(PresetStore(tmp_path / "presets"), "main/qwen38") + + @pytest.mark.parametrize( + "error", + [ + URLNotFoundError("Status code 404"), + # A server whose routes match the path with another method. + MethodNotAllowedError("Status code 405"), + ], + ids=["404", "405"], + ) + def test_maps_a_missing_endpoint_to_a_registry_support_error( + self, tmp_path, stub_client, error + ): + stub_client.error = error + + with pytest.raises(CLIError, match="does not support the preset registry"): + pull_preset_from_registry(PresetStore(tmp_path / "presets"), "main/qwen38") + + +class TestPulledArchiveMembers: + """A registry archive is an opaque blob the pusher controls: extraction must + never write outside the file it is materializing.""" + + def _pull_hostile(self, tmp_path, stub_client, blob: bytes) -> PresetStore: + store = PresetStore(tmp_path / "presets") + stub_client.remote = _registry_preset( + file_archives=[PresetArchiveMapping(id=uuid4(), path="patch/a.txt")], + file_mappings=[FilePathMapping(local_path="patch/a.txt", path="/app/a.txt")], + ) + stub_client.file_blobs["patch/a.txt"] = blob + return store + + def _assert_nothing_escaped(self, tmp_path, store: PresetStore, stub_client) -> None: + # The failed pull leaves neither the escapee nor a half-materialized + # directory without its preset document. + assert not (store.root / str(stub_client.remote.id)).exists() + assert not (tmp_path / "evil.txt").exists() + assert not (tmp_path.parent / "evil.txt").exists() + assert not (store.root / "evil.txt").exists() + assert list(store.list()) == [] + + def test_rejects_a_traversing_member_path(self, tmp_path, stub_client): + store = self._pull_hostile( + tmp_path, stub_client, _hostile_archive_blob(_file_member("a.txt/../../evil.txt")) + ) + + with pytest.raises(CLIError, match="unsafe member path"): + pull_preset_from_registry(store, "main/qwen38") + + self._assert_nothing_escaped(tmp_path, store, stub_client) + + def test_rejects_a_symlink_member_escaping_the_directory(self, tmp_path, stub_client): + store = self._pull_hostile( + tmp_path, + stub_client, + _hostile_archive_blob(_symlink_member("a.txt", "../../../../evil.txt")), + ) + + with pytest.raises(CLIError, match="unsupported member type"): + pull_preset_from_registry(store, "main/qwen38") + + self._assert_nothing_escaped(tmp_path, store, stub_client) + + def test_rejects_a_member_not_rooted_at_the_mapping_basename(self, tmp_path, stub_client): + # Rooted inside the directory, but not at the file being materialized: + # extracting it would write a file the preset does not declare. + store = self._pull_hostile( + tmp_path, stub_client, _hostile_archive_blob(_file_member("evil.txt")) + ) + + with pytest.raises(CLIError, match="unexpected member"): + pull_preset_from_registry(store, "main/qwen38") + + self._assert_nothing_escaped(tmp_path, store, stub_client) + + def test_rejects_an_absolute_member_path(self, tmp_path, stub_client): + store = self._pull_hostile( + tmp_path, stub_client, _hostile_archive_blob(_file_member("/a.txt")) + ) + + with pytest.raises(CLIError, match="unsafe member path"): + pull_preset_from_registry(store, "main/qwen38") + + self._assert_nothing_escaped(tmp_path, store, stub_client) + + def test_rejects_a_hostile_member_alongside_a_legitimate_one(self, tmp_path, stub_client): + # Every member is checked before anything is extracted, so one bad member + # rejects the whole archive rather than landing after the good ones. + store = self._pull_hostile( + tmp_path, + stub_client, + _hostile_archive_blob(_file_member("a.txt"), _file_member("a.txt/../../evil.txt")), + ) + + with pytest.raises(CLIError, match="unsafe member path"): + pull_preset_from_registry(store, "main/qwen38") + + self._assert_nothing_escaped(tmp_path, store, stub_client) diff --git a/src/tests/_internal/cli/services/presets/test_store.py b/src/tests/_internal/cli/services/presets/test_store.py index 0ed4b2cdf..28bca01f5 100644 --- a/src/tests/_internal/cli/services/presets/test_store.py +++ b/src/tests/_internal/cli/services/presets/test_store.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from io import StringIO from pathlib import Path from unittest.mock import patch @@ -5,18 +6,30 @@ import pytest import yaml +from dstack._internal.cli.models.presets import PulledPreset, VerifiedPreset from dstack._internal.cli.services.presets import store as store_module from dstack._internal.cli.services.presets.store import PresetStore from dstack._internal.compat import IS_WINDOWS from dstack._internal.core.errors import CLIError, ConfigurationError +from dstack._internal.core.models.configurations import PresetConfiguration from dstack._internal.core.models.envs import EnvSentinel from dstack._internal.core.models.files import FilePathMapping -from dstack._internal.core.models.presets import PresetConfiguration +from dstack._internal.core.models.presets import PortablePreset from tests._internal.cli.common import get_preset pytestmark = pytest.mark.windows +def _get_pulled_preset() -> PulledPreset: + verified = get_preset() + return PulledPreset( + id="0b2b7b1e-9c1a-4a58-9d5a-3f6a1b2c3d4e", + name="main/qwen", + created_at=datetime(2026, 2, 3, 4, 5, tzinfo=timezone.utc), + **{field: getattr(verified, field) for field in PortablePreset.model_fields}, + ) + + class TestPresetStore: def test_saves_and_lists_self_contained_preset(self, tmp_path: Path): store = PresetStore(tmp_path / "presets") @@ -28,14 +41,155 @@ def test_saves_and_lists_self_contained_preset(self, tmp_path: Path): data = yaml.safe_load(path.read_text()) assert data["base"] == preset.base assert data["id"] == preset.id - assert data["model"] == preset.model - assert data["submitted_at"] == "2026-01-02T03:04:00Z" + assert data["repo"] == preset.repo + assert data["created_at"] == "2026-01-02T03:04:00Z" assert data["status"] == "verified" assert "presets" not in data assert store.list() == [preset] assert store.get(preset.id) == preset assert not list(path.parent.glob("*.tmp")) + def test_a_verified_document_loads_as_a_verified_preset(self, tmp_path: Path): + store = PresetStore(tmp_path / "presets") + store.save(get_preset()) + + loaded = store.get(get_preset().id) + + assert isinstance(loaded, VerifiedPreset) + assert loaded.status == "verified" + + def test_loads_a_preset_written_by_0_21(self, tmp_path: Path): + # A preset the released version wrote: the served repo under `model`, + # the date under `submitted_at`, and a `status` that already tagged it. + store = PresetStore(tmp_path / "presets") + preset = get_preset() + store.save(preset) + path = tmp_path / "presets" / preset.id / "preset.yml" + data = yaml.safe_load(path.read_text()) + data["model"] = data.pop("repo") + data["submitted_at"] = data.pop("created_at") + path.write_text(yaml.safe_dump(data, sort_keys=False)) + + loaded = store.get(preset.id) + + assert loaded == preset + + def test_loads_a_preset_that_predates_the_repo_field(self, tmp_path: Path): + # Presets written before the rename store the served repo as `model`. + store = PresetStore(tmp_path / "presets") + preset = get_preset() + store.save(preset) + path = tmp_path / "presets" / preset.id / "preset.yml" + data = yaml.safe_load(path.read_text()) + data["model"] = data.pop("repo") + path.write_text(yaml.safe_dump(data, sort_keys=False)) + + loaded = store.get(preset.id) + + assert loaded is not None + assert loaded.repo == preset.repo + assert loaded == preset + # The service's own client-facing model name is untouched by the upgrade. + assert loaded.service.model == preset.service.model + # Re-saving migrates the file to the current field name. + store.save(loaded) + assert "model" not in yaml.safe_load(path.read_text()) + + def test_keeps_repo_when_the_file_already_uses_it(self, tmp_path: Path): + store = PresetStore(tmp_path / "presets") + preset = get_preset() + store.save(preset) + + loaded = store.get(preset.id) + + assert loaded is not None + assert loaded.repo == preset.repo + + def test_a_pulled_document_roundtrips_as_a_pulled_preset(self, tmp_path: Path): + store = PresetStore(tmp_path / "presets") + pulled = _get_pulled_preset() + + store.save(pulled) + loaded = store.get(pulled.id) + + # `status` tags the document as a pulled copy, so it loads as one. + assert isinstance(loaded, PulledPreset) + assert loaded == pulled + assert store.list() == [pulled] + data = yaml.safe_load((tmp_path / "presets" / pulled.id / "preset.yml").read_text()) + assert "configuration" not in data + assert "best_trial" not in data + assert data["status"] == "pulled" + + def test_loads_a_preset_that_predates_the_created_at_field(self, tmp_path: Path): + # Presets written before the date was named for what it is store it as + # `submitted_at`. + store = PresetStore(tmp_path / "presets") + preset = get_preset() + store.save(preset) + path = tmp_path / "presets" / preset.id / "preset.yml" + data = yaml.safe_load(path.read_text()) + data["submitted_at"] = data.pop("created_at") + path.write_text(yaml.safe_dump(data, sort_keys=False)) + + loaded = store.get(preset.id) + + assert loaded is not None + assert loaded.created_at == preset.created_at + assert loaded == preset + + def test_loads_a_pulled_copy_written_before_the_status_tag(self, tmp_path: Path): + # Pulled copies predate the tag too, so the tag cannot simply default to + # "verified": it is inferred from whether the creation context is there. + store = PresetStore(tmp_path / "presets") + pulled = _get_pulled_preset() + store.save(pulled) + path = tmp_path / "presets" / pulled.id / "preset.yml" + data = yaml.safe_load(path.read_text()) + del data["status"] + path.write_text(yaml.safe_dump(data, sort_keys=False)) + + loaded = store.get(pulled.id) + + assert isinstance(loaded, PulledPreset) + assert loaded == pulled + + def test_loads_a_preset_written_before_the_status_tag(self, tmp_path: Path): + # Only local creations could be stored before `status` tagged the union. + store = PresetStore(tmp_path / "presets") + preset = get_preset() + store.save(preset) + path = tmp_path / "presets" / preset.id / "preset.yml" + data = yaml.safe_load(path.read_text()) + del data["status"] + path.write_text(yaml.safe_dump(data, sort_keys=False)) + + loaded = store.get(preset.id) + + assert isinstance(loaded, VerifiedPreset) + assert loaded == preset + + def test_reports_one_arms_errors_for_an_unreadable_preset(self, tmp_path: Path): + # An untagged union reported both arms' errors, which buried the actual + # problem in a wall of text about the shape the file never claimed to be. + store = PresetStore(tmp_path / "presets") + preset = get_preset() + store.save(preset) + path = tmp_path / "presets" / preset.id / "preset.yml" + data = yaml.safe_load(path.read_text()) + del data["benchmark"] + path.write_text(yaml.safe_dump(data, sort_keys=False)) + + with pytest.raises(CLIError) as excinfo: + store.get(preset.id) + + message = str(excinfo.value) + # Every reported error is located under the tag the file claims; the + # untagged union used to add the other arm's errors on top. + assert "1 validation error for" in message + assert "verified.benchmark" in message + assert "published." not in message + def test_saving_same_id_overwrites_existing_preset(self, tmp_path: Path): store = PresetStore(tmp_path / "presets") preset = get_preset() @@ -425,3 +579,22 @@ def test_finds_and_detaches_names(self, tmp_path: Path): assert released.id == named.id assert store.find_by_name("qwen") is None assert store.get(named.id).name is None + + def test_find_by_id_or_name_resolves_a_pulled_qualified_name(self, tmp_path: Path): + store = PresetStore(tmp_path / "presets") + # A pulled copy: UUID-named directory, qualified `/` name. + pulled = get_preset(preset_id="0b2b7b1e-9c1a-4a58-9d5a-3f6a1b2c3d4e").model_copy( + update={"name": "main/qwen"} + ) + store.save(pulled) + + # A qualified name is never id-shaped, so the id lookup must not + # reject it before the name lookup runs. + assert store.find_by_id_or_name("main/qwen").id == pulled.id + assert store.find_by_id_or_name(pulled.id) == store.get(pulled.id) + + def test_find_by_id_or_name_returns_none_for_an_unknown_qualified_ref(self, tmp_path: Path): + store = PresetStore(tmp_path / "presets") + store.save(get_preset()) + + assert store.find_by_id_or_name("main/unknown") is None diff --git a/src/tests/_internal/cli/services/presets/test_verify.py b/src/tests/_internal/cli/services/presets/test_verify.py index 018254351..014d32d58 100644 --- a/src/tests/_internal/cli/services/presets/test_verify.py +++ b/src/tests/_internal/cli/services/presets/test_verify.py @@ -17,9 +17,9 @@ ) from dstack._internal.core.errors import CLIError from dstack._internal.core.models.common import validate_extra_ignore +from dstack._internal.core.models.configurations import PresetConfiguration from dstack._internal.core.models.envs import EnvSentinel from dstack._internal.core.models.files import FilePathMapping -from dstack._internal.core.models.presets import PresetConfiguration from dstack._internal.core.models.profiles import ProfileParams from tests._internal.cli.common import ( get_preset, @@ -54,14 +54,14 @@ def test_stores_only_the_creation_contract_not_this_machine_s_deployment(self): service=base.service, verification_replica_groups=base.verified_on, base_model="Qwen/Qwen3.5-27B", - model="community/Qwen3.5-27B-GPTQ-Int4", + repo="community/Qwen3.5-27B-GPTQ-Int4", context_length=32768, benchmark=get_preset_benchmark(), configuration=configuration, best_trial=1, preset_id="8f3a12c4", name=None, - submitted_at=datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc), + created_at=datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc), ) assert preset.configuration.min_context_length == 32768 @@ -97,13 +97,13 @@ def test_builds_portable_self_contained_preset(self, tmp_path): session_path=tmp_path, preset_id="ab12cd34", name=None, - submitted_at=created_at, + created_at=created_at, ) assert preset.base == "Qwen/Qwen3.5-27B" - assert preset.model == "community/Qwen3.5-27B-GPTQ-Int4" + assert preset.repo == "community/Qwen3.5-27B-GPTQ-Int4" assert preset.context_length == 32768 - assert preset.submitted_at == created_at + assert preset.created_at == created_at assert preset.service.name is None assert preset.service.gateway is None assert all(getattr(preset.service, field) is None for field in ProfileParams.model_fields) @@ -138,7 +138,7 @@ def test_rewrites_file_paths_onto_the_mirrored_session_copies(self, tmp_path): session_path=session, preset_id="ab12cd34", name=None, - submitted_at=datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc), + created_at=datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc), ) assert preset.service.files[0].local_path == "service/1/patches" @@ -167,7 +167,7 @@ def test_rejects_a_file_without_a_mirrored_copy(self, tmp_path): session_path=session, preset_id="ab12cd34", name=None, - submitted_at=datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc), + created_at=datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc), ) def test_rejects_benchmark_on_a_different_dataset(self, tmp_path): @@ -188,7 +188,7 @@ def test_rejects_benchmark_on_a_different_dataset(self, tmp_path): session_path=tmp_path, preset_id="ab12cd34", name=None, - submitted_at=datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc), + created_at=datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc), ) def test_rejects_variant_for_exact_model_request(self, tmp_path): @@ -210,7 +210,7 @@ def test_rejects_variant_for_exact_model_request(self, tmp_path): session_path=tmp_path, preset_id="ab12cd34", name=None, - submitted_at=datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc), + created_at=datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc), ) diff --git a/src/tests/_internal/core/models/test_configurations.py b/src/tests/_internal/core/models/test_configurations.py index 77caaaa09..d80590be8 100644 --- a/src/tests/_internal/core/models/test_configurations.py +++ b/src/tests/_internal/core/models/test_configurations.py @@ -2,13 +2,16 @@ from typing import Any, Optional, Union import pytest -from pydantic import model_validator +from pydantic import ValidationError, model_validator from typing_extensions import Self from dstack._internal.core.errors import ConfigurationError from dstack._internal.core.models.common import CoreModel, RegistryAuth, validate_extra_ignore from dstack._internal.core.models.configurations import ( DevEnvironmentConfigurationParams, + PresetConfiguration, + PresetModelBase, + PresetModelRepo, PythonVersion, RepoSpec, ServiceConfiguration, @@ -1231,3 +1234,106 @@ def test_parse_does_not_mutate_caller_dict(self): parse_run_configuration(conf) assert conf == original assert conf["replicas"][0]["count"] == 1 + + +class TestPresetConfiguration: + def test_schema_documents_supported_input(self): + assert all(field.description for field in PresetConfiguration.model_fields.values()) + assert all(field.description for field in PresetModelBase.model_fields.values()) + assert all(field.description for field in PresetModelRepo.model_fields.values()) + assert {"type": "string"} in PresetConfiguration.model_json_schema()["properties"][ + "model" + ]["anyOf"] + + def test_parses_string_as_exact_repo(self): + configuration = PresetConfiguration(model="Qwen/Qwen3.5-27B") + + assert isinstance(configuration.model, PresetModelRepo) + assert configuration.model.exact_repo == "Qwen/Qwen3.5-27B" + assert configuration.model.api_model_name == "Qwen/Qwen3.5-27B" + assert not configuration.model.allows_variant_selection + + def test_parses_base_model(self): + configuration = PresetConfiguration(base="Qwen/Qwen3.5-27B") + + assert isinstance(configuration.model, PresetModelBase) + assert configuration.model.exact_repo is None + assert configuration.model.api_model_name == "Qwen/Qwen3.5-27B" + assert configuration.model.allows_variant_selection + + def test_parses_exact_repo_with_client_facing_name(self): + configuration = PresetConfiguration( + model={ + "repo": "community/Qwen3.5-27B-GPTQ-Int4", + "name": "Qwen/Qwen3.5-27B", + } + ) + + assert configuration.model.exact_repo == "community/Qwen3.5-27B-GPTQ-Int4" + assert configuration.model.api_model_name == "Qwen/Qwen3.5-27B" + + def test_rejects_ambiguous_model_object(self): + with pytest.raises(ValidationError): + PresetConfiguration(model={"base": "Qwen/base", "repo": "Qwen/repo"}) + + def test_parses_top_level_base_shorthand(self): + configuration = PresetConfiguration(base="Qwen/Qwen3.5-27B") + + assert isinstance(configuration.model, PresetModelBase) + assert configuration.model.api_model_name == "Qwen/Qwen3.5-27B" + assert configuration.base is None + + def test_parses_top_level_repo_shorthand(self): + configuration = PresetConfiguration(repo="community/Qwen3.5-27B-GPTQ-Int4") + + assert isinstance(configuration.model, PresetModelRepo) + assert configuration.model.exact_repo == "community/Qwen3.5-27B-GPTQ-Int4" + assert configuration.repo is None + + def test_shorthand_round_trips_through_dict(self): + configuration = PresetConfiguration(base="Qwen/Qwen3.5-27B") + + round_tripped = PresetConfiguration.model_validate(configuration.model_dump()) + + assert round_tripped.model == configuration.model + + def test_rejects_combined_base_and_repo_shorthand(self): + with pytest.raises(ValidationError): + PresetConfiguration(base="Qwen/base", repo="Qwen/repo") + + def test_rejects_shorthand_combined_with_model(self): + with pytest.raises(ValidationError, match="cannot be combined"): + PresetConfiguration(base="Qwen/base", model={"repo": "Qwen/repo"}) + + def test_requires_model(self): + with pytest.raises(ValidationError): + PresetConfiguration() + + @pytest.mark.parametrize("field", ["input_tokens", "output_tokens", "shared_prefix_tokens"]) + def test_rejects_request_shape_fields_with_a_custom_dataset(self, field): + with pytest.raises(ValidationError, match="only be set with the `random` dataset"): + PresetConfiguration(base="Qwen/Qwen3.5-27B", dataset="spec_bench", **{field: 512}) + + def test_allows_request_shape_fields_with_the_random_dataset(self): + configuration = PresetConfiguration( + base="Qwen/Qwen3.5-27B", dataset="random", input_tokens=1024, output_tokens=256 + ) + + assert configuration.input_tokens == 1024 + assert configuration.output_tokens == 256 + + def test_defaults_to_the_random_dataset(self): + configuration = PresetConfiguration(base="Qwen/Qwen3.5-27B") + + assert configuration.dataset is None + assert configuration.effective_dataset == "random" + + +class TestPresetConfigurationSchema: + def test_schema_does_not_require_model(self): + # `model` is filled from the `base`/`repo` shorthand by a before-validator, + # which JSON Schema consumers (IDEs) never run. + schema = PresetConfiguration.model_json_schema() + assert "model" not in schema.get("required", []) + for field in ("model", "base", "repo"): + assert field in schema["properties"] diff --git a/src/tests/_internal/core/models/test_presets.py b/src/tests/_internal/core/models/test_presets.py index f6b1d553c..edd72cbae 100644 --- a/src/tests/_internal/core/models/test_presets.py +++ b/src/tests/_internal/core/models/test_presets.py @@ -1,113 +1,137 @@ +from typing import Any, Dict, List, Optional + import pytest from pydantic import ValidationError from dstack._internal.core.models.presets import ( - PresetConfiguration, - PresetModelBase, - PresetModelRepo, + PresetBenchmark, + PresetRandomWorkload, + PresetWorkload, + validate_preset_file_path, + validate_preset_file_paths, ) -pytestmark = pytest.mark.windows - - -class TestPresetConfiguration: - def test_schema_documents_supported_input(self): - assert all(field.description for field in PresetConfiguration.model_fields.values()) - assert all(field.description for field in PresetModelBase.model_fields.values()) - assert all(field.description for field in PresetModelRepo.model_fields.values()) - assert {"type": "string"} in PresetConfiguration.model_json_schema()["properties"][ - "model" - ]["anyOf"] - - def test_parses_string_as_exact_repo(self): - configuration = PresetConfiguration(model="Qwen/Qwen3.5-27B") - - assert isinstance(configuration.model, PresetModelRepo) - assert configuration.model.exact_repo == "Qwen/Qwen3.5-27B" - assert configuration.model.api_model_name == "Qwen/Qwen3.5-27B" - assert not configuration.model.allows_variant_selection - - def test_parses_base_model(self): - configuration = PresetConfiguration(base="Qwen/Qwen3.5-27B") - assert isinstance(configuration.model, PresetModelBase) - assert configuration.model.exact_repo is None - assert configuration.model.api_model_name == "Qwen/Qwen3.5-27B" - assert configuration.model.allows_variant_selection +def get_benchmark_data(workload: Dict[str, Any]) -> Dict[str, Any]: + return { + "tool": "vllm bench serve", + "tool_version": "0.11.0", + "command": "vllm bench serve --base-url $SERVICE_URL", + "workload": workload, + "metrics": { + "successful_requests": 16, + "failed_requests": 0, + "duration_seconds": 48.64, + "total_input_tokens": 16384, + "total_output_tokens": 2048, + "output_tok_per_s": 42.1, + "per_user_tok_per_s": 42.1, + "ttft_ms": {"mean": 110.9, "p50": 108.2, "p99": 121.6}, + "tpot_ms": {"mean": 7.5, "p50": 7.4, "p99": 8.1}, + }, + } + + +def get_workload_data(**overrides: Any) -> Dict[str, Any]: + workload: Dict[str, Any] = { + "api": "chat_completions", + "num_requests": 16, + "input_tokens": 1024, + "output_tokens": 128, + "concurrency": 1, + } + workload.update(overrides) + return workload + + +class TestValidatePresetFilePaths: + """The single owner of the preset file rules: push (client and server) and + pull all go through it, so every rule is pinned here once.""" + + @pytest.mark.parametrize( + ("paths", "error"), + [ + # Per-path rules. + pytest.param([""], "must be a relative POSIX path", id="empty-path"), + pytest.param(["/etc/passwd"], "must be a relative POSIX path", id="absolute"), + pytest.param(["patch\\a.txt"], "must be a relative POSIX path", id="backslash"), + # `:` covers Windows drive letters and is invalid on Windows targets. + pytest.param(["c:a.txt"], "must be a relative POSIX path", id="colon"), + pytest.param(["../a.txt"], "must be a relative POSIX path", id="leading-parent"), + pytest.param(["patch/../a.txt"], "must be a relative POSIX path", id="inner-parent"), + pytest.param(["./a.txt"], "must be a relative POSIX path", id="leading-current"), + pytest.param(["patch/./a.txt"], "must be a relative POSIX path", id="inner-current"), + pytest.param(["patch//a.txt"], "must be a relative POSIX path", id="empty-segment"), + pytest.param(["patch/"], "must be a relative POSIX path", id="trailing-slash"), + # The local store keeps the preset document at this path. + pytest.param(["preset.yml"], "the name is reserved", id="reserved"), + # Whole-list rules. + pytest.param( + ["patch/a.txt", "patch/a.txt"], + "Duplicate preset file path", + id="duplicate", + ), + # `a` and `a/b` cannot both materialize on one filesystem, in either + # order. + pytest.param( + ["patch", "patch/a.txt"], + "both a file and a directory", + id="file-before-directory", + ), + pytest.param( + ["patch/a.txt", "patch"], + "both a file and a directory", + id="directory-before-file", + ), + # Accepted. + pytest.param([], None, id="accepts-no-files"), + pytest.param(["a.txt"], None, id="accepts-plain-file"), + pytest.param(["patch/nested/a.txt"], None, id="accepts-nested-file"), + pytest.param([".env"], None, id="accepts-dotfile"), + pytest.param(["patch/a.txt", "patch/b.txt"], None, id="accepts-siblings"), + # Only the exact reserved path is reserved. + pytest.param(["preset.yaml", "patch/preset.yml"], None, id="accepts-near-reserved"), + ], + ) + def test_applies_every_rule(self, paths: List[str], error: Optional[str]): + if error is None: + validate_preset_file_paths(paths) + for path in paths: + validate_preset_file_path(path) + return + with pytest.raises(ValueError, match=error): + validate_preset_file_paths(paths) + if len(paths) == 1: + # A per-path rule must reject through either entry point, since push + # checks single paths (`service.files`) and lists (the archives). + with pytest.raises(ValueError, match=error): + validate_preset_file_path(paths[0]) + + +class TestPresetBenchmarkWorkload: + """`api` and `dataset` are Literals, not plain strings: the agent-facing JSON + schema is generated from these models, and `dataset` is what discriminates + the workload union.""" + + def test_rejects_an_unsupported_api(self): + data = get_benchmark_data(get_workload_data(api="embeddings")) - def test_parses_exact_repo_with_client_facing_name(self): - configuration = PresetConfiguration( - model={ - "repo": "community/Qwen3.5-27B-GPTQ-Int4", - "name": "Qwen/Qwen3.5-27B", - } - ) - - assert configuration.model.exact_repo == "community/Qwen3.5-27B-GPTQ-Int4" - assert configuration.model.api_model_name == "Qwen/Qwen3.5-27B" - - def test_rejects_ambiguous_model_object(self): with pytest.raises(ValidationError): - PresetConfiguration(model={"base": "Qwen/base", "repo": "Qwen/repo"}) - - def test_parses_top_level_base_shorthand(self): - configuration = PresetConfiguration(base="Qwen/Qwen3.5-27B") - - assert isinstance(configuration.model, PresetModelBase) - assert configuration.model.api_model_name == "Qwen/Qwen3.5-27B" - assert configuration.base is None - - def test_parses_top_level_repo_shorthand(self): - configuration = PresetConfiguration(repo="community/Qwen3.5-27B-GPTQ-Int4") - - assert isinstance(configuration.model, PresetModelRepo) - assert configuration.model.exact_repo == "community/Qwen3.5-27B-GPTQ-Int4" - assert configuration.repo is None - - def test_shorthand_round_trips_through_dict(self): - configuration = PresetConfiguration(base="Qwen/Qwen3.5-27B") - - round_tripped = PresetConfiguration.model_validate(configuration.model_dump()) - - assert round_tripped.model == configuration.model - - def test_rejects_combined_base_and_repo_shorthand(self): - with pytest.raises(ValidationError): - PresetConfiguration(base="Qwen/base", repo="Qwen/repo") - - def test_rejects_shorthand_combined_with_model(self): - with pytest.raises(ValidationError, match="cannot be combined"): - PresetConfiguration(base="Qwen/base", model={"repo": "Qwen/repo"}) - - def test_requires_model(self): - with pytest.raises(ValidationError): - PresetConfiguration() - - @pytest.mark.parametrize("field", ["input_tokens", "output_tokens", "shared_prefix_tokens"]) - def test_rejects_request_shape_fields_with_a_custom_dataset(self, field): - with pytest.raises(ValidationError, match="only be set with the `random` dataset"): - PresetConfiguration(base="Qwen/Qwen3.5-27B", dataset="spec_bench", **{field: 512}) + PresetBenchmark.model_validate(data) - def test_allows_request_shape_fields_with_the_random_dataset(self): - configuration = PresetConfiguration( - base="Qwen/Qwen3.5-27B", dataset="random", input_tokens=1024, output_tokens=256 - ) + def test_parses_a_dataset_workload_as_the_base_type(self): + data = get_benchmark_data(get_workload_data(dataset="sharegpt")) - assert configuration.input_tokens == 1024 - assert configuration.output_tokens == 256 + benchmark = PresetBenchmark.model_validate(data) - def test_defaults_to_the_random_dataset(self): - configuration = PresetConfiguration(base="Qwen/Qwen3.5-27B") + assert type(benchmark.workload) is PresetWorkload + assert benchmark.workload.dataset == "sharegpt" - assert configuration.dataset is None - assert configuration.effective_dataset == "random" + def test_parses_a_workload_without_a_dataset_as_random(self): + data = get_benchmark_data(get_workload_data()) + benchmark = PresetBenchmark.model_validate(data) -class TestPresetConfigurationSchema: - def test_schema_does_not_require_model(self): - # `model` is filled from the `base`/`repo` shorthand by a before-validator, - # which JSON Schema consumers (IDEs) never run. - schema = PresetConfiguration.model_json_schema() - assert "model" not in schema.get("required", []) - for field in ("model", "base", "repo"): - assert field in schema["properties"] + assert type(benchmark.workload) is PresetRandomWorkload + assert benchmark.workload.dataset == "random" + assert benchmark.workload.shared_prefix_tokens == 0 From ca7181c8debf22438393ec9b9e1caddc40845fcf Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Sun, 23 Aug 2026 15:41:11 +0200 Subject: [PATCH 2/9] Point preset schema references at their new module `PresetConfiguration` and its neighbours moved to `configurations.py`, but the reference page still imported them from `core.models.presets`, which failed the docs build. Co-Authored-By: Claude Opus 5 (1M context) --- mkdocs/docs/reference/dstack.yml/preset.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mkdocs/docs/reference/dstack.yml/preset.md b/mkdocs/docs/reference/dstack.yml/preset.md index 1ce978304..c4de1a0f3 100644 --- a/mkdocs/docs/reference/dstack.yml/preset.md +++ b/mkdocs/docs/reference/dstack.yml/preset.md @@ -5,7 +5,7 @@ used to create or apply a [preset](../../concepts/presets.md). ## Root reference -#SCHEMA# dstack._internal.core.models.presets.PresetConfiguration +#SCHEMA# dstack._internal.core.models.configurations.PresetConfiguration overrides: show_root_heading: false type: @@ -17,7 +17,7 @@ used to create or apply a [preset](../../concepts/presets.md). Allows the creation agent to select a compatible model variant. - #SCHEMA# dstack._internal.core.models.presets.PresetModelBase + #SCHEMA# dstack._internal.core.models.configurations.PresetModelBase overrides: show_root_heading: false @@ -26,7 +26,7 @@ used to create or apply a [preset](../../concepts/presets.md). Requires an exact model repo or path and optionally sets another client-facing model name. - #SCHEMA# dstack._internal.core.models.presets.PresetModelRepo + #SCHEMA# dstack._internal.core.models.configurations.PresetModelRepo overrides: show_root_heading: false @@ -34,7 +34,7 @@ used to create or apply a [preset](../../concepts/presets.md). Custom agent instructions. Set to an inline string, or to a file: -#SCHEMA# dstack._internal.core.models.presets.PresetPromptFile +#SCHEMA# dstack._internal.core.models.configurations.PresetPromptFile overrides: show_root_heading: false From 650abb40cf96030310170e87411eca9ed3de493b Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 24 Aug 2026 14:16:01 +0200 Subject: [PATCH 3/9] Stream a pulled preset's files in one request `get_files` replaces `get_file`: one request returns every archive the preset carries, framed by path and length, so a pull costs one round trip and one preset lookup however many files there are. The archives are also keyed by their path in the container now, which is what `RunSpec` carries, so `PresetArchiveMapping` is gone and the envelope is the same as a run's. Where each archive lands on disk is what the preset's own `files` already say. Co-Authored-By: Claude Opus 5 (1M context) --- .../cli/services/presets/registry.py | 42 ++++++--- src/dstack/_internal/core/models/presets.py | 45 ++++----- .../_internal/server/schemas/presets.py | 16 ++-- src/dstack/api/server/_presets.py | 53 ++++++++++- .../cli/services/presets/test_registry.py | 92 ++++++++++++------- 5 files changed, 165 insertions(+), 83 deletions(-) diff --git a/src/dstack/_internal/cli/services/presets/registry.py b/src/dstack/_internal/cli/services/presets/registry.py index 1f1d4bbf6..5ff738736 100644 --- a/src/dstack/_internal/cli/services/presets/registry.py +++ b/src/dstack/_internal/cli/services/presets/registry.py @@ -16,9 +16,9 @@ ServerClientError, URLNotFoundError, ) +from dstack._internal.core.models.files import FileArchiveMapping from dstack._internal.core.models.presets import ( PortablePreset, - PresetArchiveMapping, PresetSpec, validate_preset_spec_files, validate_preset_spec_limits, @@ -84,20 +84,26 @@ def push_preset_to_registry(store: PresetStore, local_ref: str, registry_ref: st artifact = PortablePreset( **{field: getattr(preset, field) for field in PortablePreset.model_fields} ).model_copy(deep=True) + # A pushed preset must carry file paths that mean something on the machine + # that pulls it, so the local side is re-rooted at the preset directory. sources: dict[str, str] = {} for mapping in artifact.service.files: relative = _relative_pushed_path(mapping.local_path, preset_dir) sources.setdefault(relative, mapping.local_path) mapping.local_path = relative client = resolve_registry_client(project) - # File contents travel as file archives, the same mechanism run `files` - # use: content-addressed, deduplicated per user, off-loaded to blob storage - # where the server has one. + # File contents travel as file archives, keyed by the path in the container, + # exactly as a run submits them - the same content-addressed, per-user + # deduplicated, storage-offloaded mechanism. Uploading once per distinct file + # is the only difference: a preset may mount one file at several paths. + archive_ids = { + relative: _upload_archive(client, local_path) for relative, local_path in sources.items() + } spec = PresetSpec( preset=artifact, file_archives=[ - PresetArchiveMapping(id=_upload_archive(client, local_path), path=relative) - for relative, local_path in sources.items() + FileArchiveMapping(id=archive_ids[mapping.local_path], path=mapping.path) + for mapping in artifact.service.files ], ) # The same rules the server enforces, checked here to fail before pushing. @@ -131,7 +137,6 @@ def pull_preset_from_registry(store: PresetStore, registry_ref: str) -> None: # The local identity of a pulled preset is its registry id, so re-pulling # the same preset overwrites its own copy in place. preset_id = str(remote.id) - file_archives = remote.spec.file_archives # The same rules the server enforces on push, re-checked before anything is # written locally: relative POSIX, no traversal, no reserved names, no # file/directory conflicts, and files and references matching exactly. @@ -170,11 +175,24 @@ def pull_preset_from_registry(store: PresetStore, registry_ref: str) -> None: # carries must not survive into the next push. shutil.rmtree(directory, ignore_errors=True) directory.mkdir(parents=True) - for mapping in file_archives: - # Downloaded by id, not by the pulled ref: a name may repoint - # between requests, and the files must be this preset's. - blob = client.presets.get_file(project, preset_id, mapping.path) - _extract_archive(blob, directory / PurePosixPath(mapping.path)) + # Downloaded by id, not by the pulled ref: a name may repoint between + # requests, and the files must be this preset's. One request streams + # them all, and each is extracted as it arrives. + # + # Archives arrive keyed by their path in the container, as a run's are; + # where each lands on disk is what the preset's `files` already say. + local_paths = {mapping.path: mapping.local_path for mapping in pulled.service.files} + expected = set(local_paths) + for path, blob in client.presets.get_files(project, preset_id): + local_path = local_paths.get(path) + if local_path is None: + raise CLIError(f"Preset {registry_ref!r} carries an unexpected file {path!r}") + expected.discard(path) + _extract_archive(blob, directory / PurePosixPath(local_path)) + if expected: + raise CLIError( + f"Preset {registry_ref!r} is missing files: {', '.join(sorted(expected))}" + ) # Absolute paths under the preset directory, as after a load; save # re-relativizes them so the stored file stays portable. for mapping in pulled.service.files: diff --git a/src/dstack/_internal/core/models/presets.py b/src/dstack/_internal/core/models/presets.py index 6e578281d..ab37cc6c8 100644 --- a/src/dstack/_internal/core/models/presets.py +++ b/src/dstack/_internal/core/models/presets.py @@ -1,6 +1,5 @@ import re from typing import Any, List, Literal, Optional, Sequence, Union -from uuid import UUID from pydantic import Field, PositiveFloat, PositiveInt, field_validator, model_validator from typing_extensions import Annotated, Self @@ -10,6 +9,7 @@ PresetModelSpec, ServiceConfiguration, ) +from dstack._internal.core.models.files import FileArchiveMapping from dstack._internal.core.models.profiles import ProfileParams from dstack._internal.core.models.resources import Range, ResourcesSpec @@ -226,32 +226,18 @@ def validate_preset_file_paths(paths: Sequence[str]) -> None: ) -class PresetArchiveMapping(CoreModel): - """One file (or directory) of the preset, stored as a file archive — the - same mechanism run `files` use.""" - - id: Annotated[UUID, Field(description="The file archive ID")] - path: Annotated[ - str, - Field( - description=( - "The preset-directory-relative POSIX path," - " as referenced by the preset service's `files`" - ) - ), - ] - - class PresetSpec(CoreModel): """A preset with its files, as `RunSpec` carries a configuration with its file archives.""" preset: PortablePreset file_archives: Annotated[ - List[PresetArchiveMapping], + List[FileArchiveMapping], Field( description=( - "The files referenced by the preset service's `files`, as uploaded file archives" + "The files referenced by the preset service's `files`, as uploaded file" + " archives, keyed by their path in the container exactly as `RunSpec`" + " carries them" ) ), ] = [] @@ -261,19 +247,22 @@ def validate_preset_spec_files(spec: PresetSpec) -> None: """Raises ValueError. The single owner of the file rules: push (server and client) and pull all call this, so what one side accepts the other can always materialize.""" - validate_preset_file_paths([mapping.path for mapping in spec.file_archives]) - referenced_paths = set() - for mapping in spec.preset.service.files: - local_path = mapping.local_path - validate_preset_file_path(local_path) - referenced_paths.add(local_path) - pushed_paths = {mapping.path for mapping in spec.file_archives} - missing_paths = referenced_paths - pushed_paths + # `local_path` is what pull writes to disk, so it carries the rules. The + # archives are keyed by the container path, as they are for a run, and only + # have to line up with the service's files. + local_paths = [mapping.local_path for mapping in spec.preset.service.files] + validate_preset_file_paths(sorted(set(local_paths))) + referenced_paths = {mapping.path for mapping in spec.preset.service.files} + pushed_paths = [mapping.path for mapping in spec.file_archives] + duplicate_paths = {path for path in pushed_paths if pushed_paths.count(path) > 1} + if duplicate_paths: + raise ValueError(f"Duplicate pushed files: {sorted(duplicate_paths)}") + missing_paths = referenced_paths - set(pushed_paths) if missing_paths: raise ValueError( f"Files referenced by the preset are missing from the push: {sorted(missing_paths)}" ) - unreferenced_paths = pushed_paths - referenced_paths + unreferenced_paths = set(pushed_paths) - referenced_paths if unreferenced_paths: raise ValueError( f"Pushed files are not referenced by the preset: {sorted(unreferenced_paths)}" diff --git a/src/dstack/_internal/server/schemas/presets.py b/src/dstack/_internal/server/schemas/presets.py index da6e0fdfc..f8a4162cd 100644 --- a/src/dstack/_internal/server/schemas/presets.py +++ b/src/dstack/_internal/server/schemas/presets.py @@ -20,11 +20,8 @@ class GetPresetRequest(CoreModel): ] -class GetPresetFileRequest(CoreModel): +class GetPresetFilesRequest(CoreModel): name_or_id: Annotated[str, Field(description="The preset id or name")] - path: Annotated[ - str, Field(description="The preset-directory-relative path of the file to download") - ] class PushPresetResponse(CoreModel): @@ -48,7 +45,14 @@ class PushPresetResponse(CoreModel): class GetPresetResponse(PushPresetResponse): - """What `get` returns: the record plus the stored spec. File contents are - downloaded separately per archive mapping.""" + """What `get` returns: the record plus the stored spec. File contents come + from `get_files`, which streams them all in one response.""" spec: PresetSpec + + +# `get_files` streams archives back to back, each framed by its path and length, +# so neither side ever holds more than one archive: a 4-byte path length, the +# UTF-8 path, an 8-byte content length, then the archive. +PRESET_FILES_PATH_LENGTH_BYTES = 4 +PRESET_FILES_CONTENT_LENGTH_BYTES = 8 diff --git a/src/dstack/api/server/_presets.py b/src/dstack/api/server/_presets.py index a097f6942..432155d4c 100644 --- a/src/dstack/api/server/_presets.py +++ b/src/dstack/api/server/_presets.py @@ -1,7 +1,12 @@ +from typing import Iterator, Tuple + +from dstack._internal.core.errors import ClientError from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.presets import PresetSpec from dstack._internal.server.schemas.presets import ( - GetPresetFileRequest, + PRESET_FILES_CONTENT_LENGTH_BYTES, + PRESET_FILES_PATH_LENGTH_BYTES, + GetPresetFilesRequest, GetPresetRequest, GetPresetResponse, PushPresetRequest, @@ -9,6 +14,8 @@ ) from dstack.api.server._group import APIClientGroup +_STREAM_CHUNK_SIZE = 64 * 1024 + class PresetsAPIClient(APIClientGroup): def push(self, project_name: str, name: str, spec: PresetSpec) -> PushPresetResponse: @@ -25,9 +32,45 @@ def get(self, project_name: str, name_or_id: str) -> GetPresetResponse: ) return validate_extra_ignore(GetPresetResponse, resp.json()) - def get_file(self, project_name: str, name_or_id: str, path: str) -> bytes: - body = GetPresetFileRequest(name_or_id=name_or_id, path=path) + def get_files(self, project_name: str, name_or_id: str) -> Iterator[Tuple[str, bytes]]: + """Yields `(path, archive)` for every file the preset carries, in one + request. The response is consumed as it arrives, so the number of files + does not change how much is held in memory.""" + body = GetPresetFilesRequest(name_or_id=name_or_id) resp = self._request( - f"/api/project/{project_name}/presets/get_file", body=body.model_dump_json() + f"/api/project/{project_name}/presets/get_files", + body=body.model_dump_json(), + stream=True, ) - return resp.content + return _iter_archives(resp.iter_content(chunk_size=_STREAM_CHUNK_SIZE)) + + +def _iter_archives(chunks: Iterator[bytes]) -> Iterator[Tuple[str, bytes]]: + reader = _ChunkReader(chunks) + while True: + header = reader.read(PRESET_FILES_PATH_LENGTH_BYTES, allow_eof=True) + if header is None: + return + path = reader.read(int.from_bytes(header, "big")).decode() + size = int.from_bytes(reader.read(PRESET_FILES_CONTENT_LENGTH_BYTES), "big") + yield path, reader.read(size) + + +class _ChunkReader: + """Exact-size reads over a chunked response body.""" + + def __init__(self, chunks: Iterator[bytes]): + self._chunks = chunks + self._buffer = bytearray() + + def read(self, size: int, allow_eof: bool = False): + while len(self._buffer) < size: + chunk = next(self._chunks, None) + if chunk is None: + if allow_eof and not self._buffer: + return None + raise ClientError("Preset file stream ended early") + self._buffer += chunk + data = bytes(self._buffer[:size]) + del self._buffer[:size] + return data diff --git a/src/tests/_internal/cli/services/presets/test_registry.py b/src/tests/_internal/cli/services/presets/test_registry.py index d9c6f342b..4cd6c0724 100644 --- a/src/tests/_internal/cli/services/presets/test_registry.py +++ b/src/tests/_internal/cli/services/presets/test_registry.py @@ -26,10 +26,13 @@ URLNotFoundError, ) from dstack._internal.core.models.common import RegistryAuth -from dstack._internal.core.models.files import FileArchive, FilePathMapping +from dstack._internal.core.models.files import ( + FileArchive, + FileArchiveMapping, + FilePathMapping, +) from dstack._internal.core.models.presets import ( PortablePreset, - PresetArchiveMapping, PresetSpec, ) from dstack._internal.server.schemas.presets import ( @@ -59,7 +62,7 @@ def __init__(self, remote: Optional[GetPresetResponse] = None): self.remote = remote self.push_requests: list[SimpleNamespace] = [] self.file_requests: list[SimpleNamespace] = [] - # Blobs served by `get_file`, keyed by the archive mapping path. + # Blobs streamed by `get_files`, keyed by the archive mapping path. self.file_blobs: dict[str, bytes] = {} self.files = FakeFilesAPIClient() self.error: Optional[Exception] = None @@ -76,12 +79,18 @@ def get(self, project_name, name_or_id): # that re-roots the returned document cannot leak into the next pull. return self.remote.model_copy(deep=True) - def get_file(self, project_name, name_or_id, path): + def get_files(self, project_name, name_or_id): self._raise_if_failing() self.file_requests.append( - SimpleNamespace(project_name=project_name, name_or_id=name_or_id, path=path) + SimpleNamespace(project_name=project_name, name_or_id=name_or_id) + ) + assert self.remote is not None + # The server streams the archives the stored spec lists, not every blob + # it happens to hold. + return iter( + (mapping.path, self.file_blobs[mapping.path]) + for mapping in self.remote.spec.file_archives ) - return self.file_blobs[path] def _raise_if_failing(self): if self.error is not None: @@ -110,7 +119,7 @@ def _portable_preset() -> PortablePreset: def _registry_preset( *, name: str = "qwen38", - file_archives: Optional[list[PresetArchiveMapping]] = None, + file_archives: Optional[list[FileArchiveMapping]] = None, file_mappings: Optional[list[FilePathMapping]] = None, is_current: bool = True, ) -> GetPresetResponse: @@ -256,7 +265,7 @@ def _client(self, response) -> tuple[PresetsAPIClient, list[SimpleNamespace]]: calls: list[SimpleNamespace] = [] def request(path, body=None, **kwargs): - calls.append(SimpleNamespace(path=path, body=body)) + calls.append(SimpleNamespace(path=path, body=body, kwargs=kwargs)) return response return PresetsAPIClient(request, logging.getLogger(__name__)), calls @@ -266,7 +275,7 @@ def test_push_sends_the_name_and_the_typed_spec(self): client, calls = self._client(SimpleNamespace(json=lambda: info.model_dump(mode="json"))) spec = PresetSpec( preset=_portable_preset(), - file_archives=[PresetArchiveMapping(id=uuid4(), path="patch/a.txt")], + file_archives=[FileArchiveMapping(id=uuid4(), path="/app/a.txt")], ) pushed = client.push("main", name="qwen", spec=spec) @@ -295,14 +304,27 @@ def test_get_returns_a_typed_spec_and_ignores_what_a_newer_server_adds(self): assert got.spec.preset.repo == "community/Qwen3.5-27B-GPTQ-Int4" assert got.id == remote.id - def test_get_file_returns_the_raw_archive_bytes(self): - client, calls = self._client(SimpleNamespace(content=b"tar-bytes")) + def test_get_files_reads_the_framed_stream(self): + def frame(path: str, blob: bytes) -> bytes: + return ( + len(path.encode()).to_bytes(4, "big") + + path.encode() + + len(blob).to_bytes(8, "big") + + blob + ) + + stream = frame("patch/a.txt", b"tar-a") + frame("run.sh", b"tar-b") + # Framed values are read across chunk boundaries, so a chunk size that + # splits every frame is what proves the reader, not the framing. + chunks = [stream[i : i + 3] for i in range(0, len(stream), 3)] + client, calls = self._client(SimpleNamespace(iter_content=lambda chunk_size: iter(chunks))) - blob = client.get_file("main", "qwen38", "patch/a.txt") + got = list(client.get_files("main", "qwen38")) (call,) = calls - assert call.path == "/api/project/main/presets/get_file" - assert blob == b"tar-bytes" + assert call.path == "/api/project/main/presets/get_files" + assert call.kwargs["stream"] is True + assert got == [("patch/a.txt", b"tar-a"), ("run.sh", b"tar-b")] class TestPushPresetToRegistry: @@ -329,7 +351,7 @@ def test_pushes_a_relativized_document_without_the_local_name(self, tmp_path, st # File contents travel as uploaded archives, referenced by id. (upload,) = stub_client.files.uploads assert request.spec.file_archives == [ - PresetArchiveMapping(id=upload.archive.id, path="patch/a.txt") + FileArchiveMapping(id=upload.archive.id, path="/app/a.txt") ] assert _archive_member_texts(upload.content) == {"a.txt": "hello"} document = request.spec.preset.model_dump(mode="json") @@ -420,10 +442,10 @@ class TestPullPresetFromRegistry: def test_materializes_the_preset_under_its_qualified_name(self, tmp_path, stub_client): store = PresetStore(tmp_path / "presets") stub_client.remote = _registry_preset( - file_archives=[PresetArchiveMapping(id=uuid4(), path="patch/a.txt")], + file_archives=[FileArchiveMapping(id=uuid4(), path="/app/a.txt")], file_mappings=[FilePathMapping(local_path="patch/a.txt", path="/app/a.txt")], ) - stub_client.file_blobs["patch/a.txt"] = _file_archive_blob(tmp_path, "a.txt", "hello") + stub_client.file_blobs["/app/a.txt"] = _file_archive_blob(tmp_path, "a.txt", "hello") preset_id = str(stub_client.remote.id) pull_preset_from_registry(store, "main/qwen38") @@ -440,10 +462,9 @@ def test_materializes_the_preset_under_its_qualified_name(self, tmp_path, stub_c ) assert (store.root / preset_id / "patch" / "a.txt").read_text() == "hello" # Files are downloaded by id, not by the pulled ref: a name may repoint - # between requests. + # between requests. One request brings them all, however many there are. (file_request,) = stub_client.file_requests assert file_request.name_or_id == preset_id - assert file_request.path == "patch/a.txt" # The saved file keeps the path relative, like any locally created preset. saved = yaml.safe_load((store.root / preset_id / "preset.yml").read_text()) assert saved["service"]["files"] == [{"local_path": "patch/a.txt", "path": "/app/a.txt"}] @@ -513,14 +534,14 @@ def test_repulling_a_copy_by_id_keeps_the_name_it_already_holds(self, tmp_path, def test_repulling_the_same_version_overwrites_in_place(self, tmp_path, stub_client): store = PresetStore(tmp_path / "presets") stub_client.remote = _registry_preset( - file_archives=[PresetArchiveMapping(id=uuid4(), path="patch/a.txt")], + file_archives=[FileArchiveMapping(id=uuid4(), path="/app/a.txt")], file_mappings=[FilePathMapping(local_path="patch/a.txt", path="/app/a.txt")], ) - stub_client.file_blobs["patch/a.txt"] = _file_archive_blob(tmp_path, "a.txt", "hello") + stub_client.file_blobs["/app/a.txt"] = _file_archive_blob(tmp_path, "a.txt", "hello") preset_id = str(stub_client.remote.id) pull_preset_from_registry(store, "main/qwen38") - stub_client.file_blobs["patch/a.txt"] = _file_archive_blob(tmp_path, "a.txt", "fresh") + stub_client.file_blobs["/app/a.txt"] = _file_archive_blob(tmp_path, "a.txt", "fresh") pull_preset_from_registry(store, "main/qwen38") assert store.get(preset_id).name == "main/qwen38" @@ -536,22 +557,22 @@ def test_repulling_a_shrunken_file_set_does_not_keep_the_stale_file( remote_id = uuid4() stub_client.remote = _registry_preset( file_archives=[ - PresetArchiveMapping(id=uuid4(), path="patch/a.txt"), - PresetArchiveMapping(id=uuid4(), path="patch/b.txt"), + FileArchiveMapping(id=uuid4(), path="/app/a.txt"), + FileArchiveMapping(id=uuid4(), path="/app/b.txt"), ], file_mappings=[ FilePathMapping(local_path="patch/a.txt", path="/app/a.txt"), FilePathMapping(local_path="patch/b.txt", path="/app/b.txt"), ], ).model_copy(update={"id": remote_id}) - stub_client.file_blobs["patch/a.txt"] = _file_archive_blob(tmp_path, "a.txt", "hello") - stub_client.file_blobs["patch/b.txt"] = _file_archive_blob(tmp_path, "b.txt", "stale") + stub_client.file_blobs["/app/a.txt"] = _file_archive_blob(tmp_path, "a.txt", "hello") + stub_client.file_blobs["/app/b.txt"] = _file_archive_blob(tmp_path, "b.txt", "stale") pull_preset_from_registry(store, "main/qwen38") preset_id = str(remote_id) assert (store.root / preset_id / "patch" / "b.txt").exists() stub_client.remote = _registry_preset( - file_archives=[PresetArchiveMapping(id=uuid4(), path="patch/a.txt")], + file_archives=[FileArchiveMapping(id=uuid4(), path="/app/a.txt")], file_mappings=[FilePathMapping(local_path="patch/a.txt", path="/app/a.txt")], ).model_copy(update={"id": remote_id}) pull_preset_from_registry(store, "main/qwen38") @@ -564,7 +585,7 @@ def test_repulling_a_shrunken_file_set_does_not_keep_the_stale_file( def test_rejects_a_traversing_file_path_before_writing(self, tmp_path, stub_client): store = PresetStore(tmp_path / "presets") stub_client.remote = _registry_preset( - file_archives=[PresetArchiveMapping(id=uuid4(), path="../evil.txt")], + file_archives=[FileArchiveMapping(id=uuid4(), path="/app/a.txt")], file_mappings=[FilePathMapping(local_path="../evil.txt", path="/app/a.txt")], ) @@ -592,7 +613,14 @@ def test_rejects_invalid_pulled_file_paths_before_writing( ): store = PresetStore(tmp_path / "presets") stub_client.remote = _registry_preset( - file_archives=[PresetArchiveMapping(id=uuid4(), path=path) for path in paths], + file_archives=[ + FileArchiveMapping(id=uuid4(), path=f"/app/{index}") + for index, _ in enumerate(paths) + ], + file_mappings=[ + FilePathMapping(local_path=path, path=f"/app/{index}") + for index, path in enumerate(paths) + ], ) with pytest.raises(CLIError, match=error): @@ -612,7 +640,7 @@ def test_rejects_a_mapping_not_included_in_the_pulled_files(self, tmp_path, stub def test_rejects_a_pulled_file_the_preset_does_not_reference(self, tmp_path, stub_client): store = PresetStore(tmp_path / "presets") stub_client.remote = _registry_preset( - file_archives=[PresetArchiveMapping(id=uuid4(), path="patch/a.txt")], + file_archives=[FileArchiveMapping(id=uuid4(), path="/app/orphan.txt")], ) with pytest.raises(CLIError, match="not referenced"): @@ -654,10 +682,10 @@ class TestPulledArchiveMembers: def _pull_hostile(self, tmp_path, stub_client, blob: bytes) -> PresetStore: store = PresetStore(tmp_path / "presets") stub_client.remote = _registry_preset( - file_archives=[PresetArchiveMapping(id=uuid4(), path="patch/a.txt")], + file_archives=[FileArchiveMapping(id=uuid4(), path="/app/a.txt")], file_mappings=[FilePathMapping(local_path="patch/a.txt", path="/app/a.txt")], ) - stub_client.file_blobs["patch/a.txt"] = blob + stub_client.file_blobs["/app/a.txt"] = blob return store def _assert_nothing_escaped(self, tmp_path, store: PresetStore, stub_client) -> None: From 06583018640aa1327e86b28e2d69946d89f2f246 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 24 Aug 2026 14:16:01 +0200 Subject: [PATCH 4/9] Add a preset event target type Presets can be the target of events, and `list_events` filters by them, so a server extending dstack with a preset registry can record who pushed what. Co-Authored-By: Claude Opus 5 (1M context) --- src/dstack/_internal/core/models/events.py | 1 + src/dstack/_internal/server/routers/events.py | 1 + src/dstack/_internal/server/schemas/events.py | 11 +++++++++++ src/dstack/_internal/server/services/events.py | 8 ++++++++ src/dstack/api/server/_events.py | 2 ++ 5 files changed, 23 insertions(+) diff --git a/src/dstack/_internal/core/models/events.py b/src/dstack/_internal/core/models/events.py index 4d1f6494b..86bce7365 100644 --- a/src/dstack/_internal/core/models/events.py +++ b/src/dstack/_internal/core/models/events.py @@ -19,6 +19,7 @@ class EventTargetType(str, Enum): VOLUME = "volume" GATEWAY = "gateway" SECRET = "secret" + PRESET = "preset" class EventTarget(CoreModel): diff --git a/src/dstack/_internal/server/routers/events.py b/src/dstack/_internal/server/routers/events.py index 574a59bac..fd89c82fd 100644 --- a/src/dstack/_internal/server/routers/events.py +++ b/src/dstack/_internal/server/routers/events.py @@ -51,6 +51,7 @@ async def list_events( target_volumes=body.target_volumes, target_gateways=body.target_gateways, target_secrets=body.target_secrets, + target_presets=body.target_presets, within_projects=body.within_projects, within_fleets=body.within_fleets, within_runs=body.within_runs, diff --git a/src/dstack/_internal/server/schemas/events.py b/src/dstack/_internal/server/schemas/events.py index c3af2a5da..6513f8401 100644 --- a/src/dstack/_internal/server/schemas/events.py +++ b/src/dstack/_internal/server/schemas/events.py @@ -114,6 +114,17 @@ class ListEventsRequest(CoreModel): max_length=MAX_FILTER_ITEMS, ), ] = None + target_presets: Annotated[ + Optional[list[uuid.UUID]], + Field( + description=( + "List of preset IDs." + " The response will only include events that target the specified presets" + ), + min_length=MIN_FILTER_ITEMS, + max_length=MAX_FILTER_ITEMS, + ), + ] = None within_projects: Annotated[ Optional[list[uuid.UUID]], Field( diff --git a/src/dstack/_internal/server/services/events.py b/src/dstack/_internal/server/services/events.py index 75f56c6ee..9a16ce16f 100644 --- a/src/dstack/_internal/server/services/events.py +++ b/src/dstack/_internal/server/services/events.py @@ -262,6 +262,7 @@ async def list_events( target_volumes: Optional[list[uuid.UUID]], target_gateways: Optional[list[uuid.UUID]], target_secrets: Optional[list[uuid.UUID]], + target_presets: Optional[list[uuid.UUID]], within_projects: Optional[list[uuid.UUID]], within_fleets: Optional[list[uuid.UUID]], within_runs: Optional[list[uuid.UUID]], @@ -353,6 +354,13 @@ async def list_events( EventTargetModel.entity_id.in_(target_secrets), ) ) + if target_presets is not None: + target_filters.append( + and_( + EventTargetModel.entity_type == EventTargetType.PRESET, + EventTargetModel.entity_id.in_(target_presets), + ) + ) if within_projects is not None: target_filters.append(EventTargetModel.entity_project_id.in_(within_projects)) if within_fleets is not None: diff --git a/src/dstack/api/server/_events.py b/src/dstack/api/server/_events.py index 132d5994f..7beedf2a3 100644 --- a/src/dstack/api/server/_events.py +++ b/src/dstack/api/server/_events.py @@ -32,6 +32,7 @@ def list( target_volumes: Optional[list[UUID]] = None, target_gateways: Optional[list[UUID]] = None, target_secrets: Optional[list[UUID]] = None, + target_presets: Optional[list[UUID]] = None, ) -> list[Event]: if prev_recorded_at is not None: # Time zones other than UTC are misinterpreted by the server: @@ -47,6 +48,7 @@ def list( target_volumes=target_volumes, target_gateways=target_gateways, target_secrets=target_secrets, + target_presets=target_presets, within_projects=within_projects, within_fleets=within_fleets, within_runs=within_runs, From d4e64deccc85e905b4cc1f9936428cabe5bc8a19 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 24 Aug 2026 14:16:02 +0200 Subject: [PATCH 5/9] Fix `dstack preset export` for a directory in `files` `files` may mount a directory, and exporting a preset carrying one failed with `IsADirectoryError`. Co-Authored-By: Claude Opus 5 (1M context) --- .../_internal/cli/services/presets/export.py | 7 ++++++- .../cli/services/presets/test_export.py | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/dstack/_internal/cli/services/presets/export.py b/src/dstack/_internal/cli/services/presets/export.py index bf7b638dc..e3c5d4313 100644 --- a/src/dstack/_internal/cli/services/presets/export.py +++ b/src/dstack/_internal/cli/services/presets/export.py @@ -57,5 +57,10 @@ def export_preset( destination.write_text(yaml.safe_dump(service.model_dump(mode="json"), sort_keys=False)) for source, target in copies: target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, target) + if source.is_dir(): + # `files` may mount a directory, and a preset carrying one exports + # like any other. + shutil.copytree(source, target, dirs_exist_ok=True) + else: + shutil.copy2(source, target) return written diff --git a/src/tests/_internal/cli/services/presets/test_export.py b/src/tests/_internal/cli/services/presets/test_export.py index 40c2b11d7..e8e17ba79 100644 --- a/src/tests/_internal/cli/services/presets/test_export.py +++ b/src/tests/_internal/cli/services/presets/test_export.py @@ -14,6 +14,26 @@ class TestExportPreset: + def test_exports_a_directory_the_service_mounts(self, tmp_path: Path): + # `files` may mount a whole directory, so export copies the tree rather + # than failing on it. + store = PresetStore(tmp_path / "presets") + preset = get_preset() + preset.service.files = [FilePathMapping(local_path="service/1/patches", path="/patches")] + preset_dir = store.save(preset).parent + (preset_dir / "service" / "1" / "patches" / "nested").mkdir(parents=True) + (preset_dir / "service" / "1" / "patches" / "fix.patch").write_text("--- a\n+++ b\n") + (preset_dir / "service" / "1" / "patches" / "nested" / "more.patch").write_text("--- c\n") + destination = tmp_path / "deploy" / "qwen.dstack.yml" + + export_preset( + store.get(preset.id), preset_dir=preset_dir, destination=destination, force=False + ) + + exported = destination.parent / "service" / "1" / "patches" + assert (exported / "fix.patch").read_text() == "--- a\n+++ b\n" + assert (exported / "nested" / "more.patch").read_text() == "--- c\n" + def test_exports_a_deployable_service_configuration_with_its_files(self, tmp_path: Path): store = PresetStore(tmp_path / "presets") preset = get_preset() From fb10f25332a8ace5df5e13644eeb27e6b13b36b5 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 24 Aug 2026 14:23:30 +0200 Subject: [PATCH 6/9] Show preset event targets in the UI The events table rendered a preset target as `---`, and neither the type filter nor the target ID filter knew about presets. Co-Authored-By: Claude Opus 5 (1M context) --- .../Events/List/hooks/useColumnDefinitions.tsx | 13 +++++++++++++ .../src/pages/Events/List/hooks/useFilters.ts | 9 +++++++++ frontend/src/types/event.d.ts | 15 +++++++++++++-- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx b/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx index be4ec19a5..d41e3af6f 100644 --- a/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx +++ b/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx @@ -154,6 +154,19 @@ export const useColumnsDefinitions = () => { ); + case 'preset': + return ( +
+ Preset{' '} + {target.project_name && ( + + {target.project_name} + + )} + /{target.name} +
+ ); + default: return '---'; } diff --git a/frontend/src/pages/Events/List/hooks/useFilters.ts b/frontend/src/pages/Events/List/hooks/useFilters.ts index d160caa17..066f17828 100644 --- a/frontend/src/pages/Events/List/hooks/useFilters.ts +++ b/frontend/src/pages/Events/List/hooks/useFilters.ts @@ -28,6 +28,7 @@ const filterKeys: Record = { TARGET_VOLUMES: 'target_volumes', TARGET_GATEWAYS: 'target_gateways', TARGET_SECRETS: 'target_secrets', + TARGET_PRESETS: 'target_presets', WITHIN_PROJECTS: 'within_projects', WITHIN_FLEETS: 'within_fleets', WITHIN_RUNS: 'within_runs', @@ -47,6 +48,7 @@ const multipleChoiseKeys: RequestParamsKeys[] = [ 'target_volumes', 'target_gateways', 'target_secrets', + 'target_presets', 'within_projects', 'within_fleets', 'within_runs', @@ -64,6 +66,7 @@ const targetTypes = [ { label: 'Volume', value: 'volume' }, { label: 'Gateway', value: 'gateway' }, { label: 'Secret', value: 'secret' }, + { label: 'Preset', value: 'preset' }, ]; const baseFilteringProperties = [ @@ -121,6 +124,12 @@ const baseFilteringProperties = [ propertyLabel: 'Target secret IDs', groupValuesLabel: 'Secret ids', }, + { + key: filterKeys.TARGET_PRESETS, + operators: ['='], + propertyLabel: 'Target preset IDs', + groupValuesLabel: 'Preset ids', + }, { key: filterKeys.WITHIN_PROJECTS, diff --git a/frontend/src/types/event.d.ts b/frontend/src/types/event.d.ts index 0afdb7436..43565c079 100644 --- a/frontend/src/types/event.d.ts +++ b/frontend/src/types/event.d.ts @@ -1,4 +1,14 @@ -declare type TEventTargetType = 'project' | 'user' | 'fleet' | 'instance' | 'run' | 'job' | 'volume' | 'gateway' | 'secret'; +declare type TEventTargetType = + | 'project' + | 'user' + | 'fleet' + | 'instance' + | 'run' + | 'job' + | 'volume' + | 'gateway' + | 'secret' + | 'preset'; declare type TEventListFilters = { prev_recorded_at?: string; @@ -11,6 +21,7 @@ declare type TEventListFilters = { target_volumes?: string[]; target_gateways?: string[]; target_secrets?: string[]; + target_presets?: string[]; within_projects?: string[]; within_fleets?: string[]; within_runs?: string[]; @@ -20,7 +31,7 @@ declare type TEventListFilters = { declare type TEventListRequestParams = Omit & TEventListFilters; declare interface IEventTarget { - type: 'project' | 'user' | 'fleet' | 'instance' | 'run' | 'job' | 'volume' | 'gateway' | 'secret'; + type: TEventTargetType; project_id?: string; project_name?: string; id: string; From 935a11f51938030e70886791a2ae644cb53e099c Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 24 Aug 2026 15:20:08 +0200 Subject: [PATCH 7/9] Document preset push and pull The presets docs cover pushing, pulling, and the registry behind them. Traces move under Monitor presets, the intro is shorter, and the export snippet matches what the command prints now. Co-Authored-By: Claude Opus 5 (1M context) --- mkdocs/docs/concepts/presets.md | 64 +++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/mkdocs/docs/concepts/presets.md b/mkdocs/docs/concepts/presets.md index 36a639622..b48b7d064 100644 --- a/mkdocs/docs/concepts/presets.md +++ b/mkdocs/docs/concepts/presets.md @@ -5,11 +5,7 @@ description: Creating and reusing optimized model inference configurations # Presets -A preset configuration lets you use an agent to create a preset: a verified and optimized model inference configuration. Once created, the preset can be reused to deploy model inference on verified hardware without an agent. - -The value of presets comes from combining two fundamental features: agent-driven model inference optimization and the `dstack` [service](services.md) primitive, which can deploy model inference to any cloud, Kubernetes, or on-prem cluster. - -To get the best performance for the given model, hardware, and other constraints, the agent selects the serving framework, quantization, and serving parameters, and can patch the framework's source code, generate custom kernels, and patch drivers. +Presets offer a toolkit that streamlines agent-based model inference optimization and a portable format to deploy the optimized inference endpoint to any cloud, Kubernetes cluster, or on-prem fleet. > The presets feature is experimental and may change. @@ -201,6 +197,53 @@ When the session builds on `previous`, the baseline trial reproduces the best co !!! info "Reference" The `preset` configuration supports many more options. See the [`.dstack.yml` reference](../reference/dstack.yml/preset.md). +## Push and pull a preset + +To share a preset, push it to the registry: + +
+ +```shell +$ dstack preset push dsv4-flash-b200 main/dsv4-flash-b200 +OK +``` + +
+ +Pull it wherever you want to use it: + +
+ +```shell +$ dstack preset pull main/dsv4-flash-b200 +OK +``` + +
+ +Push shares everything needed to deploy the preset. The prompt and trials that produced it stay on your machine. + +A pulled preset works like any other, and is named `/`: + +
+ +```shell +$ dstack preset list -a + NAME ID BASE CONSTRAINTS BENCHMARK STATUS SUBMITTED + main/dsv4-flash-b200 8f065dde deepseek-ai/DeepSeek-V4-Flash io=10K/1.5K c=1 tps/user=309 ttft=213ms ctx=1M pulled 2 min ago + qwen35-pro6000 092c792b Qwen/Qwen3.5-397B-A17B io=8K/1K c=64 tps/user=19.6 ttft=3.43s ctx=32K verified (7) 3 days ago +``` + +
+ +Pushing the same name again moves the name to the new preset. The previous one stays available as `/`. + +### Registry + +Presets are pushed to and pulled from the registry hosted at [dstack Sky](https://sky.dstack.ai). To share a preset, create a project there, add the people you want to share it with, and push the preset to that project. To push or pull a preset from a project, you have to be its member. + +A self-hosted registry is part of [dstack Enterprise](https://calendly.com/dstackai/discovery-call){ target="_blank" }. + ## Export a preset To deploy a preset, export it as a service configuration with `dstack preset export`: @@ -209,7 +252,7 @@ To deploy a preset, export it as a service configuration with `dstack preset exp ```shell $ dstack preset export c83375b4 -f qwen.dstack.yml -Preset c83375b4 exported to qwen.dstack.yml (16 files). Deploy it with `dstack apply -f qwen.dstack.yml` +OK ``` @@ -240,7 +283,7 @@ Submit the run dsv4-flash? [y/n]: y ## Manage presets -### Watch presets +### Monitor presets While a preset is being created, you can watch the progress of its trials and what the agent is doing. @@ -254,9 +297,8 @@ $ dstack preset logs -f c83375b4 -### Traces - -The agent subprocess writes real-time traces to `~/.dstack/presets//trace.jsonl`: the agent's messages and every tool call with its result. Traces are the main way to analyze a session in depth — see [Protips](#protips). +!!! info "Traces" + The agent subprocess writes real-time traces to `~/.dstack/presets//trace.jsonl`: the agent's messages and every tool call with its result. Traces are the main way to analyze a session in depth — see [Protips](#protips). ### List presets @@ -321,7 +363,7 @@ At the same time, it's recommended to create presets using your own agent — ei * Currently, the agent doesn't upload compiled binaries anywhere; patches compile at runtime * Doesn't support PD disaggregation (coming soon) -* Presets are saved locally (a preset registry is coming soon) +* The registry doesn't support public presets (coming soon) * Doesn't support ranges for `concurrency` > Report bugs and request features on [GitHub](https://github.com/dstackai/dstack/issues), and ask questions on [Discord](https://discord.gg/u8SmfwPpMd). From d9efc68e9e05ba8b636dd1b4d8620a82c9a4bc16 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 24 Aug 2026 19:38:29 +0200 Subject: [PATCH 8/9] Make a preset name a moving reference Pushing a name that already exists transfers it to the new preset, the way a Docker tag moves. The superseded preset keeps existing without a name and is reached as `/`, so `name` is optional on the wire and `is_current` is gone: a preset that arrives without one is a preset the name has moved on from, and it lands untagged locally. Also adds the list and delete request schemas the registry UI calls, and `project_name` to the record, so presets can be listed across projects. Co-Authored-By: Claude Opus 5 (1M context) --- .../cli/services/presets/registry.py | 38 +++++++------- .../_internal/server/schemas/presets.py | 50 +++++++++++++++---- .../cli/services/presets/test_registry.py | 13 +++-- 3 files changed, 64 insertions(+), 37 deletions(-) diff --git a/src/dstack/_internal/cli/services/presets/registry.py b/src/dstack/_internal/cli/services/presets/registry.py index 5ff738736..82e6f7008 100644 --- a/src/dstack/_internal/cli/services/presets/registry.py +++ b/src/dstack/_internal/cli/services/presets/registry.py @@ -144,23 +144,24 @@ def pull_preset_from_registry(store: PresetStore, registry_ref: str) -> None: validate_preset_spec_files(remote.spec) except ValueError as e: raise CLIError(f"Preset {registry_ref!r} cannot be pulled: {e}") from e - # The local name is the qualified ref. It can never collide with a locally - # created preset (local names cannot contain `/`), so the only possible - # holder is an earlier pull; the name silently moves to the fresh copy, - # Docker-style. A non-current preset (pulled by id after the name was - # repointed) must not take the name from the current one — like a Docker - # pull by digest, it lands untagged. - qualified_name = f"{project}/{remote.name}" - local_name = qualified_name if remote.is_current else None - holder = store.find_by_name(qualified_name) - if local_name is not None: + # A preset carries its name only while the name resolves to it, so one + # pulled by id after the name moved on arrives without one and lands + # untagged, like a Docker pull by digest. + qualified_name = f"{project}/{remote.name}" if remote.name is not None else None + local_name = qualified_name + if qualified_name is not None: + # The qualified name can never collide with a locally created preset + # (local names cannot contain `/`), so the only possible holder is an + # earlier pull; the name silently moves to the fresh copy. + holder = store.find_by_name(qualified_name) if holder is not None and holder.id != preset_id: store.release_name(qualified_name) - elif holder is not None and holder.id == preset_id: - # This copy holds the name from an earlier pull, when it was current. - # Re-pulling it by id must not strip the name off the local store - # entirely, or local refs to it would stop resolving. - local_name = qualified_name + else: + # Re-pulling by id a copy that already holds the name from an earlier + # pull must not strip it off the local store, or local refs to it would + # stop resolving. + holder = store.get(preset_id) + local_name = holder.name if holder is not None else None pulled = PulledPreset( id=preset_id, name=local_name, @@ -206,12 +207,7 @@ def pull_preset_from_registry(store: PresetStore, registry_ref: str) -> None: # interrupt - must not leave a directory without its preset document. if not saved: shutil.rmtree(directory, ignore_errors=True) - if local_name is not None: - console.print("OK") - else: - console.print( - f"Pulled [code]{preset_id}[/]; [code]{qualified_name}[/] now names a newer preset" - ) + console.print("OK") def _upload_archive(client: APIClient, local_path: str) -> uuid.UUID: diff --git a/src/dstack/_internal/server/schemas/presets.py b/src/dstack/_internal/server/schemas/presets.py index f8a4162cd..6d301ac91 100644 --- a/src/dstack/_internal/server/schemas/presets.py +++ b/src/dstack/_internal/server/schemas/presets.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import List, Optional from uuid import UUID from pydantic import Field @@ -24,24 +25,55 @@ class GetPresetFilesRequest(CoreModel): name_or_id: Annotated[str, Field(description="The preset id or name")] +class ListPresetsRequest(CoreModel): + project_name: Annotated[ + Optional[str], Field(description="Only list presets pushed to this project") + ] = None + username: Annotated[ + Optional[str], Field(description="Only list presets pushed by this user") + ] = None + base: Annotated[Optional[str], Field(description="Only list presets for this base model")] = ( + None + ) + prev_created_at: Annotated[ + Optional[datetime], Field(description="The `created_at` of the last preset of the page") + ] = None + prev_id: Annotated[ + Optional[UUID], Field(description="The `id` of the last preset of the page") + ] = None + limit: Annotated[int, Field(description="The page size", ge=0, le=100)] = 100 + ascending: bool = False + + +class DeletePresetRequest(CoreModel): + id: Annotated[UUID, Field(description="The preset to delete")] + + class PushPresetResponse(CoreModel): """What `push` returns: the record the registry minted.""" id: UUID - name: str - base: str - repo: str - created_at: datetime - pushed_by: Annotated[str, Field(description="The username of the pusher")] - is_current: Annotated[ - bool, + name: Annotated[ + Optional[str], Field( description=( - "Whether the name currently resolves to this preset." - " Derived when read: a later push under the same name takes it over" + "The name while it resolves to this preset." + " A later push under the same name takes it over, and this is then unset" ) ), ] + project_name: str + base: str + repo: str + created_at: datetime + pushed_by: Annotated[str, Field(description="The username of the pusher")] + + +class ListPresetsResponse(CoreModel): + """What `list` returns: a record per preset, superseded ones included, + without the specs, which `get` reads one at a time.""" + + presets: List[PushPresetResponse] class GetPresetResponse(PushPresetResponse): diff --git a/src/tests/_internal/cli/services/presets/test_registry.py b/src/tests/_internal/cli/services/presets/test_registry.py index 4cd6c0724..c5bd5a009 100644 --- a/src/tests/_internal/cli/services/presets/test_registry.py +++ b/src/tests/_internal/cli/services/presets/test_registry.py @@ -97,15 +97,15 @@ def _raise_if_failing(self): raise self.error -def _registry_preset_info(*, name: str = "qwen38") -> PushPresetResponse: +def _registry_preset_info(*, name: Optional[str] = "qwen38") -> PushPresetResponse: return PushPresetResponse( id=uuid4(), name=name, + project_name="main", base="Qwen/Qwen3.5-27B", repo="community/Qwen3.5-27B-GPTQ-Int4", created_at=datetime(2026, 8, 20, 12, 0), pushed_by="alice", - is_current=True, ) @@ -118,15 +118,14 @@ def _portable_preset() -> PortablePreset: def _registry_preset( *, - name: str = "qwen38", + name: Optional[str] = "qwen38", file_archives: Optional[list[FileArchiveMapping]] = None, file_mappings: Optional[list[FilePathMapping]] = None, - is_current: bool = True, ) -> GetPresetResponse: document = _portable_preset() if file_mappings is not None: document.service.files = file_mappings - info = _registry_preset_info(name=name).model_copy(update={"is_current": is_current}) + info = _registry_preset_info(name=name) return GetPresetResponse( **info.model_dump(), spec=PresetSpec(preset=document, file_archives=file_archives or []), @@ -506,7 +505,7 @@ def test_a_non_current_version_pulled_by_id_does_not_take_the_name( # An older version pulled by id lands untagged, like a Docker pull by # digest: the qualified name stays on the current version's copy. - stub_client.remote = _registry_preset(is_current=False) + stub_client.remote = _registry_preset(name=None) old_id = str(stub_client.remote.id) pull_preset_from_registry(store, f"main/{old_id}") @@ -523,7 +522,7 @@ def test_repulling_a_copy_by_id_keeps_the_name_it_already_holds(self, tmp_path, preset_id = str(stub_client.remote.id) pull_preset_from_registry(store, "main/qwen38") - stub_client.remote = _registry_preset(is_current=False).model_copy( + stub_client.remote = _registry_preset(name=None).model_copy( update={"id": stub_client.remote.id} ) pull_preset_from_registry(store, f"main/{preset_id}") From 5e30c94946a65b67c03958f857a5c35bb3b07918 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 24 Aug 2026 19:38:34 +0200 Subject: [PATCH 9/9] Add the Presets UI Lists the presets in the registry, filtered by project, user, and base model, and opens each one to show what it was verified on, how it performs, and the commands to pull, export, and apply it. The pages are Sky-only, gated as Billing is. Events moves down the sidebar to sit next to Projects. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/api.ts | 9 + frontend/src/components/index.ts | 1 + frontend/src/consts.ts | 2 + frontend/src/layouts/AppLayout/hooks.ts | 3 +- frontend/src/libs/presets.ts | 94 +++++++ frontend/src/locale/en.json | 47 +++- .../pages/Presets/Details/Benchmark/index.tsx | 50 ++++ .../Presets/Details/Constraints/index.tsx | 63 +++++ .../pages/Presets/Details/Deploy/index.tsx | 125 +++++++++ .../pages/Presets/Details/Inspect/index.tsx | 60 +++++ .../Presets/Details/VerifiedOn/index.tsx | 105 ++++++++ frontend/src/pages/Presets/Details/index.tsx | 195 ++++++++++++++ frontend/src/pages/Presets/List/hooks.tsx | 245 ++++++++++++++++++ frontend/src/pages/Presets/List/index.tsx | 131 ++++++++++ frontend/src/pages/Presets/index.ts | 4 + frontend/src/router.tsx | 27 ++ frontend/src/routes.ts | 19 ++ frontend/src/services/preset.ts | 50 ++++ frontend/src/store.ts | 3 + frontend/src/types/preset.d.ts | 52 ++++ 20 files changed, 1283 insertions(+), 2 deletions(-) create mode 100644 frontend/src/libs/presets.ts create mode 100644 frontend/src/pages/Presets/Details/Benchmark/index.tsx create mode 100644 frontend/src/pages/Presets/Details/Constraints/index.tsx create mode 100644 frontend/src/pages/Presets/Details/Deploy/index.tsx create mode 100644 frontend/src/pages/Presets/Details/Inspect/index.tsx create mode 100644 frontend/src/pages/Presets/Details/VerifiedOn/index.tsx create mode 100644 frontend/src/pages/Presets/Details/index.tsx create mode 100644 frontend/src/pages/Presets/List/hooks.tsx create mode 100644 frontend/src/pages/Presets/List/index.tsx create mode 100644 frontend/src/pages/Presets/index.ts create mode 100644 frontend/src/services/preset.ts create mode 100644 frontend/src/types/preset.d.ts diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 89451648d..58507d847 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -106,6 +106,10 @@ export const API = { // Fleets VOLUMES_DELETE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/volumes/delete`, + // PRESETS + PRESETS_GET: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/presets/get`, + PRESETS_DELETE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/presets/delete`, + // METRICS JOB_METRICS: (projectName: IProject['project_name'], runName: IRun['run_spec']['run_name']) => `${API.BASE()}/project/${projectName}/metrics/job/${runName}`, @@ -183,6 +187,11 @@ export const API = { LIST: () => `${API.VOLUME.BASE()}/list`, }, + PRESET: { + BASE: () => `${API.BASE()}/presets`, + LIST: () => `${API.PRESET.BASE()}/list`, + }, + USER_PUBLIC_KEYS: { BASE: () => `${API.BASE()}/users/public_keys`, LIST: () => `${API.USER_PUBLIC_KEYS.BASE()}/list`, diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 6acd2a2b0..1cdef08c1 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -64,6 +64,7 @@ export type { ModalProps } from '@cloudscape-design/components/modal'; export { default as AnchorNavigation } from '@cloudscape-design/components/anchor-navigation'; export { default as ExpandableSection } from '@cloudscape-design/components/expandable-section'; export { default as KeyValuePairs } from '@cloudscape-design/components/key-value-pairs'; +export { default as TreeView } from '@cloudscape-design/components/tree-view'; export { I18nProvider } from '@cloudscape-design/components/i18n'; export { default as Wizard } from '@cloudscape-design/components/wizard'; export { default as SegmentedControl } from '@cloudscape-design/components/segmented-control'; diff --git a/frontend/src/consts.ts b/frontend/src/consts.ts index 06715082d..579238f18 100644 --- a/frontend/src/consts.ts +++ b/frontend/src/consts.ts @@ -3,4 +3,6 @@ export const DISCORD_URL = 'https://discord.gg/u8SmfwPpMd'; export const QUICK_START_URL = 'https://dstack.ai/docs/quickstart/'; export const TALLY_FORM_ID = '3xYlYG'; export const DOCS_URL = 'https://dstack.ai/docs/'; +export const PRESETS_DOCS_URL = 'https://dstack.ai/docs/concepts/presets/'; +export const FLEETS_DOCS_URL = 'https://dstack.ai/docs/concepts/fleets/'; export const DEFAULT_TABLE_PAGE_SIZE = 20; diff --git a/frontend/src/layouts/AppLayout/hooks.ts b/frontend/src/layouts/AppLayout/hooks.ts index f46366fcd..52e31db28 100644 --- a/frontend/src/layouts/AppLayout/hooks.ts +++ b/frontend/src/layouts/AppLayout/hooks.ts @@ -28,8 +28,9 @@ export const useSideNavigation = () => { { type: 'link', text: t('navigation.fleets'), href: ROUTES.FLEETS.LIST }, { type: 'link', text: t('navigation.instances'), href: ROUTES.INSTANCES.LIST }, { type: 'link', text: t('navigation.volumes'), href: ROUTES.VOLUMES.LIST }, - { type: 'link', text: t('navigation.events'), href: ROUTES.EVENTS.LIST }, { type: 'link', text: t('navigation.models'), href: ROUTES.MODELS.LIST }, + process.env.UI_VERSION === 'sky' && { type: 'link', text: t('navigation.presets'), href: ROUTES.PRESETS.LIST }, + { type: 'link', text: t('navigation.events'), href: ROUTES.EVENTS.LIST }, { type: 'link', text: t('navigation.project_other'), href: ROUTES.PROJECT.LIST }, isGlobalAdmin && { diff --git a/frontend/src/libs/presets.ts b/frontend/src/libs/presets.ts new file mode 100644 index 000000000..0153fb2a8 --- /dev/null +++ b/frontend/src/libs/presets.ts @@ -0,0 +1,94 @@ +/** + * Formats a token count the way the CLI does: exact binary multiples keep + * binary names (32768 is "32K"), anything else rounds as decimal (1500 is + * "1.5K"). + */ +export const formatTokenCount = (value: number): string => { + for (const [divisor, suffix] of [ + [1024 * 1024, 'M'], + [1024, 'K'], + ] as const) { + if (value >= divisor && value % divisor === 0) { + return `${value / divisor}${suffix}`; + } + } + + if (value >= 999950) { + return `${trimZero((value / 1_000_000).toFixed(1))}M`; + } + + if (value >= 1000) { + return `${trimZero((value / 1000).toFixed(1))}K`; + } + + return String(value); +}; + +const trimZero = (value: string): string => (value.endsWith('.0') ? value.slice(0, -2) : value); + +/** As the CLI prints durations: 999.6 reads as 1s rather than 1000ms. */ +const formatDurationMs = (value: number): string => (value < 999.5 ? `${round(value)}ms` : `${round(value / 1000)}s`); + +const round = (value: number): string => String(Math.round(value * 100) / 100); + +/** + * Request counts, wall time, and token totals say nothing about how the preset + * performs: the totals are the workload multiplied by the request count, which + * the constraints already state. + */ +const HIDDEN_METRICS = new Set([ + 'successful_requests', + 'failed_requests', + 'duration_seconds', + 'total_input_tokens', + 'total_output_tokens', +]); + +const METRIC_LABELS: Record = { + output_tok_per_s: 'TPS', + per_user_tok_per_s: 'TPS/user', + total_input_tokens: 'Input tokens', + total_output_tokens: 'Output tokens', + ttft_ms: 'TTFT', + tpot_ms: 'TPOT', +}; + +const TOKEN_COUNT_METRICS = new Set(['total_input_tokens', 'total_output_tokens']); + +export type BenchmarkMetric = { label: string; value: string }; + +const formatMetricValue = (key: string, value: number): string => { + if (TOKEN_COUNT_METRICS.has(key)) return formatTokenCount(value); + if (key.endsWith('_ms')) return formatDurationMs(value); + return round(value); +}; + +/** + * The metrics worth showing, flattened: a metric measured as a distribution + * becomes one entry per statistic. + */ +export const getBenchmarkMetrics = (metrics: HashMap): BenchmarkMetric[] => { + const entries: BenchmarkMetric[] = []; + + Object.entries(metrics ?? {}).forEach(([key, value]) => { + if (HIDDEN_METRICS.has(key)) return; + const label = METRIC_LABELS[key] ?? key; + + if (typeof value === 'number') { + entries.push({ label, value: formatMetricValue(key, value) }); + return; + } + + if (value && typeof value === 'object') { + Object.entries(value as HashMap).forEach(([statistic, statisticValue]) => { + if (typeof statisticValue !== 'number') return; + entries.push({ + label: `${label} ${statistic}`, + value: formatMetricValue(key, statisticValue), + }); + }); + } + }); + + return entries; +}; diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 804134f3d..a06f08c4d 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -87,7 +87,8 @@ "volumes": "Volumes", "instances": "Instances", "offers": "Offers", - "events": "Events" + "events": "Events", + "presets": "Presets" }, "backend": { @@ -834,5 +835,49 @@ "confirm_dialog": { "title": "Confirm delete", "message": "Are you sure you want to delete?" + }, + "presets": { + "list_page_title": "Presets", + "empty_message_title": "No presets", + "empty_message_text": "Presets are created and pushed with the CLI.", + "documentation": "Documentation", + "nomatch_message_title": "No matches", + "nomatch_message_text": "We can't find a match. Try to change project or clear filter", + "filter_property_placeholder": "Filter by properties", + "name": "Name", + "id": "ID", + "project": "Project", + "base": "Base", + "repo": "Repo", + "user": "User", + "created_at": "Created", + "context_length": "Context length", + "benchmark": "Benchmark", + "details": "Details", + "inspect": "Inspect", + "delete_confirm_title": "Delete presets", + "delete_confirm_message": "Are you sure you want to delete these presets?", + "verified_on": "Verified on", + "replica_group": "Replica group {{name}}", + "step_pull": "Pull", + "step_pull_description": "Pull the preset to your machine.", + "step_export": "Export", + "step_export_description": "Export it as a service configuration, along with the files it references.", + "step_apply": "Apply", + "step_apply_description": "Deploy the service to any cloud, Kubernetes cluster, or on-prem fleet.", + "replica": "Replica {{index}}", + "deploy": "Deploy", + "no_cli": "No CLI installed?", + "no_cli_description": "To use dstack, install the CLI on your local machine.", + "constraints": "Constraints", + "concurrency": "Concurrency", + "dataset": "Dataset", + "input_tokens": "Input tokens", + "output_tokens": "Output tokens", + "shared_prefix": "Shared prefix", + "no_fleet_description": "Deploying the service requires a fleet with matching resources.", + "fleets": "Fleets", + "superseded_alert": "A later push took this preset's name. It stays available by ID.", + "fleets_link": "How to create a fleet" } } diff --git a/frontend/src/pages/Presets/Details/Benchmark/index.tsx b/frontend/src/pages/Presets/Details/Benchmark/index.tsx new file mode 100644 index 000000000..fd0e18648 --- /dev/null +++ b/frontend/src/pages/Presets/Details/Benchmark/index.tsx @@ -0,0 +1,50 @@ +import React, { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; + +import { Box, ColumnLayout, Container, Header, Loader } from 'components'; + +import { formatTokenCount, getBenchmarkMetrics } from 'libs/presets'; +import { useGetPresetQuery } from 'services/preset'; + +export const PresetBenchmark: FC = () => { + const { t } = useTranslation(); + const params = useParams(); + const paramProjectName = params.projectName ?? ''; + const paramPresetId = params.presetId ?? ''; + + const { data, isLoading } = useGetPresetQuery({ + project_name: paramProjectName, + id: paramPresetId, + }); + + if (isLoading || !data) + return ( + + + + ); + + const metrics = getBenchmarkMetrics(data.spec.preset.benchmark.metrics as HashMap); + + return ( + {t('presets.benchmark')}}> + +
+ {t('presets.context_length')} +
{formatTokenCount(data.spec.preset.context_length)}
+
+
+ {t('presets.concurrency')} +
{String((data.spec.preset.benchmark.workload as HashMap)?.concurrency)}
+
+ {metrics.map(({ label, value }) => ( +
+ {label} +
{value}
+
+ ))} +
+
+ ); +}; diff --git a/frontend/src/pages/Presets/Details/Constraints/index.tsx b/frontend/src/pages/Presets/Details/Constraints/index.tsx new file mode 100644 index 000000000..5d01286e1 --- /dev/null +++ b/frontend/src/pages/Presets/Details/Constraints/index.tsx @@ -0,0 +1,63 @@ +import React, { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; + +import { Box, ColumnLayout, Container, Header, Loader } from 'components'; + +import { formatTokenCount } from 'libs/presets'; +import { useGetPresetQuery } from 'services/preset'; + +const DEFAULT_DATASET = 'random'; + +export const PresetConstraints: FC = () => { + const { t } = useTranslation(); + const params = useParams(); + const paramProjectName = params.projectName ?? ''; + const paramPresetId = params.presetId ?? ''; + + const { data, isLoading } = useGetPresetQuery({ + project_name: paramProjectName, + id: paramPresetId, + }); + + if (isLoading || !data) + return ( + + + + ); + + // The conditions the benchmark holds for: the workload it measured and the + // context the service was verified to serve. + const workload = (data.spec.preset.benchmark.workload ?? {}) as HashMap; + const dataset = workload.dataset as string | undefined; + const inputTokens = workload.input_tokens as number; + const sharedPrefix = (workload.shared_prefix_tokens as number) ?? 0; + + return ( + {t('presets.constraints')}}> + +
+ {t('presets.dataset')} +
{dataset ?? DEFAULT_DATASET}
+
+
+ {t('presets.input_tokens')} +
{formatTokenCount(inputTokens)}
+
+
+ {t('presets.output_tokens')} +
{formatTokenCount(workload.output_tokens as number)}
+
+ {sharedPrefix > 0 && ( +
+ {t('presets.shared_prefix')} +
+ {formatTokenCount(sharedPrefix)} ({Math.round((100 * sharedPrefix) / inputTokens)}%) +
+
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/Presets/Details/Deploy/index.tsx b/frontend/src/pages/Presets/Details/Deploy/index.tsx new file mode 100644 index 000000000..ec6fb2719 --- /dev/null +++ b/frontend/src/pages/Presets/Details/Deploy/index.tsx @@ -0,0 +1,125 @@ +import React, { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Button, ExpandableSection, Link, Popover, SpaceBetween, StatusIndicator, Tabs, Wizard } from 'components'; + +import { FLEETS_DOCS_URL } from 'consts'; +import { copyToClipboard } from 'libs'; + +const UV_INSTALL_COMMAND = 'uv tool install dstack -U'; +const PIP_INSTALL_COMMAND = 'pip install dstack -U'; + +const CopyableCommand: FC<{ command: string }> = ({ command }) => { + const { t } = useTranslation(); + + return ( + + {command} + {t('common.copied')}} + > + + + ); + } + + return ( + + + + ); + }; + + const renderNoMatchMessage = (): React.ReactNode => { + return ( + + + + ); + }; + + return { renderEmptyMessage, renderNoMatchMessage } as const; +}; + +export const useColumnsDefinitions = () => { + const { t } = useTranslation(); + + const columns = [ + { + id: 'name', + header: t('presets.name'), + // The name says which preset it points at today; the id is what + // identifies one, so that is what links to it. + cell: (item: IPreset) => item.name, + }, + { + id: 'id', + header: t('presets.id'), + cell: (item: IPreset) => ( + {item.id} + ), + }, + { + id: 'project', + header: t('presets.project'), + cell: (item: IPreset) => ( + {item.project_name} + ), + }, + { + id: 'base', + header: t('presets.base'), + cell: (item: IPreset) => item.base, + }, + { + id: 'repo', + header: t('presets.repo'), + cell: (item: IPreset) => item.repo, + }, + { + id: 'user', + header: t('presets.user'), + cell: (item: IPreset) => ( + {item.pushed_by} + ), + }, + { + id: 'created', + header: t('presets.created_at'), + cell: (item: IPreset) => format(new Date(item.created_at), DATE_TIME_FORMAT), + }, + ]; + + return { columns } as const; +}; + +export const usePresetsDelete = () => { + const { t } = useTranslation(); + const [request, { isLoading: isDeleting }] = useDeletePresetMutation(); + const [pushNotification] = useNotifications(); + + const deletePresets = (presets: IPreset[]) => { + return Promise.all( + presets.map((preset) => request({ project_name: preset.project_name, id: preset.id }).unwrap()), + ).catch((error) => { + pushNotification({ + type: 'error', + content: t('common.server_error', { error: getServerError(error) }), + }); + }); + }; + + return { isDeleting, deletePresets } as const; +}; + +export const useFilters = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => + requestParamsToTokens({ searchParams, filterKeys }), + ); + const [filteringOptions, setFilteringOptions] = useState([]); + const [filteringStatusType, setFilteringStatusType] = useState(); + const [getProjects] = useLazyGetProjectsQuery(); + const [getUsers] = useLazyGetUserListQuery(); + + const filteringProperties = [ + { + key: filterKeys.PROJECT_NAME, + operators: ['='], + propertyLabel: 'Project', + groupValuesLabel: 'Project values', + }, + { + key: filterKeys.USERNAME, + operators: ['='], + propertyLabel: 'User', + groupValuesLabel: 'User values', + }, + { + key: filterKeys.BASE, + operators: ['='], + propertyLabel: 'Base', + groupValuesLabel: 'Base values', + }, + ]; + + // Projects and users are suggested by the same name-pattern lookups the + // other list pages use; a base model is typed in, as no API enumerates one. + const handleLoadItems: PropertyFilterProps['onLoadItems'] = async ({ detail: { filteringProperty, filteringText } }) => { + setFilteringOptions([]); + setFilteringStatusType('loading'); + + if (filteringProperty?.key === filterKeys.PROJECT_NAME) { + await getProjects(getNamePatternFilterRequestParams(filteringText, MAX_FILTER_OPTIONS)) + .unwrap() + .then(({ data }) => + data.map(({ project_name }) => ({ + propertyKey: filterKeys.PROJECT_NAME, + value: project_name, + })), + ) + .then(setFilteringOptions); + } + + if (filteringProperty?.key === filterKeys.USERNAME) { + await getUsers(getNamePatternFilterRequestParams(filteringText, MAX_FILTER_OPTIONS)) + .unwrap() + .then(({ data }) => + data.map(({ username }) => ({ + propertyKey: filterKeys.USERNAME, + value: username, + })), + ) + .then(setFilteringOptions); + } + + setFilteringStatusType(undefined); + }; + + const onChangePropertyFilter: PropertyFilterProps['onChange'] = ({ detail }) => { + const filteredTokens = detail.tokens.filter((token, tokenIndex) => { + if (!token.propertyKey) return true; + return !detail.tokens.some((item, index) => tokenIndex < index && item.propertyKey === token.propertyKey); + }); + + setSearchParams(tokensToSearchParams(filteredTokens)); + setPropertyFilterQuery({ ...detail, tokens: filteredTokens }); + }; + + const clearFilter = () => { + setSearchParams({}); + setPropertyFilterQuery(EMPTY_QUERY); + }; + + const filteringRequestParams = useMemo(() => { + return tokensToRequestParams({ tokens: propertyFilterQuery.tokens }); + }, [propertyFilterQuery]); + + const isDisabledClearFilter = !propertyFilterQuery.tokens.length; + + return { + filteringRequestParams, + clearFilter, + propertyFilterQuery, + onChangePropertyFilter, + filteringOptions, + filteringProperties, + isDisabledClearFilter, + filteringStatusType, + handleLoadItems, + } as const; +}; diff --git a/frontend/src/pages/Presets/List/index.tsx b/frontend/src/pages/Presets/List/index.tsx new file mode 100644 index 000000000..67646fcfd --- /dev/null +++ b/frontend/src/pages/Presets/List/index.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, ButtonWithConfirmation, Header, Loader, PropertyFilter, SpaceBetween, Table } from 'components'; + +import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; +import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; +import { ROUTES } from 'routes'; +import { useLazyGetAllPresetsQuery } from 'services/preset'; + +import { useColumnsDefinitions, useFilters, usePresetsDelete, usePresetsTableEmptyMessages } from './hooks'; + +export const PresetList: React.FC = () => { + const { t } = useTranslation(); + + const { + clearFilter, + propertyFilterQuery, + onChangePropertyFilter, + filteringOptions, + filteringProperties, + filteringRequestParams, + isDisabledClearFilter, + filteringStatusType, + handleLoadItems, + } = useFilters(); + + const { isDeleting, deletePresets } = usePresetsDelete(); + + const { renderEmptyMessage, renderNoMatchMessage } = usePresetsTableEmptyMessages({ + clearFilter, + isDisabledClearFilter, + }); + + const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ + useLazyQuery: useLazyGetAllPresetsQuery, + args: { ...filteringRequestParams, limit: DEFAULT_TABLE_PAGE_SIZE } as TPresetsListRequestParams, + + getPaginationParams: (lastPreset) => ({ + prev_created_at: lastPreset.created_at, + prev_id: lastPreset.id, + }), + }); + + useBreadcrumbs([ + { + text: t('navigation.presets'), + href: ROUTES.PRESETS.LIST, + }, + ]); + + const { columns } = useColumnsDefinitions(); + + const { items, actions, collectionProps } = useCollection(data ?? [], { + filtering: { + empty: renderEmptyMessage(), + noMatch: renderNoMatchMessage(), + }, + selection: {}, + }); + + const { selectedItems } = collectionProps; + + const deleteSelected = () => { + if (!selectedItems?.length) return; + + deletePresets([...selectedItems]).then(() => { + actions.setSelectedItems([]); + refreshList(); + }); + }; + + const isDisabledDelete = isDeleting || !selectedItems?.length; + + return ( + + + {t('common.delete')} + + +