From b57dbe8e25c6f6aad441cca14ab9264091b59e13 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Fri, 25 Sep 2026 13:52:08 +0200 Subject: [PATCH 1/2] Add Daytona catalog provider Signed-off-by: Mislav Ivanda --- .github/workflows/catalogs.yml | 33 +++++ README.md | 1 + src/gpuhunt/__main__.py | 5 + src/gpuhunt/_internal/catalog.py | 1 + src/gpuhunt/providers/daytona.py | 116 ++++++++++++++++ src/integrity_tests/test_daytona.py | 22 +++ src/tests/providers/test_daytona.py | 201 ++++++++++++++++++++++++++++ 7 files changed, 379 insertions(+) create mode 100644 src/gpuhunt/providers/daytona.py create mode 100644 src/integrity_tests/test_daytona.py create mode 100644 src/tests/providers/test_daytona.py diff --git a/.github/workflows/catalogs.yml b/.github/workflows/catalogs.yml index 6510475..8b16105 100644 --- a/.github/workflows/catalogs.yml +++ b/.github/workflows/catalogs.yml @@ -304,6 +304,37 @@ jobs: path: runpod.csv retention-days: 1 + catalog-daytona: + name: Collect Daytona catalog + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: Install dependencies + run: | + uv sync --extra dev + uv tool install awscli + - name: Collect catalog + working-directory: src + run: uv run python -m gpuhunt daytona --output ../daytona.csv + - name: Test catalog integrity + env: + CATALOG_DIR: . + run: uv run pytest src/integrity_tests/test_daytona.py + - name: Publish catalog + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + run: .github/scripts/publish_catalog.sh daytona + - uses: actions/upload-artifact@v4 + with: + name: catalogs-daytona + path: daytona.csv + retention-days: 1 + catalog-cloudrift: name: Collect CloudRift catalog runs-on: ubuntu-latest @@ -462,6 +493,7 @@ jobs: - catalog-oci - catalog-runpod - catalog-cloudrift + - catalog-daytona runs-on: ubuntu-latest steps: - name: Set up uv @@ -507,6 +539,7 @@ jobs: - catalog-oci - catalog-runpod - catalog-cloudrift + - catalog-daytona - test-crusoe - test-digitalocean - test-hotaisle diff --git a/README.md b/README.md index 705fb42..169622e 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ print(*items, sep="\n") * Azure * CloudRift * Crusoe +* Daytona * DigitalOcean * GCP * Hot Aisle diff --git a/src/gpuhunt/__main__.py b/src/gpuhunt/__main__.py index b1d3a1d..03d4f05 100644 --- a/src/gpuhunt/__main__.py +++ b/src/gpuhunt/__main__.py @@ -16,6 +16,7 @@ def main(): "azure", "cloudrift", "crusoe", + "daytona", "verda", "digitalocean", "gcp", @@ -51,6 +52,10 @@ def main(): from gpuhunt.providers.cloudrift import CloudRiftProvider provider = CloudRiftProvider() + elif args.provider == "daytona": + from gpuhunt.providers.daytona import DaytonaProvider + + provider = DaytonaProvider() elif args.provider == "verda": from gpuhunt.providers.verda import VerdaProvider diff --git a/src/gpuhunt/_internal/catalog.py b/src/gpuhunt/_internal/catalog.py index 814fff9..3c359c7 100644 --- a/src/gpuhunt/_internal/catalog.py +++ b/src/gpuhunt/_internal/catalog.py @@ -33,6 +33,7 @@ "oci", "runpod", "cloudrift", + "daytona", ] ONLINE_PROVIDERS = [ "crusoe", diff --git a/src/gpuhunt/providers/daytona.py b/src/gpuhunt/providers/daytona.py new file mode 100644 index 0000000..603de08 --- /dev/null +++ b/src/gpuhunt/providers/daytona.py @@ -0,0 +1,116 @@ +import logging +from typing import Any + +import requests + +from gpuhunt import CatalogItem, QueryFilter +from gpuhunt._internal.models import AcceleratorVendor +from gpuhunt.providers.base import OfflineProvider + +logger = logging.getLogger(__name__) + +# Public rate card, no authentication. Served from the same definitions the +# billing rate card is built from; both capacity types (on-demand and spot) +# are published for GPUs and resources alike. +API_URL = "https://billing.app.daytona.io/gpu-pricing" +TIMEOUT = 10.0 + +# Daytona bills GPU sandboxes a la carte: each GPU type carries a per-GPU +# hourly rate, and vCPU/RAM/disk are billed separately per second, so there +# are no fixed instance types. The catalog therefore publishes representative +# sandbox configurations: per GPU, 8 vCPU with 100 GB RAM on datacenter-class +# cards (50 GB on consumer cards) and 256 GB disk, scaled linearly with the +# GPU count. Spot offers price the GPU and the resources at Daytona's spot +# rates, which are discounted independently of the on-demand rates. +GPU_COUNTS = [1, 2, 4, 8] +CPUS_PER_GPU = 8 +DISK_PER_GPU = 256.0 # GB +MEMORY_PER_GPU = 100.0 # GB, datacenter-class cards +CONSUMER_MEMORY_PER_GPU = 50.0 # GB, consumer-class cards + +# Daytona GPU type -> (canonical gpuhunt name, vendor, VRAM in GB, RAM per GPU in GB) +GPU_MAP: dict[str, tuple[str, AcceleratorVendor, float, float]] = { + "B300": ("B300", AcceleratorVendor.NVIDIA, 270.0, MEMORY_PER_GPU), + "H100": ("H100", AcceleratorVendor.NVIDIA, 80.0, MEMORY_PER_GPU), + "H200": ("H200", AcceleratorVendor.NVIDIA, 141.0, MEMORY_PER_GPU), + "B200": ("B200", AcceleratorVendor.NVIDIA, 180.0, MEMORY_PER_GPU), + "MI355X": ("MI355X", AcceleratorVendor.AMD, 288.0, MEMORY_PER_GPU), + "RTX-PRO-6000": ("RTXPRO6000", AcceleratorVendor.NVIDIA, 96.0, MEMORY_PER_GPU), + "RTX-5090": ("RTX5090", AcceleratorVendor.NVIDIA, 32.0, CONSUMER_MEMORY_PER_GPU), + "RTX-4090": ("RTX4090", AcceleratorVendor.NVIDIA, 24.0, CONSUMER_MEMORY_PER_GPU), +} + +# GPU capacity is pooled and scheduled across Daytona's fleet; a specific +# region is not user-selectable on shared capacity, so offers are published +# under the primary region. +LOCATION = "us" + + +class DaytonaProvider(OfflineProvider): + NAME = "daytona" + + def get( + self, + query_filter: QueryFilter | None = None, + balance_resources: bool = True, + apply_filter: bool = False, + ) -> list[CatalogItem]: + pricing = _fetch_pricing() + offers = _make_offers(pricing) + return sorted(offers, key=lambda i: i.price) + + +def _fetch_pricing() -> dict[str, Any]: + response = requests.get(API_URL, timeout=TIMEOUT) + response.raise_for_status() + pricing = response.json() + if ( + not isinstance(pricing, dict) + or not isinstance(pricing.get("gpus"), list) + or not isinstance(pricing.get("resources"), dict) + or not isinstance(pricing["resources"].get("onDemand"), dict) + or not isinstance(pricing["resources"].get("spot"), dict) + ): + raise ValueError(f"Unexpected gpu-pricing response: {pricing!r}") + return pricing + + +def _make_offers(pricing: dict[str, Any]) -> list[CatalogItem]: + offers = [] + for gpu in pricing["gpus"]: + gpu_info = GPU_MAP.get(gpu["type"]) + if gpu_info is None: + logger.warning("Failed to find GPU name matching '%s'", gpu["type"]) + continue + gpu_name, gpu_vendor, gpu_memory, memory_per_gpu = gpu_info + for spot in (False, True): + gpu_rate = gpu["spotPricePerHour" if spot else "onDemandPricePerHour"] + resources = pricing["resources"]["spot" if spot else "onDemand"] + for gpu_count in GPU_COUNTS: + cpu = CPUS_PER_GPU * gpu_count + memory = memory_per_gpu * gpu_count + disk_size = DISK_PER_GPU * gpu_count + price = round( + gpu_count * gpu_rate + + cpu * resources["vcpuPerHour"] + + memory * resources["memoryGiBPerHour"] + + disk_size * resources["diskGiBPerHour"], + 6, + ) + offers.append( + CatalogItem( + provider=DaytonaProvider.NAME, + instance_name=f"{gpu_count}x-{gpu_name}", + location=LOCATION, + price=price, + cpu=cpu, + memory=memory, + gpu_count=gpu_count, + gpu_name=gpu_name, + gpu_memory=gpu_memory, + spot=spot, + disk_size=disk_size, + gpu_vendor=gpu_vendor, + ) + ) + return offers diff --git a/src/integrity_tests/test_daytona.py b/src/integrity_tests/test_daytona.py new file mode 100644 index 0000000..af690d1 --- /dev/null +++ b/src/integrity_tests/test_daytona.py @@ -0,0 +1,22 @@ +import pytest + +from gpuhunt import CatalogItem +from gpuhunt.providers.daytona import GPU_MAP +from integrity_tests.base import CatalogFileIntegrityTests + + +class TestDaytonaCatalog(CatalogFileIntegrityTests): + CATALOG_NAME = "daytona" + + def test_no_unexpected_gpus(self, offers: list[CatalogItem]) -> None: + expected_gpus = {name for name, _, _, _ in GPU_MAP.values()} + gpus = {o.gpu_name for o in offers if o.gpu_name} + assert not gpus - expected_gpus + + @pytest.mark.parametrize("gpu_count", [1, 2, 4, 8]) + def test_gpu_count_present(self, gpu_count: int, offers: list[CatalogItem]) -> None: + assert any(o.gpu_count == gpu_count for o in offers) + + def test_both_capacity_types_present(self, offers: list[CatalogItem]) -> None: + assert any(o.spot for o in offers) + assert any(not o.spot for o in offers) diff --git a/src/tests/providers/test_daytona.py b/src/tests/providers/test_daytona.py new file mode 100644 index 0000000..c7a15e5 --- /dev/null +++ b/src/tests/providers/test_daytona.py @@ -0,0 +1,201 @@ +import copy +import logging + +import pytest +import requests + +import gpuhunt.providers.daytona as daytona_module +from gpuhunt._internal.models import AcceleratorVendor +from gpuhunt.providers.daytona import API_URL, GPU_COUNTS, GPU_MAP, DaytonaProvider + +PAYLOAD = { + "generatedAt": "2026-09-23T08:43:09Z", + "currency": "USD", + "gpus": [ + { + "type": "B300", + "onDemandPricePerHour": 7.1, + "spotPricePerHour": 4.08, + "onDemandPricePerSecond": 0.0019722222222222222, + "spotPricePerSecond": 0.0011333333333333334, + }, + { + "type": "B200", + "onDemandPricePerHour": 6.25, + "spotPricePerHour": 3.59, + "onDemandPricePerSecond": 0.001736111111111111, + "spotPricePerSecond": 0.0009972222222222223, + }, + { + "type": "MI355X", + "onDemandPricePerHour": 5.99, + "spotPricePerHour": 3.44, + "onDemandPricePerSecond": 0.0016638888888888888, + "spotPricePerSecond": 0.0009555555555555555, + }, + { + "type": "H200", + "onDemandPricePerHour": 4.54, + "spotPricePerHour": 2.61, + "onDemandPricePerSecond": 0.001261, + "spotPricePerSecond": 0.000725, + }, + { + "type": "H100", + "onDemandPricePerHour": 3.95, + "spotPricePerHour": 2.27, + "onDemandPricePerSecond": 0.001097, + "spotPricePerSecond": 0.0006305555555555555, + }, + { + "type": "RTX-PRO-6000", + "onDemandPricePerHour": 3.03, + "spotPricePerHour": 1.74, + "onDemandPricePerSecond": 0.0008416666666666667, + "spotPricePerSecond": 0.00048333333333333334, + }, + { + "type": "RTX-5090", + "onDemandPricePerHour": 1.29, + "spotPricePerHour": 0.74, + "onDemandPricePerSecond": 0.00035833333333333333, + "spotPricePerSecond": 0.00020555555555555556, + }, + { + "type": "RTX-4090", + "onDemandPricePerHour": 0.99, + "spotPricePerHour": 0.57, + "onDemandPricePerSecond": 0.000275, + "spotPricePerSecond": 0.00015833333333333332, + }, + ], + "resources": { + "onDemand": { + "vcpuPerHour": 0.0504, + "memoryGiBPerHour": 0.0162, + "diskGiBPerHour": 0.000108, + }, + "spot": { + "vcpuPerHour": 0.03, + "memoryGiBPerHour": 0.0093, + "diskGiBPerHour": 0.000062, + }, + }, + "notes": ["GPU rates are per GPU. Sandbox vCPU, memory, and disk are billed separately."], +} + + +class FakeResponse: + def __init__(self, payload, status_code: int = 200): + self.payload = payload + self.status_code = status_code + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise requests.HTTPError(f"status {self.status_code}") + + def json(self): + return self.payload + + +@pytest.fixture +def requested_urls(monkeypatch) -> list[tuple[str, float]]: + requested = [] + + def fake_get(url, timeout): + requested.append((url, timeout)) + return FakeResponse(copy.deepcopy(PAYLOAD)) + + monkeypatch.setattr(daytona_module.requests, "get", fake_get) + return requested + + +def test_offers_cover_every_gpu_count_and_capacity_type(requested_urls): + offers = DaytonaProvider().get() + + assert len(offers) == len(PAYLOAD["gpus"]) * len(GPU_COUNTS) * 2 + assert [(url, timeout) for url, timeout in requested_urls] == [(API_URL, 10.0)] + assert offers == sorted(offers, key=lambda o: o.price) + assert {o.gpu_name for o in offers} == {name for name, _, _, _ in GPU_MAP.values()} + assert {o.gpu_count for o in offers} == set(GPU_COUNTS) + assert {o.spot for o in offers} == {False, True} + assert all(o.provider == "daytona" and o.location == "us" for o in offers) + + +def test_prices_combine_gpu_and_resource_rates_per_capacity_type(requested_urls): + offers = DaytonaProvider().get() + + def find(gpu_name: str, gpu_count: int, spot: bool): + return next( + o + for o in offers + if o.gpu_name == gpu_name and o.gpu_count == gpu_count and o.spot is spot + ) + + # 3.95 GPU + 8 vCPU * 0.0504 + 100 GB * 0.0162 + 256 GB disk * 0.000108 + h100_on_demand = find("H100", 1, spot=False) + assert h100_on_demand.price == 6.000848 + assert (h100_on_demand.cpu, h100_on_demand.memory, h100_on_demand.disk_size) == ( + 8, + 100.0, + 256.0, + ) + assert h100_on_demand.gpu_memory == 80.0 + assert h100_on_demand.gpu_vendor == AcceleratorVendor.NVIDIA + + # Spot discounts the resources too: 2.27 + 8 * 0.03 + 100 * 0.0093 + 256 * 0.000062 + h100_spot = find("H100", 1, spot=True) + assert h100_spot.price == 3.455872 + + # Consumer cards carry 50 GB RAM per GPU. + rtx4090_on_demand = find("RTX4090", 1, spot=False) + assert rtx4090_on_demand.price == 2.230848 + assert rtx4090_on_demand.memory == 50.0 + + # Resources scale linearly with the GPU count. + h100_8x = find("H100", 8, spot=False) + assert (h100_8x.cpu, h100_8x.memory, h100_8x.disk_size) == (64, 800.0, 2048.0) + assert h100_8x.price == 48.006784 + + mi355x = find("MI355X", 1, spot=False) + assert mi355x.gpu_vendor == AcceleratorVendor.AMD + assert mi355x.gpu_memory == 288.0 + + +def test_unknown_gpu_type_is_skipped_with_warning(monkeypatch, caplog): + payload = copy.deepcopy(PAYLOAD) + payload["gpus"].append( + {"type": "B400", "onDemandPricePerHour": 9.99, "spotPricePerHour": 5.99} + ) + monkeypatch.setattr(daytona_module.requests, "get", lambda url, timeout: FakeResponse(payload)) + + with caplog.at_level(logging.WARNING): + offers = DaytonaProvider().get() + + assert len(offers) == len(PAYLOAD["gpus"]) * len(GPU_COUNTS) * 2 + assert "B400" in caplog.text + + +def test_http_error_propagates(monkeypatch): + monkeypatch.setattr( + daytona_module.requests, "get", lambda url, timeout: FakeResponse({}, status_code=500) + ) + + with pytest.raises(requests.HTTPError): + DaytonaProvider().get() + + +@pytest.mark.parametrize( + "payload", + [ + "not a dict", + {}, + {"gpus": "not a list", "resources": {"onDemand": {}, "spot": {}}}, + {"gpus": [], "resources": {"onDemand": {}}}, + ], +) +def test_malformed_payload_is_rejected(monkeypatch, payload): + monkeypatch.setattr(daytona_module.requests, "get", lambda url, timeout: FakeResponse(payload)) + + with pytest.raises(ValueError): + DaytonaProvider().get() From cc9749c0887653409595d5af3a5fe4a12c932faf Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Sun, 27 Sep 2026 21:05:51 +0200 Subject: [PATCH 2/2] Make Daytona an online provider with CPU support --- .github/workflows/catalogs.yml | 28 +- README.md | 4 +- src/gpuhunt/__main__.py | 2 +- src/gpuhunt/_internal/catalog.py | 2 +- src/gpuhunt/_internal/default.py | 1 + src/gpuhunt/providers/daytona.py | 289 ++++++++++--- src/integrity_tests/test_daytona.py | 46 +- src/tests/_internal/test_default.py | 6 +- src/tests/providers/test_daytona.py | 624 ++++++++++++++++++++++++---- 9 files changed, 808 insertions(+), 194 deletions(-) diff --git a/.github/workflows/catalogs.yml b/.github/workflows/catalogs.yml index 8b16105..6f412c6 100644 --- a/.github/workflows/catalogs.yml +++ b/.github/workflows/catalogs.yml @@ -304,8 +304,8 @@ jobs: path: runpod.csv retention-days: 1 - catalog-daytona: - name: Collect Daytona catalog + test-daytona: + name: Test Daytona integrity runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -314,26 +314,11 @@ jobs: with: python-version: ${{ env.PYTHON_VERSION }} - name: Install dependencies - run: | - uv sync --extra dev - uv tool install awscli - - name: Collect catalog - working-directory: src - run: uv run python -m gpuhunt daytona --output ../daytona.csv - - name: Test catalog integrity + run: uv sync --extra dev + - name: Run integrity tests env: - CATALOG_DIR: . + DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }} run: uv run pytest src/integrity_tests/test_daytona.py - - name: Publish catalog - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - run: .github/scripts/publish_catalog.sh daytona - - uses: actions/upload-artifact@v4 - with: - name: catalogs-daytona - path: daytona.csv - retention-days: 1 catalog-cloudrift: name: Collect CloudRift catalog @@ -493,7 +478,6 @@ jobs: - catalog-oci - catalog-runpod - catalog-cloudrift - - catalog-daytona runs-on: ubuntu-latest steps: - name: Set up uv @@ -539,8 +523,8 @@ jobs: - catalog-oci - catalog-runpod - catalog-cloudrift - - catalog-daytona - test-crusoe + - test-daytona - test-digitalocean - test-hotaisle - test-jarvislabs diff --git a/README.md b/README.md index 169622e..5505096 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,9 @@ List of all available filters: ## Advanced usage -Every provider catalog is published and versioned independently, so a provider that fails +Every offline provider catalog is published and versioned independently, so a provider that fails to be collected keeps its previous catalog while the other providers are updated. Passing -a version to `load()` requests that version from every provider: +a version to `load()` requests that version from every offline provider: ```python from gpuhunt import Catalog diff --git a/src/gpuhunt/__main__.py b/src/gpuhunt/__main__.py index 03d4f05..b95f367 100644 --- a/src/gpuhunt/__main__.py +++ b/src/gpuhunt/__main__.py @@ -55,7 +55,7 @@ def main(): elif args.provider == "daytona": from gpuhunt.providers.daytona import DaytonaProvider - provider = DaytonaProvider() + provider = DaytonaProvider.from_env() elif args.provider == "verda": from gpuhunt.providers.verda import VerdaProvider diff --git a/src/gpuhunt/_internal/catalog.py b/src/gpuhunt/_internal/catalog.py index 3c359c7..b0a0c5b 100644 --- a/src/gpuhunt/_internal/catalog.py +++ b/src/gpuhunt/_internal/catalog.py @@ -33,10 +33,10 @@ "oci", "runpod", "cloudrift", - "daytona", ] ONLINE_PROVIDERS = [ "crusoe", + "daytona", "digitalocean", "hotaisle", "jarvislabs", diff --git a/src/gpuhunt/_internal/default.py b/src/gpuhunt/_internal/default.py index a9dbea4..6473cc4 100644 --- a/src/gpuhunt/_internal/default.py +++ b/src/gpuhunt/_internal/default.py @@ -15,6 +15,7 @@ # Every provider in `ONLINE_PROVIDERS` must be listed here to be queried by `default_catalog`. ONLINE_PROVIDER_MODULES = [ ("gpuhunt.providers.crusoe", "CrusoeProvider"), + ("gpuhunt.providers.daytona", "DaytonaProvider"), ("gpuhunt.providers.digitalocean", "DigitalOceanProvider"), ("gpuhunt.providers.hotaisle", "HotAisleProvider"), ("gpuhunt.providers.jarvislabs", "JarvisLabsProvider"), diff --git a/src/gpuhunt/providers/daytona.py b/src/gpuhunt/providers/daytona.py index 603de08..2511faa 100644 --- a/src/gpuhunt/providers/daytona.py +++ b/src/gpuhunt/providers/daytona.py @@ -1,69 +1,140 @@ import logging -from typing import Any +import math +import os +import threading +from dataclasses import replace +from typing import Any, cast import requests +from typing_extensions import NotRequired, TypedDict from gpuhunt import CatalogItem, QueryFilter -from gpuhunt._internal.models import AcceleratorVendor -from gpuhunt.providers.base import OfflineProvider +from gpuhunt._internal.constraints import matches +from gpuhunt._internal.models import AcceleratorVendor, JSONObject +from gpuhunt.providers.base import OnlineProvider logger = logging.getLogger(__name__) -# Public rate card, no authentication. Served from the same definitions the -# billing rate card is built from; both capacity types (on-demand and spot) -# are published for GPUs and resources alike. -API_URL = "https://billing.app.daytona.io/gpu-pricing" +PRICING_URL = "https://billing.app.daytona.io/gpu-pricing" +API_URL = "https://app.daytona.io/api" TIMEOUT = 10.0 +GPU_REGION = "earth" +MAX_GPU_COUNT = 8 -# Daytona bills GPU sandboxes a la carte: each GPU type carries a per-GPU -# hourly rate, and vCPU/RAM/disk are billed separately per second, so there -# are no fixed instance types. The catalog therefore publishes representative -# sandbox configurations: per GPU, 8 vCPU with 100 GB RAM on datacenter-class -# cards (50 GB on consumer cards) and 256 GB disk, scaled linearly with the -# GPU count. Spot offers price the GPU and the resources at Daytona's spot -# rates, which are discounted independently of the on-demand rates. -GPU_COUNTS = [1, 2, 4, 8] +# Preferences for balanced offers, not fixed instance configurations. Explicit +# query bounds take precedence. Daytona meters each resource independently. CPUS_PER_GPU = 8 -DISK_PER_GPU = 256.0 # GB -MEMORY_PER_GPU = 100.0 # GB, datacenter-class cards -CONSUMER_MEMORY_PER_GPU = 50.0 # GB, consumer-class cards +DISK_PER_GPU = 256 +MEMORY_PER_GPU = 100 +CONSUMER_MEMORY_PER_GPU = 50 -# Daytona GPU type -> (canonical gpuhunt name, vendor, VRAM in GB, RAM per GPU in GB) +# Published GPU sandbox limits: https://www.daytona.io/docs/en/sandboxes/#gpu-sandboxes +MAX_CPUS_PER_GPU = 16 +MAX_MEMORY_PER_GPU = 192 +MAX_DISK_PER_GPU = 512 + +# Only types supported by sandbox creation belong here. B200 has billing rates, +# but the create API rejects gpuType=["B200"] as an invalid GPU type. GPU_MAP: dict[str, tuple[str, AcceleratorVendor, float, float]] = { "B300": ("B300", AcceleratorVendor.NVIDIA, 270.0, MEMORY_PER_GPU), "H100": ("H100", AcceleratorVendor.NVIDIA, 80.0, MEMORY_PER_GPU), "H200": ("H200", AcceleratorVendor.NVIDIA, 141.0, MEMORY_PER_GPU), - "B200": ("B200", AcceleratorVendor.NVIDIA, 180.0, MEMORY_PER_GPU), "MI355X": ("MI355X", AcceleratorVendor.AMD, 288.0, MEMORY_PER_GPU), "RTX-PRO-6000": ("RTXPRO6000", AcceleratorVendor.NVIDIA, 96.0, MEMORY_PER_GPU), "RTX-5090": ("RTX5090", AcceleratorVendor.NVIDIA, 32.0, CONSUMER_MEMORY_PER_GPU), "RTX-4090": ("RTX4090", AcceleratorVendor.NVIDIA, 24.0, CONSUMER_MEMORY_PER_GPU), } -# GPU capacity is pooled and scheduled across Daytona's fleet; a specific -# region is not user-selectable on shared capacity, so offers are published -# under the primary region. -LOCATION = "us" + +class DaytonaCatalogItemProviderData(TypedDict): + # Daytona GPU name, included only when it differs from the gpuhunt GPU name. + gpu_type: NotRequired[str] -class DaytonaProvider(OfflineProvider): +class DaytonaProvider(OnlineProvider): NAME = "daytona" + def __init__(self, api_key: str | None = None, api_url: str = API_URL): + self.api_key = (api_key or "").strip() or None + self.api_url = api_url.rstrip("/") + self._organization_id: str | None = None + self._warned_no_auth = False + self._lock = threading.Lock() + + @classmethod + def from_env(cls) -> "DaytonaProvider": + return cls( + api_key=os.getenv("DAYTONA_API_KEY"), + api_url=os.getenv("DAYTONA_API_URL", API_URL), + ) + def get( self, query_filter: QueryFilter | None = None, balance_resources: bool = True, apply_filter: bool = False, ) -> list[CatalogItem]: + if not self.api_key: + with self._lock: + if not self._warned_no_auth: + logger.warning( + "DAYTONA_API_KEY is not set. Returning price estimates without " + "checking live availability." + ) + self._warned_no_auth = True + query = query_filter or QueryFilter() pricing = _fetch_pricing() - offers = _make_offers(pricing) + offers = [] + if query.max_gpu_count is None or query.max_gpu_count > 0: + capacity = self._get_gpu_capacity() if self.api_key else None + offers.extend(_make_gpu_offers(pricing, query, balance_resources, capacity)) + cpu_offer = _make_offer(pricing, query) + if cpu_offer is not None: + offers.extend( + replace(cpu_offer, location=region) + for region in _fetch_shared_regions(self.api_url) + ) return sorted(offers, key=lambda i: i.price) + def _get_gpu_capacity(self) -> dict[str, dict[str, int]]: + with self._lock: + if self._organization_id is None: + identity = _request_json(f"{self.api_url}/api-keys/current", self.api_key) + if not ( + isinstance(identity, dict) + and isinstance(identity.get("organizationId"), str) + and identity["organizationId"] + ): + raise ValueError("Unexpected Daytona API key response: missing organizationId") + self._organization_id = identity["organizationId"] + data = _request_json( + f"{self.api_url}/organizations/{self._organization_id}/gpu-capacity", self.api_key + ) + if not isinstance(data, dict) or not isinstance(data.get("capacity"), list): + raise ValueError("Unexpected Daytona GPU capacity response") + capacity = {} + for row in data["capacity"]: + if not isinstance(row, dict) or not isinstance(row.get("gpuType"), str): + raise ValueError("Unexpected Daytona GPU capacity entry") + capacity[row["gpuType"]] = { + "onDemand": _count(row.get("availableOnDemand")), + "spot": _count(row.get("availableSpot")), + } + return capacity -def _fetch_pricing() -> dict[str, Any]: - response = requests.get(API_URL, timeout=TIMEOUT) + +def _request_json(url: str, api_key: str | None = None) -> Any: + response = requests.get( + url, + headers={"Authorization": f"Bearer {api_key}"} if api_key else None, + timeout=TIMEOUT, + ) response.raise_for_status() - pricing = response.json() + return response.json() + + +def _fetch_pricing() -> dict[str, Any]: + pricing = _request_json(PRICING_URL) if ( not isinstance(pricing, dict) or not isinstance(pricing.get("gpus"), list) @@ -71,46 +142,136 @@ def _fetch_pricing() -> dict[str, Any]: or not isinstance(pricing["resources"].get("onDemand"), dict) or not isinstance(pricing["resources"].get("spot"), dict) ): - raise ValueError(f"Unexpected gpu-pricing response: {pricing!r}") + raise ValueError("Unexpected Daytona gpu-pricing response") return pricing -def _make_offers(pricing: dict[str, Any]) -> list[CatalogItem]: +def _fetch_shared_regions(api_url: str) -> list[str]: + regions = _request_json(f"{api_url}/shared-regions") + if not isinstance(regions, list) or any( + not isinstance(region, dict) or not isinstance(region.get("id"), str) or not region["id"] + for region in regions + ): + raise ValueError("Unexpected Daytona shared regions response") + return [region["id"] for region in regions] + + +def _count(value: Any) -> int: + if ( + isinstance(value, bool) + or not isinstance(value, int | float) + or not math.isfinite(value) + or value < 0 + or int(value) != value + ): + raise ValueError("Unexpected Daytona resource count") + return int(value) + + +def _resource_size( + preferred: float, minimum: float | None, maximum: float | None, limit: float = math.inf +) -> int | None: + lower = math.ceil(max(1, minimum if minimum is not None else 1)) + upper = min(limit, maximum if maximum is not None else limit) + if lower > upper: + return None + return math.floor(min(upper, max(lower, math.ceil(preferred)))) + + +def _make_gpu_offers( + pricing: dict[str, Any], + query: QueryFilter, + balance_resources: bool, + capacity: dict[str, dict[str, int]] | None, +) -> list[CatalogItem]: offers = [] for gpu in pricing["gpus"]: - gpu_info = GPU_MAP.get(gpu["type"]) - if gpu_info is None: - logger.warning("Failed to find GPU name matching '%s'", gpu["type"]) + gpu_type = gpu["type"] + if gpu_type not in GPU_MAP: + # B200 is a known billing-only entry, not a newly introduced type. + if gpu_type != "B200": + logger.warning("Failed to find GPU name matching '%s'", gpu_type) continue - gpu_name, gpu_vendor, gpu_memory, memory_per_gpu = gpu_info - for spot in (False, True): - gpu_rate = gpu["spotPricePerHour" if spot else "onDemandPricePerHour"] - resources = pricing["resources"]["spot" if spot else "onDemand"] - for gpu_count in GPU_COUNTS: - cpu = CPUS_PER_GPU * gpu_count - memory = memory_per_gpu * gpu_count - disk_size = DISK_PER_GPU * gpu_count - price = round( - gpu_count * gpu_rate - + cpu * resources["vcpuPerHour"] - + memory * resources["memoryGiBPerHour"] - + disk_size * resources["diskGiBPerHour"], - 6, - ) - offers.append( - CatalogItem( - provider=DaytonaProvider.NAME, - instance_name=f"{gpu_count}x-{gpu_name}", - location=LOCATION, - price=price, - cpu=cpu, - memory=memory, - gpu_count=gpu_count, - gpu_name=gpu_name, - gpu_memory=gpu_memory, - spot=spot, - disk_size=disk_size, - gpu_vendor=gpu_vendor, - ) + for pricing_type in ("onDemand", "spot"): + available = MAX_GPU_COUNT + if capacity is not None: + available = min(available, capacity.get(gpu_type, {}).get(pricing_type, 0)) + for gpu_count in range(1, available + 1): + offer = _make_offer( + pricing, + query, + gpu=gpu, + gpu_count=gpu_count, + pricing_type=pricing_type, + balance_resources=balance_resources, ) + if offer is not None: + offers.append(offer) return offers + + +def _make_offer( + pricing: dict[str, Any], + query: QueryFilter, + *, + gpu: dict[str, Any] | None = None, + gpu_count: int = 0, + pricing_type: str = "onDemand", + balance_resources: bool = True, +) -> CatalogItem | None: + if gpu is None: + # CPU sandboxes have no published provider-wide resource limits. + gpu_name, gpu_vendor, gpu_memory = None, None, None + default_cpu, default_memory, default_disk = 1, 1, 3 + cpu_limit = memory_limit = disk_limit = math.inf + gpu_price = 0 + provider_data = {} + else: + gpu_name, gpu_vendor, gpu_memory, memory_per_gpu = GPU_MAP[gpu["type"]] + default_cpu = CPUS_PER_GPU * gpu_count if balance_resources else 1 + default_memory = memory_per_gpu * gpu_count if balance_resources else 1 + default_disk = DISK_PER_GPU * gpu_count if balance_resources else 1 + cpu_limit = MAX_CPUS_PER_GPU * gpu_count + memory_limit = MAX_MEMORY_PER_GPU * gpu_count + disk_limit = MAX_DISK_PER_GPU * gpu_count + gpu_price = gpu_count * gpu[f"{pricing_type}PricePerHour"] + provider_data = _gpu_provider_data(gpu["type"], gpu_name) + + cpu = _resource_size(default_cpu, query.min_cpu, query.max_cpu, cpu_limit) + memory = _resource_size(default_memory, query.min_memory, query.max_memory, memory_limit) + disk_size = _resource_size(default_disk, query.min_disk_size, query.max_disk_size, disk_limit) + if cpu is None or memory is None or disk_size is None: + return None + rates = pricing["resources"][pricing_type] + instance_name = f"{cpu}cpu-{memory}gb-{disk_size}gb" + if gpu is not None: + instance_name = f"{gpu_count}x-{gpu_name}-{instance_name}" + offer = CatalogItem( + provider=DaytonaProvider.NAME, + instance_name=instance_name, + # CPU offers are copied to each shared region after filtering the shape. + location=GPU_REGION if gpu is not None else "", + price=round( + gpu_price + + cpu * rates["vcpuPerHour"] + + memory * rates["memoryGiBPerHour"] + + disk_size * rates["diskGiBPerHour"], + 6, + ), + cpu=cpu, + memory=float(memory), + disk_size=float(disk_size), + gpu_count=gpu_count, + gpu_name=gpu_name, + gpu_memory=gpu_memory, + gpu_vendor=gpu_vendor, + spot=pricing_type == "spot", + provider_data=provider_data, + ) + return offer if matches(offer, query) else None + + +def _gpu_provider_data(gpu_type: str, gpu_name: str) -> JSONObject: + if gpu_type == gpu_name: + return {} + return cast(JSONObject, DaytonaCatalogItemProviderData(gpu_type=gpu_type)) diff --git a/src/integrity_tests/test_daytona.py b/src/integrity_tests/test_daytona.py index af690d1..36209c6 100644 --- a/src/integrity_tests/test_daytona.py +++ b/src/integrity_tests/test_daytona.py @@ -1,22 +1,46 @@ import pytest from gpuhunt import CatalogItem -from gpuhunt.providers.daytona import GPU_MAP -from integrity_tests.base import CatalogFileIntegrityTests +from gpuhunt.providers.daytona import GPU_MAP, DaytonaProvider +from integrity_tests.base import OffersIntegrityTests -class TestDaytonaCatalog(CatalogFileIntegrityTests): - CATALOG_NAME = "daytona" +class TestDaytonaOffers(OffersIntegrityTests): + @pytest.fixture(scope="class") + def offers(self) -> list[CatalogItem]: + provider = DaytonaProvider.from_env() + offers = provider.get() + if provider.api_key and not offers: + pytest.skip("No Daytona offers are currently available") + return offers def test_no_unexpected_gpus(self, offers: list[CatalogItem]) -> None: - expected_gpus = {name for name, _, _, _ in GPU_MAP.values()} + expected_gpus = {info[0] for info in GPU_MAP.values()} gpus = {o.gpu_name for o in offers if o.gpu_name} assert not gpus - expected_gpus - @pytest.mark.parametrize("gpu_count", [1, 2, 4, 8]) - def test_gpu_count_present(self, gpu_count: int, offers: list[CatalogItem]) -> None: - assert any(o.gpu_count == gpu_count for o in offers) + def test_supported_gpu_counts(self, offers: list[CatalogItem]) -> None: + assert all(0 <= o.gpu_count <= 8 for o in offers) - def test_both_capacity_types_present(self, offers: list[CatalogItem]) -> None: - assert any(o.spot for o in offers) - assert any(not o.spot for o in offers) + def test_shared_region(self, offers: list[CatalogItem]) -> None: + assert all(o.location == "earth" for o in offers if o.gpu_count) + assert all(o.location and o.location != "earth" for o in offers if not o.gpu_count) + + def test_cpu_metadata(self, offers: list[CatalogItem]) -> None: + for offer in offers: + if offer.gpu_count == 0: + assert offer.provider_data == {} + assert not offer.spot + + def test_gpu_type_metadata(self, offers: list[CatalogItem]) -> None: + for offer in offers: + if offer.gpu_count == 0: + continue + gpu_type = offer.provider_data.get("gpu_type", offer.gpu_name) + assert isinstance(gpu_type, str) + assert gpu_type in GPU_MAP + assert GPU_MAP[gpu_type][0] == offer.gpu_name + if gpu_type == offer.gpu_name: + assert offer.provider_data == {} + else: + assert offer.provider_data == {"gpu_type": gpu_type} diff --git a/src/tests/_internal/test_default.py b/src/tests/_internal/test_default.py index cdb9db3..5ab88ff 100644 --- a/src/tests/_internal/test_default.py +++ b/src/tests/_internal/test_default.py @@ -7,6 +7,7 @@ "CRUSOE_ACCESS_KEY", "CRUSOE_SECRET_KEY", "CRUSOE_PROJECT_ID", + "DAYTONA_API_KEY", "DIGITAL_OCEAN_API_KEY", "HOTAISLE_API_KEY", "HOTAISLE_TEAM_HANDLE", @@ -26,9 +27,10 @@ def offline_catalog(monkeypatch): class TestDefaultCatalog: - def test_skips_providers_with_missing_creds(self, offline_catalog) -> None: + def test_skips_providers_with_missing_creds(self, offline_catalog, caplog) -> None: catalog = default_catalog() - assert sorted(p.NAME for p in catalog.providers) == ["vastai", "vultr"] + assert sorted(p.NAME for p in catalog.providers) == ["daytona", "vastai", "vultr"] + assert not any("daytona" in record.getMessage().lower() for record in caplog.records) def test_loads_providers_with_creds(self, offline_catalog, monkeypatch) -> None: monkeypatch.setenv("HOTAISLE_API_KEY", "key") diff --git a/src/tests/providers/test_daytona.py b/src/tests/providers/test_daytona.py index c7a15e5..8a54eab 100644 --- a/src/tests/providers/test_daytona.py +++ b/src/tests/providers/test_daytona.py @@ -5,68 +5,51 @@ import requests import gpuhunt.providers.daytona as daytona_module -from gpuhunt._internal.models import AcceleratorVendor -from gpuhunt.providers.daytona import API_URL, GPU_COUNTS, GPU_MAP, DaytonaProvider +from gpuhunt import QueryFilter +from gpuhunt._internal.models import AcceleratorVendor, CPUArchitecture +from gpuhunt.providers.daytona import API_URL, PRICING_URL, DaytonaProvider PAYLOAD = { - "generatedAt": "2026-09-23T08:43:09Z", - "currency": "USD", "gpus": [ { "type": "B300", "onDemandPricePerHour": 7.1, "spotPricePerHour": 4.08, - "onDemandPricePerSecond": 0.0019722222222222222, - "spotPricePerSecond": 0.0011333333333333334, }, { "type": "B200", "onDemandPricePerHour": 6.25, "spotPricePerHour": 3.59, - "onDemandPricePerSecond": 0.001736111111111111, - "spotPricePerSecond": 0.0009972222222222223, }, { "type": "MI355X", "onDemandPricePerHour": 5.99, "spotPricePerHour": 3.44, - "onDemandPricePerSecond": 0.0016638888888888888, - "spotPricePerSecond": 0.0009555555555555555, }, { "type": "H200", "onDemandPricePerHour": 4.54, "spotPricePerHour": 2.61, - "onDemandPricePerSecond": 0.001261, - "spotPricePerSecond": 0.000725, }, { "type": "H100", "onDemandPricePerHour": 3.95, "spotPricePerHour": 2.27, - "onDemandPricePerSecond": 0.001097, - "spotPricePerSecond": 0.0006305555555555555, }, { "type": "RTX-PRO-6000", "onDemandPricePerHour": 3.03, "spotPricePerHour": 1.74, - "onDemandPricePerSecond": 0.0008416666666666667, - "spotPricePerSecond": 0.00048333333333333334, }, { "type": "RTX-5090", "onDemandPricePerHour": 1.29, "spotPricePerHour": 0.74, - "onDemandPricePerSecond": 0.00035833333333333333, - "spotPricePerSecond": 0.00020555555555555556, }, { "type": "RTX-4090", "onDemandPricePerHour": 0.99, "spotPricePerHour": 0.57, - "onDemandPricePerSecond": 0.000275, - "spotPricePerSecond": 0.00015833333333333332, }, ], "resources": { @@ -81,106 +64,566 @@ "diskGiBPerHour": 0.000062, }, }, - "notes": ["GPU rates are per GPU. Sandbox vCPU, memory, and disk are billed separately."], } +ORGANIZATION_ID = "test-organization" +CONTROL_API_PATHS = { + "identity": "/api-keys/current", + "capacity": f"/organizations/{ORGANIZATION_ID}/gpu-capacity", +} +REGIONS_PAYLOAD = [{"id": "us"}, {"id": "eu"}] +CAPACITY_PAYLOAD = { + "capacity": [ + {"gpuType": "H100", "availableOnDemand": 8, "availableSpot": 8}, + {"gpuType": "RTX-PRO-6000", "availableOnDemand": 2, "availableSpot": 0}, + # Billing rates and physical capacity do not imply create API support. + {"gpuType": "B200", "availableOnDemand": 8, "availableSpot": 8}, + ], +} -class FakeResponse: - def __init__(self, payload, status_code: int = 200): - self.payload = payload - self.status_code = status_code - def raise_for_status(self) -> None: - if self.status_code >= 400: - raise requests.HTTPError(f"status {self.status_code}") +@pytest.fixture +def pricing(requests_mock): + payload = copy.deepcopy(PAYLOAD) + requests_mock.get(PRICING_URL, json=lambda request, context: payload) + return payload + - def json(self): - return self.payload +@pytest.fixture(autouse=True) +def shared_regions(requests_mock): + payload = copy.deepcopy(REGIONS_PAYLOAD) + requests_mock.get(API_URL + "/shared-regions", json=lambda request, context: payload) + return payload @pytest.fixture -def requested_urls(monkeypatch) -> list[tuple[str, float]]: - requested = [] +def control_api(requests_mock): + def register(api_url=API_URL): + payloads = { + "identity": {"organizationId": ORGANIZATION_ID}, + "capacity": copy.deepcopy(CAPACITY_PAYLOAD), + } + for name, path in CONTROL_API_PATHS.items(): + requests_mock.get( + api_url + path, + json=lambda request, context, name=name: payloads[name], + ) + return payloads + + return register + + +def find_offer(offers, gpu_name="H100", gpu_count=1, spot=False): + return next( + offer + for offer in offers + if (offer.gpu_name, offer.gpu_count, offer.spot) == (gpu_name, gpu_count, spot) + ) - def fake_get(url, timeout): - requested.append((url, timeout)) - return FakeResponse(copy.deepcopy(PAYLOAD)) - monkeypatch.setattr(daytona_module.requests, "get", fake_get) - return requested +def no_key_warnings(caplog): + return [record for record in caplog.records if "DAYTONA_API_KEY" in record.message] -def test_offers_cover_every_gpu_count_and_capacity_type(requested_urls): +def test_public_offers_cover_supported_counts_and_preserve_create_tokens(pricing, requests_mock): offers = DaytonaProvider().get() - assert len(offers) == len(PAYLOAD["gpus"]) * len(GPU_COUNTS) * 2 - assert [(url, timeout) for url, timeout in requested_urls] == [(API_URL, 10.0)] - assert offers == sorted(offers, key=lambda o: o.price) - assert {o.gpu_name for o in offers} == {name for name, _, _, _ in GPU_MAP.values()} - assert {o.gpu_count for o in offers} == set(GPU_COUNTS) - assert {o.spot for o in offers} == {False, True} - assert all(o.provider == "daytona" and o.location == "us" for o in offers) + assert len(offers) == 7 * 8 * 2 + 2 + gpu_offers = [offer for offer in offers if offer.gpu_count] + cpu_offers = [offer for offer in offers if not offer.gpu_count] + assert {offer.gpu_name for offer in gpu_offers} == { + "B300", + "H100", + "H200", + "MI355X", + "RTXPRO6000", + "RTX5090", + "RTX4090", + } + assert {offer.gpu_count for offer in offers} == set(range(9)) + assert {offer.spot for offer in offers} == {False, True} + assert offers == sorted(offers, key=lambda offer: offer.price) + assert all(offer.provider == "daytona" for offer in offers) + assert all(offer.location == "earth" for offer in gpu_offers) + assert {offer.location for offer in cpu_offers} == {"us", "eu"} + assert all( + (offer.cpu, offer.memory, offer.disk_size, offer.gpu_count, offer.spot) + == (1, 1, 3, 0, False) + for offer in cpu_offers + ) + assert all( + offer.gpu_name is None + and offer.gpu_memory is None + and offer.gpu_vendor is None + and offer.provider_data == {} + for offer in cpu_offers + ) + expected_metadata = { + "B300": {}, + "H100": {}, + "H200": {}, + "MI355X": {}, + "RTXPRO6000": {"gpu_type": "RTX-PRO-6000"}, + "RTX5090": {"gpu_type": "RTX-5090"}, + "RTX4090": {"gpu_type": "RTX-4090"}, + } + for offer in gpu_offers: + assert offer.gpu_name is not None + assert offer.provider_data == expected_metadata[offer.gpu_name] + assert [request.url for request in requests_mock.request_history] == [ + PRICING_URL, + API_URL + "/shared-regions", + ] + assert all(r.headers.get("Authorization") is None for r in requests_mock.request_history) + + +def test_prices_include_selected_cpu_memory_and_disk_at_each_capacity_rate(pricing): + offers = DaytonaProvider().get() + # 3.95 + 8 * 0.0504 + 100 * 0.0162 + 256 * 0.000108. + h100 = find_offer(offers) + assert h100.price == 6.000848 + assert (h100.cpu, h100.memory, h100.disk_size) == (8, 100, 256) + assert (h100.gpu_memory, h100.gpu_vendor) == (80, AcceleratorVendor.NVIDIA) + assert find_offer(offers, spot=True).price == 3.455872 + + consumer = find_offer(offers, "RTX4090") + assert consumer.price == 2.230848 + assert consumer.memory == 50 + + eight_gpus = find_offer(offers, gpu_count=8) + assert (eight_gpus.cpu, eight_gpus.memory, eight_gpus.disk_size) == (64, 800, 2048) + assert eight_gpus.price == 48.006784 + + amd = find_offer(offers, "MI355X") + assert (amd.gpu_vendor, amd.gpu_memory) == (AcceleratorVendor.AMD, 288) + + +@pytest.mark.parametrize("authenticated", [False, True]) +@pytest.mark.parametrize("balance_resources", [False, True]) +def test_cpu_only_queries_use_public_regions_without_gpu_auth_requests( + pricing, requests_mock, authenticated, balance_resources +): + query = QueryFilter( + provider=["DAYTONA"], + cpu_arch=CPUArchitecture.X86, + max_gpu_count=0, + spot=False, + min_price=0.066, + max_price=0.067, + ) + provider = DaytonaProvider(api_key="test-key" if authenticated else None) + offers = provider.get(query_filter=query, balance_resources=balance_resources) -def test_prices_combine_gpu_and_resource_rates_per_capacity_type(requested_urls): - offers = DaytonaProvider().get() + assert {offer.location for offer in offers} == {"us", "eu"} + assert all( + (offer.cpu, offer.memory, offer.disk_size, offer.price) == (1, 1, 3, 0.066924) + for offer in offers + ) + assert all(offer.gpu_count == 0 and not offer.spot for offer in offers) + assert [request.url for request in requests_mock.request_history] == [ + PRICING_URL, + API_URL + "/shared-regions", + ] + assert all(r.headers.get("Authorization") is None for r in requests_mock.request_history) + assert all(r.timeout == 10 for r in requests_mock.request_history) + + +@pytest.mark.parametrize( + ("query", "expected", "price"), + [ + (QueryFilter(min_cpu=2, min_memory=2, min_disk_size=5), (2, 2, 5), 0.13374), + (QueryFilter(min_memory=2.1), (1, 3, 3), 0.099324), + (QueryFilter(max_disk_size=1), (1, 1, 1), 0.066708), + ( + QueryFilter( + min_cpu=4, + max_cpu=4, + min_memory=3.2, + max_memory=4.9, + min_disk_size=5, + max_disk_size=5, + ), + (4, 4, 5), + 0.26694, + ), + ], +) +def test_cpu_resources_are_sized_and_all_reserved_disk_is_priced(pricing, query, expected, price): + query.max_gpu_count = 0 + offers = DaytonaProvider().get(query_filter=query) + + assert len(offers) == 2 + for offer in offers: + assert (offer.cpu, offer.memory, offer.disk_size) == expected + assert offer.price == price + + +def test_cpu_offers_do_not_apply_account_resource_or_region_restrictions(pricing, requests_mock): + query = QueryFilter(max_gpu_count=0, min_cpu=17, min_memory=193, min_disk_size=513) + offers = DaytonaProvider(api_key="test-key").get(query_filter=query) + + assert {offer.location for offer in offers} == {"us", "eu"} + assert all((offer.cpu, offer.memory, offer.disk_size) == (17, 193, 513) for offer in offers) + assert all(offer.price == 4.038804 for offer in offers) + assert [request.url for request in requests_mock.request_history] == [ + PRICING_URL, + API_URL + "/shared-regions", + ] + + +def test_cpu_regions_and_resource_prices_refresh_each_query( + pricing, shared_regions, requests_mock +): + provider = DaytonaProvider(api_key="test-key") + query = QueryFilter(max_gpu_count=0) + assert {offer.location for offer in provider.get(query_filter=query)} == {"us", "eu"} + shared_regions[:] = [{"id": "us"}, {"id": "ap"}] + pricing["resources"]["onDemand"]["vcpuPerHour"] = 0.0604 + offers = provider.get(query_filter=query) + + assert {offer.location for offer in offers} == {"us", "ap"} + assert all(offer.price == 0.076924 for offer in offers) + assert [request.url for request in requests_mock.request_history] == [ + PRICING_URL, + API_URL + "/shared-regions", + PRICING_URL, + API_URL + "/shared-regions", + ] + + +@pytest.mark.parametrize( + "query", + [ + QueryFilter(gpu_name=["H100"]), + QueryFilter(gpu_vendor=AcceleratorVendor.NVIDIA), + QueryFilter(min_gpu_memory=1), + QueryFilter(min_total_gpu_memory=1), + QueryFilter(min_compute_capability=(1, 0)), + QueryFilter(min_gpu_count=1), + QueryFilter(spot=True), + QueryFilter(cpu_arch=CPUArchitecture.ARM), + QueryFilter(provider=["other"]), + QueryFilter(max_price=0.06), + QueryFilter(min_price=0.07), + ], +) +def test_nonmatching_cpu_queries_skip_region_discovery(pricing, requests_mock, query): + query.max_gpu_count = 0 + assert DaytonaProvider(api_key="test-key").get(query_filter=query) == [] + assert [request.url for request in requests_mock.request_history] == [PRICING_URL] + + +def test_explicit_zero_min_gpu_count_preserves_shared_cpu_filter_semantics(pricing): + query = QueryFilter( + min_gpu_count=0, + max_gpu_count=0, + gpu_name=["H100"], + gpu_vendor=AcceleratorVendor.NVIDIA, + min_gpu_memory=80, + min_total_gpu_memory=80, + min_compute_capability=(9, 0), + ) + offers = DaytonaProvider().get(query_filter=query) + assert {(offer.gpu_count, offer.location) for offer in offers} == {(0, "us"), (0, "eu")} - def find(gpu_name: str, gpu_count: int, spot: bool): - return next( - o - for o in offers - if o.gpu_name == gpu_name and o.gpu_count == gpu_count and o.spot is spot - ) - - # 3.95 GPU + 8 vCPU * 0.0504 + 100 GB * 0.0162 + 256 GB disk * 0.000108 - h100_on_demand = find("H100", 1, spot=False) - assert h100_on_demand.price == 6.000848 - assert (h100_on_demand.cpu, h100_on_demand.memory, h100_on_demand.disk_size) == ( - 8, - 100.0, - 256.0, + +@pytest.mark.parametrize("status_code", [403, 500]) +def test_cpu_region_http_errors_propagate(pricing, requests_mock, status_code): + requests_mock.get(API_URL + "/shared-regions", status_code=status_code) + with pytest.raises(requests.HTTPError): + DaytonaProvider().get(query_filter=QueryFilter(max_gpu_count=0)) + + +@pytest.mark.parametrize("payload", [None, {}, ["us"], [{}], [{"id": 7}], [{"id": ""}]]) +def test_malformed_cpu_regions_are_rejected(pricing, requests_mock, payload): + requests_mock.get(API_URL + "/shared-regions", json=payload) + with pytest.raises(ValueError): + DaytonaProvider().get(query_filter=QueryFilter(max_gpu_count=0)) + + +def test_empty_shared_regions_return_no_cpu_offers(pricing, shared_regions): + shared_regions.clear() + assert DaytonaProvider().get(query_filter=QueryFilter(max_gpu_count=0)) == [] + + +@pytest.mark.parametrize("api_key", [None, "", " "]) +def test_from_env_without_key_warns_only_on_first_query(pricing, monkeypatch, caplog, api_key): + if api_key is None: + monkeypatch.delenv("DAYTONA_API_KEY", raising=False) + else: + monkeypatch.setenv("DAYTONA_API_KEY", api_key) + monkeypatch.delenv("DAYTONA_API_URL", raising=False) + with caplog.at_level(logging.WARNING, logger=daytona_module.__name__): + provider = DaytonaProvider.from_env() + assert not caplog.records + provider.get() + warnings = no_key_warnings(caplog) + assert len(warnings) == 1 + assert warnings[0].levelno == logging.WARNING + assert "availab" in warnings[0].message.lower() + provider.get() + assert len(no_key_warnings(caplog)) == 1 + + +def test_no_key_warning_is_per_provider(pricing, caplog): + with caplog.at_level(logging.WARNING, logger=daytona_module.__name__): + first = DaytonaProvider() + second = DaytonaProvider() + assert not caplog.records + first.get() + first.get() + second.get() + assert len(no_key_warnings(caplog)) == 2 + + +def test_from_env_uses_optional_api_key_and_control_api_override( + pricing, control_api, requests_mock, monkeypatch, caplog, shared_regions +): + api_url = "https://daytona.example/api" + control_api(api_url) + requests_mock.get(api_url + "/shared-regions", json=shared_regions) + monkeypatch.setenv("DAYTONA_API_KEY", "test-key") + monkeypatch.setenv("DAYTONA_API_URL", api_url + "/") + with caplog.at_level(logging.WARNING, logger=daytona_module.__name__): + provider = DaytonaProvider.from_env() + assert not requests_mock.called + offers = provider.get() + assert offers + assert not no_key_warnings(caplog) + private_requests = [ + r + for r in requests_mock.request_history + if r.url not in (PRICING_URL, api_url + "/shared-regions") + ] + assert len(private_requests) == 2 + assert all(r.url.startswith(api_url + "/") for r in private_requests) + assert all(r.headers["Authorization"] == "Bearer test-key" for r in private_requests) + regions_request = next( + r for r in requests_mock.request_history if r.url == api_url + "/shared-regions" ) - assert h100_on_demand.gpu_memory == 80.0 - assert h100_on_demand.gpu_vendor == AcceleratorVendor.NVIDIA + assert regions_request.headers.get("Authorization") is None + assert all(r.timeout == 10 for r in requests_mock.request_history) - # Spot discounts the resources too: 2.27 + 8 * 0.03 + 100 * 0.0093 + 256 * 0.000062 - h100_spot = find("H100", 1, spot=True) - assert h100_spot.price == 3.455872 - # Consumer cards carry 50 GB RAM per GPU. - rtx4090_on_demand = find("RTX4090", 1, spot=False) - assert rtx4090_on_demand.price == 2.230848 - assert rtx4090_on_demand.memory == 50.0 +@pytest.mark.parametrize("authenticated", [False, True]) +def test_each_query_uses_current_prices(pricing, control_api, requests_mock, authenticated): + control_api() + provider = DaytonaProvider(api_key="test-key" if authenticated else None) + assert find_offer(provider.get()).price == 6.000848 + next(gpu for gpu in pricing["gpus"] if gpu["type"] == "H100")["onDemandPricePerHour"] = 4.95 - # Resources scale linearly with the GPU count. - h100_8x = find("H100", 8, spot=False) - assert (h100_8x.cpu, h100_8x.memory, h100_8x.disk_size) == (64, 800.0, 2048.0) - assert h100_8x.price == 48.006784 + assert find_offer(provider.get()).price == 7.000848 + assert sum(request.url == PRICING_URL for request in requests_mock.request_history) == 2 - mi355x = find("MI355X", 1, spot=False) - assert mi355x.gpu_vendor == AcceleratorVendor.AMD - assert mi355x.gpu_memory == 288.0 +@pytest.mark.parametrize( + ("query", "expected"), + [ + (QueryFilter(min_cpu=12, min_memory=125.2, min_disk_size=300), (12, 126, 300)), + (QueryFilter(max_cpu=4, max_memory=40.5, max_disk_size=128), (4, 40, 128)), + ( + QueryFilter( + min_cpu=3, + max_cpu=4, + min_memory=30.1, + max_memory=32, + min_disk_size=50, + max_disk_size=64, + ), + (4, 32, 64), + ), + ], +) +def test_query_resources_are_rounded_and_priced_after_sizing(pricing, query, expected): + query.gpu_name = ["H100"] + query.min_gpu_count = query.max_gpu_count = 1 + query.spot = False + offers = DaytonaProvider().get(query_filter=query) + + assert len(offers) == 1 + offer = offers[0] + assert (offer.cpu, offer.memory, offer.disk_size) == expected + cpu, memory, disk = expected + assert offer.price == round(3.95 + cpu * 0.0504 + memory * 0.0162 + disk * 0.000108, 6) + + +def test_disabling_balancing_uses_per_sandbox_minimums(pricing): + provider = DaytonaProvider() + query = QueryFilter(gpu_name=["H100"], min_gpu_count=3, max_gpu_count=3, spot=False) + offers = provider.get(query_filter=query, balance_resources=False) + assert len(offers) == 1 + offer = offers[0] + assert (offer.cpu, offer.memory, offer.disk_size) == (1, 1, 1) + assert offer.price == 11.916708 + + query.min_cpu, query.min_memory, query.min_disk_size = 5, 13.2, 40 + resized = provider.get(query_filter=query, balance_resources=False)[0] + assert (resized.cpu, resized.memory, resized.disk_size) == (5, 14, 40) + assert resized.price == 12.33312 -def test_unknown_gpu_type_is_skipped_with_warning(monkeypatch, caplog): - payload = copy.deepcopy(PAYLOAD) - payload["gpus"].append( - {"type": "B400", "onDemandPricePerHour": 9.99, "spotPricePerHour": 5.99} + +@pytest.mark.parametrize( + "query", + [ + QueryFilter(min_cpu=5, max_cpu=4), + QueryFilter(min_memory=40.5, max_memory=40.7), + QueryFilter(min_disk_size=129, max_disk_size=128), + QueryFilter(max_cpu=0), + QueryFilter(max_memory=0), + QueryFilter(max_disk_size=0), + QueryFilter(min_gpu_count=9), + QueryFilter(min_gpu_count=1, max_gpu_count=0), + QueryFilter(min_gpu_count=4, max_gpu_count=3), + ], +) +def test_unsatisfiable_resource_and_gpu_ranges_return_no_offers(pricing, query): + assert DaytonaProvider().get(query_filter=query) == [] + + +def test_gpu_and_price_filters_are_applied_to_final_offers(pricing): + query = QueryFilter( + provider=["DAYTONA"], + gpu_name=["h100"], + gpu_vendor=AcceleratorVendor.NVIDIA, + min_gpu_count=2, + max_gpu_count=4, + min_gpu_memory=80, + max_gpu_memory=80, + min_total_gpu_memory=200, + max_total_gpu_memory=300, + min_price=17, + max_price=19, + min_compute_capability=(9, 0), + max_compute_capability=(9, 0), + spot=False, ) - monkeypatch.setattr(daytona_module.requests, "get", lambda url, timeout: FakeResponse(payload)) + offers = DaytonaProvider().get(query_filter=query) + assert len(offers) == 1 + assert (offers[0].gpu_name, offers[0].gpu_count, offers[0].spot) == ("H100", 3, False) + assert offers[0].price == 18.002544 - with caplog.at_level(logging.WARNING): - offers = DaytonaProvider().get() - assert len(offers) == len(PAYLOAD["gpus"]) * len(GPU_COUNTS) * 2 +def test_unknown_and_uncreatable_gpu_types_are_not_advertised(pricing, caplog): + pricing["gpus"].append( + {"type": "B400", "onDemandPricePerHour": 9.99, "spotPricePerHour": 5.99} + ) + with caplog.at_level(logging.WARNING, logger=daytona_module.__name__): + offers = DaytonaProvider().get(query_filter=QueryFilter(min_gpu_count=1)) + assert len(offers) == 112 + assert not {"B200", "B400"} & {offer.gpu_name for offer in offers} assert "B400" in caplog.text -def test_http_error_propagates(monkeypatch): - monkeypatch.setattr( - daytona_module.requests, "get", lambda url, timeout: FakeResponse({}, status_code=500) +def test_authenticated_offers_follow_capacity_by_gpu_and_purchase_option(pricing, control_api): + payloads = control_api() + payloads["capacity"]["capacity"][0].update(availableOnDemand=5, availableSpot=3) + offers = DaytonaProvider(api_key="test-key").get() + + h100 = [offer for offer in offers if offer.gpu_name == "H100"] + assert {offer.gpu_count for offer in h100 if not offer.spot} == {1, 2, 3, 4, 5} + assert {offer.gpu_count for offer in h100 if offer.spot} == {1, 2, 3} + assert not any(offer.gpu_name == "B200" for offer in offers) + assert not any(offer.gpu_name == "RTXPRO6000" and offer.spot for offer in offers) + assert all(offer.provider_data == {} for offer in h100) + assert find_offer(offers, "RTXPRO6000").provider_data == {"gpu_type": "RTX-PRO-6000"} + assert find_offer(offers).price == 6.000848 + assert find_offer(offers, spot=True).price == 3.455872 + + +def test_account_restrictions_are_not_queried_or_applied_to_capacity_offers( + pricing, control_api, requests_mock +): + control_api() + organization_url = f"{API_URL}/organizations/{ORGANIZATION_ID}" + query = QueryFilter( + gpu_name=["H100"], + min_gpu_count=8, + max_gpu_count=8, + min_cpu=128, + min_memory=1536, + min_disk_size=4096, + spot=False, ) + offers = DaytonaProvider(api_key="test-key").get(query_filter=query) + + assert len(offers) == 1 + assert (offers[0].gpu_count, offers[0].spot) == (8, False) + assert (offers[0].cpu, offers[0].memory, offers[0].disk_size) == (128, 1536, 4096) + assert [request.url for request in requests_mock.request_history] == [ + PRICING_URL, + API_URL + "/api-keys/current", + organization_url + "/gpu-capacity", + ] + + +def test_capacity_and_prices_refresh_while_identity_is_cached(pricing, control_api, requests_mock): + payloads = control_api() + provider = DaytonaProvider(api_key="test-key") + query = QueryFilter(min_gpu_count=1) + assert find_offer(provider.get(query_filter=query), gpu_count=8) + payloads["capacity"]["capacity"][0].update(availableOnDemand=3, availableSpot=0) + payloads["capacity"]["capacity"][1].update(availableOnDemand=0) + offers = provider.get(query_filter=query) + + assert {(offer.gpu_name, offer.gpu_count, offer.spot) for offer in offers} == { + ("H100", 1, False), + ("H100", 2, False), + ("H100", 3, False), + } + capacity_url = f"{API_URL}/organizations/{ORGANIZATION_ID}/gpu-capacity" + assert [request.url for request in requests_mock.request_history] == [ + PRICING_URL, + API_URL + "/api-keys/current", + capacity_url, + PRICING_URL, + capacity_url, + ] + + +@pytest.mark.parametrize("authenticated", [False, True]) +@pytest.mark.parametrize( + "query", + [QueryFilter(min_cpu=17), QueryFilter(min_memory=192.1), QueryFilter(min_disk_size=513)], +) +def test_query_cannot_exceed_published_per_gpu_limits(pricing, control_api, query, authenticated): + control_api() + query.min_gpu_count = query.max_gpu_count = 1 + provider = DaytonaProvider(api_key="test-key" if authenticated else None) + assert provider.get(query_filter=query) == [] + + +def test_empty_gpu_capacity_preserves_cpu_estimates_without_unchecked_gpu_fallback( + pricing, control_api +): + payloads = control_api() + payloads["capacity"]["capacity"] = [] + provider = DaytonaProvider(api_key="test-key") + offers = provider.get() + assert {(offer.gpu_count, offer.location) for offer in offers} == {(0, "us"), (0, "eu")} + assert provider.get(query_filter=QueryFilter(min_gpu_count=1)) == [] + + +@pytest.mark.parametrize("path", CONTROL_API_PATHS.values()) +def test_authenticated_http_errors_never_fall_back_to_unchecked_offers( + pricing, control_api, requests_mock, path +): + control_api() + requests_mock.get(API_URL + path, status_code=403) + with pytest.raises(requests.HTTPError): + DaytonaProvider(api_key="test-key").get() + + +@pytest.mark.parametrize("path", CONTROL_API_PATHS.values()) +def test_malformed_authenticated_payload_is_rejected(pricing, control_api, requests_mock, path): + control_api() + requests_mock.get(API_URL + path, json={}) + with pytest.raises(ValueError): + DaytonaProvider(api_key="test-key").get() + +def test_rate_card_http_error_propagates(requests_mock): + requests_mock.get(PRICING_URL, status_code=500) with pytest.raises(requests.HTTPError): DaytonaProvider().get() @@ -194,8 +637,7 @@ def test_http_error_propagates(monkeypatch): {"gpus": [], "resources": {"onDemand": {}}}, ], ) -def test_malformed_payload_is_rejected(monkeypatch, payload): - monkeypatch.setattr(daytona_module.requests, "get", lambda url, timeout: FakeResponse(payload)) - +def test_malformed_rate_card_is_rejected(requests_mock, payload): + requests_mock.get(PRICING_URL, json=payload) with pytest.raises(ValueError): DaytonaProvider().get()