diff --git a/.github/workflows/catalogs.yml b/.github/workflows/catalogs.yml index 3d5ba5b..f055f16 100644 --- a/.github/workflows/catalogs.yml +++ b/.github/workflows/catalogs.yml @@ -419,6 +419,20 @@ jobs: JL_API_KEY: ${{ secrets.JL_API_KEY }} run: uv run pytest src/integrity_tests/test_jarvislabs.py + test-lium: + name: Test Lium integrity + 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 + - name: Run integrity tests + run: uv run pytest src/integrity_tests/test_lium.py + test-seeweb: name: Test Seeweb integrity runs-on: ubuntu-latest @@ -530,6 +544,7 @@ jobs: - test-digitalocean - test-hotaisle - test-jarvislabs + - test-lium - test-seeweb - test-vastai - test-vultr diff --git a/README.md b/README.md index f331208..5240ff9 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Its public plan locations can omit available VDM plans. * Hot Aisle * JarvisLabs * LambdaLabs +* Lium * Nebius * OCI * Runpod diff --git a/src/gpuhunt/__main__.py b/src/gpuhunt/__main__.py index b95f367..6953c9f 100644 --- a/src/gpuhunt/__main__.py +++ b/src/gpuhunt/__main__.py @@ -23,6 +23,7 @@ def main(): "hotaisle", "jarvislabs", "lambdalabs", + "lium", "nebius", "oci", "runpod", @@ -83,6 +84,10 @@ def main(): from gpuhunt.providers.lambdalabs import LambdaLabsProvider provider = LambdaLabsProvider(token=os.environ["LAMBDALABS_TOKEN"]) + elif args.provider == "lium": + from gpuhunt.providers.lium import LiumProvider + + provider = LiumProvider.from_env() elif args.provider == "nebius": from nebius.base.service_account.pk_file import Reader as PKReader diff --git a/src/gpuhunt/_internal/catalog.py b/src/gpuhunt/_internal/catalog.py index b0a0c5b..14a47b3 100644 --- a/src/gpuhunt/_internal/catalog.py +++ b/src/gpuhunt/_internal/catalog.py @@ -40,6 +40,7 @@ "digitalocean", "hotaisle", "jarvislabs", + "lium", "seeweb", "vastai", "vultr", diff --git a/src/gpuhunt/_internal/default.py b/src/gpuhunt/_internal/default.py index 6473cc4..f9fca0d 100644 --- a/src/gpuhunt/_internal/default.py +++ b/src/gpuhunt/_internal/default.py @@ -19,6 +19,7 @@ ("gpuhunt.providers.digitalocean", "DigitalOceanProvider"), ("gpuhunt.providers.hotaisle", "HotAisleProvider"), ("gpuhunt.providers.jarvislabs", "JarvisLabsProvider"), + ("gpuhunt.providers.lium", "LiumProvider"), ("gpuhunt.providers.seeweb", "SeewebProvider"), ("gpuhunt.providers.vastai", "VastAIProvider"), ("gpuhunt.providers.vultr", "VultrProvider"), diff --git a/src/gpuhunt/providers/lium.py b/src/gpuhunt/providers/lium.py new file mode 100644 index 0000000..e106d03 --- /dev/null +++ b/src/gpuhunt/providers/lium.py @@ -0,0 +1,270 @@ +import logging +import math +import re +from typing import cast + +import requests +from typing_extensions import NotRequired, TypedDict + +from gpuhunt._internal.constraints import correct_gpu_memory_gib, find_accelerators +from gpuhunt._internal.models import AcceleratorVendor, CatalogItem, JSONObject, QueryFilter +from gpuhunt.providers.base import OnlineProvider + +logger = logging.getLogger(__name__) + +# Public, unauthenticated feed of every node currently rentable on Lium. Documented at +# https://docs.lium.io/developers/public-nodes-feed with a compatibility promise: fields are only +# ever added under /public/v1, breaking changes ship as /public/v2. +NODES_URL = "https://lium.io/api/public/v1/nodes" +TIMEOUT = 30 + + +class LiumProvider(OnlineProvider): + """Online provider for Lium (https://lium.io) GPU pods. + + Lium publishes its whole rentable inventory as one public JSON feed with per-GPU hourly + prices, so no credentials are needed. Any GPU count between ``min_rentable_gpu_count`` and + ``available_gpu_count`` is a valid order and gets a proportional share of the host's CPUs, + free disk and RAM (after the RAM the host keeps for itself). Memory and disk are lower bounds + in whole GiB/GB, so a filter never matches an offer that cannot hold the request. Most nodes publish + ``min_rentable_gpu_count == gpu_count``, so they get a single whole-node offer. One offer is + produced per rentable GPU + count so that requests for 1, 2, ... GPUs all match, as the jarvislabs provider does. + """ + + NAME = "lium" + + @classmethod + def from_env(cls) -> "LiumProvider": + return cls() + + def get( + self, + query_filter: QueryFilter | None = None, + balance_resources: bool = True, + apply_filter: bool = False, + ) -> list[CatalogItem]: + response = requests.get(NODES_URL, timeout=TIMEOUT) + response.raise_for_status() + data = response.json() + if not isinstance(data, dict) or not isinstance(data.get("nodes"), list): + raise ValueError("Unexpected response from Lium public nodes feed") + + offers: list[CatalogItem] = [] + for node in data["nodes"]: + if not isinstance(node, dict): + logger.warning("Skipping malformed Lium node: %r", node) + continue + offers.extend(_make_offers(node)) + return sorted(offers, key=lambda i: i.price) + + +class LiumCatalogItemProviderData(TypedDict): + # Deep link to the rent page of the node, as published in the feed. + rent_url: NotRequired[str] + + +def _make_offers(node: dict) -> list[CatalogItem]: + node_id = node.get("id") + if not node_id: + logger.warning("Skipping Lium node without id: %r", node) + return [] + node_id = str(node_id) + + # Only `id` and `rent_url` are guaranteed non-null by the feed contract. + try: + gpu_model = str(_required(node, "gpu_model")) + gpu_count = int(_required(node, "gpu_count")) + available_gpu_count = int(_required(node, "available_gpu_count")) + price_per_gpu_hour = float(_required(node, "price_per_gpu_hour")) + gpu_memory_gb = float(_required(node, "gpu_memory_gb")) + cpu_count = int(_required(node, "cpu_count")) + ram_gb = float(_required(node, "ram_gb")) + except (TypeError, ValueError) as e: + logger.warning("Skipping Lium node %s: %s", node_id, e) + return [] + disk_free_gb = node.get("disk_free_gb") + if ( + isinstance(disk_free_gb, bool) + or not isinstance(disk_free_gb, int | float) + or disk_free_gb <= 0 + ): + # with disk_size=None an offer would pass every disk filter, so no free disk means no offer + logger.warning("Skipping Lium node %s: no free disk reported", node_id) + return [] + if gpu_count <= 0 or available_gpu_count <= 0 or price_per_gpu_hour <= 0: + logger.warning("Skipping Lium node %s: non-positive GPU count or price", node_id) + return [] + available_gpu_count = min(available_gpu_count, gpu_count) + # The feed publishes the smallest order checkout accepts, and sets it to `gpu_count` wherever + # the node cannot be split. If it is ever missing, only the whole node is a safe offer. + min_gpu_count = max(1, int(node.get("min_rentable_gpu_count") or gpu_count)) + if min_gpu_count > available_gpu_count: + logger.debug( + "Skipping Lium node %s: %d GPUs free, %d is the smallest order", + node_id, + available_gpu_count, + min_gpu_count, + ) + return [] + + location = get_location(node.get("country_code"), node.get("region")) + if not location: + logger.warning("Skipping Lium node %s: no location", node_id) + return [] + gpu_name = get_dstack_gpu_name(gpu_model) + if not find_accelerators(names=[gpu_name], vendors=[AcceleratorVendor.NVIDIA]): + # Like the seeweb and jarvislabs providers, keep unmapped models out of the catalog + # rather than publishing a name nothing can match; the warning makes them easy to spot. + logger.warning("Skipping Lium node %s: unknown gpu_model %r", node_id, gpu_model) + return [] + gpu_memory = correct_gpu_memory_gib(gpu_name, gpu_memory_gb * 1024) + provider_data = LiumCatalogItemProviderData() + rent_url = node.get("rent_url") + if rent_url: + provider_data["rent_url"] = str(rent_url) + + # The feed rounds RAM and free disk to the nearest GB, so start from the lowest true value. + host_ram_gb = ram_gb - 0.5 + pod_ram_gb = host_ram_gb - get_host_ram_reserve_gb(host_ram_gb) + disk_free_gb = max(disk_free_gb - 0.5, 0) + offers: list[CatalogItem] = [] + for count in range(min_gpu_count, available_gpu_count + 1): + # A rental gets the host's CPUs, RAM (after the host reserve) and free disk in + # proportion to its GPU share. + share = count / gpu_count + cpu = int(cpu_count * share) + # Lium sizes the pod's memory limit in whole GiB, rounded down. + memory = max(1, math.floor(pod_ram_gb * share)) + if cpu < 1: + logger.warning( + "Skipping Lium node %s with %d GPU(s): less than one CPU", + node_id, + count, + ) + continue + disk_size = get_storage_quota_gb(disk_free_gb * share) + if disk_size < 1: + logger.debug( + "Skipping Lium node %s with %d GPU(s): its storage quota rounds to zero", + node_id, + count, + ) + continue + offers.append( + CatalogItem( + provider=LiumProvider.NAME, + instance_name=node_id, + location=location, + price=round(price_per_gpu_hour * count, 5), + cpu=cpu, + memory=float(memory), + gpu_vendor=AcceleratorVendor.NVIDIA, + gpu_count=count, + gpu_name=gpu_name, + gpu_memory=float(gpu_memory), + spot=node.get("tier") == "spot", + disk_size=float(disk_size), + provider_data=cast(JSONObject, dict(provider_data)), + ) + ) + return offers + + +def get_storage_quota_gb(disk_gb: float) -> int: + """ + Disk in GB available to a job on a pod with this share of the host's free disk. + + A pod's share is split into a volume (2/3) and the container storage quota (1/3), each rounded + down, as documented at https://docs.lium.io/pod-users/create-pod. Jobs run on the container + filesystem, so only the storage quota counts. + + >>> get_storage_quota_gb(1466.5 / 8) + 61 + >>> get_storage_quota_gb(2) + 0 + """ + return int(disk_gb / 3) + + +def get_host_ram_reserve_gb(ram_gb: float) -> float: + """ + RAM in GiB that the host keeps for its own services before splitting the rest between GPUs. + + Documented at https://docs.lium.io/pod-users/create-pod as ``max(4, 1 % of host RAM)``. + + >>> get_host_ram_reserve_gb(16) + 4 + >>> round(get_host_ram_reserve_gb(2048), 2) + 20.48 + """ + return max(4, ram_gb * 0.01) + + +def _required(node: dict, name: str) -> str | int | float: + value = node.get(name) + if value is None or isinstance(value, bool) or not isinstance(value, str | int | float): + raise ValueError(f"missing {name}") + return value + + +# Tokens that describe a form factor, memory size or marketing line rather than the GPU itself. +_GPU_NAME_NOISE = re.compile( + r"\b(nvidia|geforce|tesla|quadro|tensor core gpu|generation|blackwell|server edition|" + r"workstation edition|pcie|sxm\d?|hbm\d\w*|ac|pc|\d+\s*gb)\b", + flags=re.IGNORECASE, +) + + +def get_dstack_gpu_name(gpu_model: str) -> str: + """ + Convert a Lium ``gpu_model`` to the name used in gpuhunt's `KNOWN_NVIDIA_GPUS`. + + The result is only published if it is a known GPU, see `_make_offers`. + + >>> get_dstack_gpu_name("RTX 5090") + 'RTX5090' + >>> get_dstack_gpu_name("H100 80GB HBM3") + 'H100' + >>> get_dstack_gpu_name("A100-SXM4-80GB") + 'A100' + >>> get_dstack_gpu_name("B300 SXM6 AC") + 'B300' + >>> get_dstack_gpu_name("B300 SXM6 PC") + 'B300' + >>> get_dstack_gpu_name("RTX PRO 6000 Blackwell Server Edition") + 'RTXPRO6000' + >>> get_dstack_gpu_name("RTX 6000 Ada Generation") + 'RTX6000Ada' + >>> get_dstack_gpu_name("RTX A6000") + 'A6000' + >>> get_dstack_gpu_name("H200 NVL") + 'H200NVL' + >>> get_dstack_gpu_name("Tesla V100 Tensor Core GPU") + 'V100' + """ + name = gpu_model.replace("-", " ") + name = _GPU_NAME_NOISE.sub(" ", name) + name = re.sub(r"^RTX A(\d)", r"A\1", name.strip()) + return re.sub(r"\s+", "", name) + + +def get_location(country_code: object, region: object) -> str: + """ + Build a location such as ``us-ca`` from the feed's ISO country code and subdivision code. + + >>> get_location("US", "CA") + 'us-ca' + >>> get_location("JP", "13") + 'jp-13' + >>> get_location("DE", None) + 'de' + >>> get_location(None, "CA") + '' + """ + if not country_code: + return "" + location = str(country_code).strip().lower() + if region: + location = f"{location}-{str(region).strip().lower()}" + return location.replace(" ", "") diff --git a/src/integrity_tests/test_lium.py b/src/integrity_tests/test_lium.py new file mode 100644 index 0000000..595e9ec --- /dev/null +++ b/src/integrity_tests/test_lium.py @@ -0,0 +1,60 @@ +import warnings + +import pytest +import requests + +from gpuhunt import AcceleratorVendor, CatalogItem +from gpuhunt._internal.constraints import find_accelerators +from gpuhunt.providers.lium import NODES_URL, TIMEOUT, LiumProvider, get_dstack_gpu_name +from integrity_tests.base import OffersIntegrityTests + + +class TestLiumOffers(OffersIntegrityTests): + @pytest.fixture(scope="class") + def offers(self) -> list[CatalogItem]: + return LiumProvider.from_env().get() + + # Lium rents GPU pods only + def test_all_offers_have_gpus(self, offers: list[CatalogItem]) -> None: + assert all(o.gpu_count > 0 for o in offers) + + def test_gpu_vendor_nvidia(self, offers: list[CatalogItem]) -> None: + vendors = {o.gpu_vendor for o in offers} + assert vendors == {AcceleratorVendor.NVIDIA} + + # Unmapped models are skipped, so every published name must be a known GPU + def test_gpu_names_known(self, offers: list[CatalogItem]) -> None: + for offer in offers: + assert offer.gpu_name and find_accelerators(names=[offer.gpu_name]), str(offer) + + def test_price_is_per_gpu_times_count(self, offers: list[CatalogItem]) -> None: + # Offers for the same node differ only in GPU count, so price must scale linearly. + by_node: dict[str, list[CatalogItem]] = {} + for o in offers: + by_node.setdefault(o.instance_name, []).append(o) + for node_offers in by_node.values(): + per_gpu = node_offers[0].price / node_offers[0].gpu_count + for o in node_offers: + assert o.price == pytest.approx(per_gpu * o.gpu_count, abs=1e-4), node_offers + + def test_location_present(self, offers: list[CatalogItem]) -> None: + assert all(o.location and o.location == o.location.lower() for o in offers) + + +def test_real_world_lium_gpu_models(): + """Warn about live ``gpu_model`` values that would be skipped as unknown GPUs.""" + response = requests.get(NODES_URL, timeout=TIMEOUT) + response.raise_for_status() + unknown = set() + for node in response.json()["nodes"]: + gpu_model = node.get("gpu_model") + if not gpu_model: + continue + gpu_name = get_dstack_gpu_name(gpu_model) + if not find_accelerators(names=[gpu_name], vendors=[AcceleratorVendor.NVIDIA]): + unknown.add(gpu_model) + if unknown: + warnings.warn( + f"Found {len(unknown)} Lium GPU models without a known GPU name:\n" + + "".join(f"- {name}\n" for name in sorted(unknown)) + ) diff --git a/src/tests/_internal/test_default.py b/src/tests/_internal/test_default.py index 5ab88ff..c062a48 100644 --- a/src/tests/_internal/test_default.py +++ b/src/tests/_internal/test_default.py @@ -29,7 +29,7 @@ def offline_catalog(monkeypatch): class TestDefaultCatalog: def test_skips_providers_with_missing_creds(self, offline_catalog, caplog) -> None: catalog = default_catalog() - assert sorted(p.NAME for p in catalog.providers) == ["daytona", "vastai", "vultr"] + assert sorted(p.NAME for p in catalog.providers) == ["daytona", "lium", "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: diff --git a/src/tests/providers/test_lium.py b/src/tests/providers/test_lium.py new file mode 100644 index 0000000..631839b --- /dev/null +++ b/src/tests/providers/test_lium.py @@ -0,0 +1,399 @@ +import copy + +import pytest +import requests + +from gpuhunt._internal.constraints import find_accelerators, matches +from gpuhunt._internal.models import AcceleratorVendor, QueryFilter +from gpuhunt.providers.lium import NODES_URL, LiumProvider, get_dstack_gpu_name, get_location + +# Recorded from https://lium.io/api/public/v1/nodes on 2026-09-10 (trimmed to four nodes). +# `disk_free_gb` was added on 2026-10-15: the H200's is the reading from the PR review, the +# others are illustrative. +RECORDED_FEED = { + "generated_at": "2026-09-10T07:37:02.247559Z", + "count": 4, + "nodes": [ + { + "id": "ebdfdbe2-875a-4caa-8eea-1ab5c2a763e1", + "gpu_model": "RTX 5090", + "gpu_count": 8, + "available_gpu_count": 8, + "price_per_gpu_hour": 0.5, + "min_rentable_gpu_count": 8, + "gpu_memory_gb": 32, + "cpu_count": 384, + "ram_gb": 251, + "disk_gb": 935, + "disk_free_gb": 800, + "tier": "secure", + "country": "Japan", + "country_code": "JP", + "region": "13", + "city": "Chiyoda City", + "rent_url": "https://lium.io/browse-pods/ebdfdbe2-875a-4caa-8eea-1ab5c2a763e1", + "price_per_node_hour": 4.0, + }, + { + "id": "f092246d-5704-4c9e-b57f-7c0710a06deb", + "gpu_model": "H200", + "gpu_count": 8, + "available_gpu_count": 5, + "price_per_gpu_hour": 3.99, + "min_rentable_gpu_count": 1, + "gpu_memory_gb": 140, + "cpu_count": 192, + "ram_gb": 2015, + "disk_gb": 3575, + "disk_free_gb": 1467, + "tier": "secure", + "country": "United States", + "country_code": "US", + "region": "MS", + "city": "Jackson", + "rent_url": "https://lium.io/browse-pods/f092246d-5704-4c9e-b57f-7c0710a06deb", + "price_per_node_hour": 31.92, + }, + { + "id": "45af0308-a13c-497f-93ea-ab06144eee29", + "gpu_model": "RTX 5090", + "gpu_count": 1, + "available_gpu_count": 1, + "price_per_gpu_hour": 0.5, + "min_rentable_gpu_count": 1, + "gpu_memory_gb": 32, + "cpu_count": 24, + "ram_gb": 62, + "disk_gb": 1832, + "disk_free_gb": 1500, + "tier": "spot", + "country": "United States", + "country_code": "US", + "region": "MN", + "city": "Minneapolis", + "rent_url": "https://lium.io/browse-pods/45af0308-a13c-497f-93ea-ab06144eee29", + "price_per_node_hour": 0.5, + }, + { + "id": "6780e503-6861-4dca-bdec-ca9dcf37e8d0", + "gpu_model": "A100-SXM4-80GB", + "gpu_count": 8, + "available_gpu_count": 8, + "price_per_gpu_hour": 1.23, + "min_rentable_gpu_count": 8, + "gpu_memory_gb": 80, + "cpu_count": 240, + "ram_gb": 1772, + "disk_gb": 19383, + "disk_free_gb": 15000, + "tier": "secure", + "country": "United States", + "country_code": "US", + "region": "VA", + "city": "Ashburn", + "rent_url": "https://lium.io/browse-pods/6780e503-6861-4dca-bdec-ca9dcf37e8d0", + "price_per_node_hour": 9.84, + }, + ], +} + + +@pytest.fixture +def feed() -> dict: + return copy.deepcopy(RECORDED_FEED) + + +def make_node(**overrides) -> dict: + node = copy.deepcopy(RECORDED_FEED["nodes"][2]) + node.update(overrides) + return node + + +class TestGet: + def test_one_offer_per_rentable_gpu_count(self, requests_mock, feed): + requests_mock.get(NODES_URL, json=feed) + + offers = LiumProvider().get() + + # 8x RTX 5090 (min 8) -> 1, 8x H200 with 5 free (min 1) -> 5, 1x RTX 5090 -> 1, + # 8x A100 (min 8) -> 1 + assert len(offers) == 8 + h200 = [o for o in offers if o.instance_name == "f092246d-5704-4c9e-b57f-7c0710a06deb"] + assert [o.gpu_count for o in h200] == [1, 2, 3, 4, 5] + assert all(o.gpu_name == "H200" for o in h200) + + def test_offers_sorted_by_price(self, requests_mock, feed): + requests_mock.get(NODES_URL, json=feed) + + offers = LiumProvider().get() + + prices = [o.price for o in offers] + assert prices == sorted(prices) + + def test_whole_node_offer(self, requests_mock, feed): + requests_mock.get(NODES_URL, json=feed) + + offers = LiumProvider().get() + + offer = next( + o for o in offers if o.instance_name == "ebdfdbe2-875a-4caa-8eea-1ab5c2a763e1" + ) + assert offer.provider == "lium" + assert offer.location == "jp-13" + assert offer.price == 4.0 # 8 GPUs x $0.5 per GPU-hour + assert offer.cpu == 384 + assert offer.memory == 246 # at least 250.5 GiB, minus the 4 GiB host reserve + # The storage quota of the free disk: int((800 - 0.5) / 3), not the 935 GB total + assert offer.disk_size == 266 + assert offer.gpu_vendor == AcceleratorVendor.NVIDIA + assert offer.gpu_count == 8 + assert offer.gpu_name == "RTX5090" + assert offer.gpu_memory == 32 + assert offer.spot is False + assert offer.flags == [] + assert offer.provider_data == { + "rent_url": "https://lium.io/browse-pods/ebdfdbe2-875a-4caa-8eea-1ab5c2a763e1", + } + + def test_partial_rental_gets_proportional_resources(self, requests_mock, feed): + requests_mock.get(NODES_URL, json=feed) + + offers = LiumProvider().get() + + h200 = { + o.gpu_count: o + for o in offers + if o.instance_name == "f092246d-5704-4c9e-b57f-7c0710a06deb" + } + assert h200[1].price == 3.99 + assert h200[1].cpu == 24 # 192 / 8 + # At least 2014.5 GiB; the host keeps 1% of it and the rest is split and rounded down. + assert h200[1].memory == 249 # (2014.5 - 20.145) / 8 = 249.29, measured memory.max 249 + # The storage quota of the free disk: int((1467 - 0.5) / 8 / 3) = 61. + assert h200[1].disk_size == 61 + assert h200[5].price == 19.95 + assert h200[5].cpu == 120 + assert h200[5].memory == 1246 + # The feed reports 140 GB for a 141 GB card; the known size wins. + assert all(o.gpu_memory == 141 for o in h200.values()) + + def test_spot_tier(self, requests_mock, feed): + requests_mock.get(NODES_URL, json=feed) + + offers = LiumProvider().get() + + spot = [o for o in offers if o.spot] + assert [o.instance_name for o in spot] == ["45af0308-a13c-497f-93ea-ab06144eee29"] + assert spot[0].location == "us-mn" + + def test_gpu_name_normalized(self, requests_mock, feed): + requests_mock.get(NODES_URL, json=feed) + + offers = LiumProvider().get() + + a100 = next(o for o in offers if o.instance_name == "6780e503-6861-4dca-bdec-ca9dcf37e8d0") + assert a100.gpu_name == "A100" + assert a100.gpu_memory == 80 + + def test_skips_node_with_missing_fields(self, requests_mock, caplog): + requests_mock.get( + NODES_URL, + json={"nodes": [make_node(price_per_gpu_hour=None), make_node(gpu_model=None)]}, + ) + + assert LiumProvider().get() == [] + assert "missing price_per_gpu_hour" in caplog.text + assert "missing gpu_model" in caplog.text + + def test_skips_node_without_location(self, requests_mock, caplog): + requests_mock.get(NODES_URL, json={"nodes": [make_node(country_code=None)]}) + + assert LiumProvider().get() == [] + assert "no location" in caplog.text + + def test_location_without_region(self, requests_mock): + requests_mock.get(NODES_URL, json={"nodes": [make_node(region=None)]}) + + offers = LiumProvider().get() + + assert [o.location for o in offers] == ["us"] + + def test_skips_node_when_fewer_gpus_free_than_min_order(self, requests_mock): + requests_mock.get( + NODES_URL, + json={ + "nodes": [make_node(gpu_count=8, available_gpu_count=3, min_rentable_gpu_count=4)] + }, + ) + + assert LiumProvider().get() == [] + + def test_missing_min_rentable_offers_whole_node_only(self, requests_mock): + requests_mock.get( + NODES_URL, + json={ + "nodes": [ + make_node(gpu_count=2, available_gpu_count=2, min_rentable_gpu_count=None) + ] + }, + ) + + offers = LiumProvider().get() + + assert [o.gpu_count for o in offers] == [2] + + def test_missing_min_rentable_on_partly_rented_node_is_skipped(self, requests_mock): + requests_mock.get( + NODES_URL, + json={ + "nodes": [ + make_node(gpu_count=8, available_gpu_count=3, min_rentable_gpu_count=None) + ] + }, + ) + + assert LiumProvider().get() == [] + + def test_small_host_keeps_minimum_reserve(self, requests_mock): + requests_mock.get( + NODES_URL, + json={ + "nodes": [ + make_node( + gpu_count=2, available_gpu_count=2, min_rentable_gpu_count=1, ram_gb=16 + ) + ] + }, + ) + + offers = LiumProvider().get() + + assert [o.memory for o in offers] == [5, 11] # (15.5 - 4) / 2 per GPU, rounded down + + @pytest.mark.parametrize("disk_free_gb", [None, 0, -5, "100", True], ids=repr) + def test_skips_node_without_free_disk(self, requests_mock, disk_free_gb): + # disk_size=None would let the offer through every disk filter + requests_mock.get(NODES_URL, json={"nodes": [make_node(disk_free_gb=disk_free_gb)]}) + + assert LiumProvider().get() == [] + + def test_skips_node_missing_free_disk(self, requests_mock): + node = make_node() + del node["disk_free_gb"] + requests_mock.get(NODES_URL, json={"nodes": [node]}) + + assert LiumProvider().get() == [] + + def test_skips_offers_whose_disk_quotas_round_to_zero(self, requests_mock): + node = make_node( + gpu_count=2, available_gpu_count=2, min_rentable_gpu_count=1, disk_free_gb=6 + ) + requests_mock.get(NODES_URL, json={"nodes": [node]}) + + offers = LiumProvider().get() + + # 1 GPU: int(5.5 / 2 / 3) = 0, skipped; 2 GPUs: int(5.5 / 3) = 1 + assert [(o.gpu_count, o.disk_size) for o in offers] == [(2, 1)] + + def test_disk_filter_uses_the_pod_quota(self, requests_mock, feed): + requests_mock.get(NODES_URL, json=feed) + + offers = LiumProvider().get() + h200 = [o for o in offers if o.instance_name == "f092246d-5704-4c9e-b57f-7c0710a06deb"] + + # The one-GPU H200 share is 446.88 GB of total disk but only 61 GB of storage quota. + assert not matches(h200[0], QueryFilter(min_disk_size=62)) + assert not matches(h200[0], QueryFilter(min_disk_size=150)) + assert matches(h200[0], QueryFilter(min_disk_size=61)) + assert matches(h200[1], QueryFilter(min_disk_size=122)) # 2 GPUs: int(366.6 / 3) + + def test_memory_filter_boundary(self, requests_mock, feed): + requests_mock.get(NODES_URL, json=feed) + + offers = LiumProvider().get() + h200 = [o for o in offers if o.instance_name == "f092246d-5704-4c9e-b57f-7c0710a06deb"] + + # The one-GPU H200 pod gets memory.max = 249 GiB, so asking for more must not match it. + assert matches(h200[0], QueryFilter(min_memory=249)) + assert not matches(h200[0], QueryFilter(min_memory=249.1)) + + def test_skips_unknown_gpu_model(self, requests_mock, caplog): + requests_mock.get( + NODES_URL, json={"nodes": [make_node(gpu_model="RTX 4090 D"), make_node()]} + ) + + offers = LiumProvider().get() + + assert [o.gpu_name for o in offers] == ["RTX5090"] + assert "unknown gpu_model 'RTX 4090 D'" in caplog.text + + def test_skips_malformed_nodes(self, requests_mock, caplog): + requests_mock.get(NODES_URL, json={"nodes": ["not a node", make_node()]}) + + offers = LiumProvider().get() + + assert len(offers) == 1 + assert "malformed" in caplog.text + + def test_unexpected_response_is_rejected(self, requests_mock): + requests_mock.get(NODES_URL, json={"nodes": {}}) + + with pytest.raises(ValueError, match="Unexpected response"): + LiumProvider().get() + + def test_http_error_is_propagated(self, requests_mock): + requests_mock.get(NODES_URL, status_code=503) + + with pytest.raises(requests.HTTPError): + LiumProvider().get() + + +def test_from_env_needs_no_credentials(): + assert isinstance(LiumProvider.from_env(), LiumProvider) + + +@pytest.mark.parametrize( + ("lium_name", "expected"), + [ + ("RTX 5090", "RTX5090"), + ("RTX 4090", "RTX4090"), + ("RTX 3090", "RTX3090"), + ("RTX 4070 Ti SUPER", "RTX4070TiSUPER"), + ("RTX PRO 6000 Blackwell Server Edition", "RTXPRO6000"), + ("RTX PRO 6000 Blackwell Workstation Edition", "RTXPRO6000"), + ("RTX PRO 4500 Blackwell", "RTXPRO4500"), + ("RTX 6000 Ada Generation", "RTX6000Ada"), + ("RTX 4000 Ada Generation", "RTX4000Ada"), + ("RTX A6000", "A6000"), + ("RTX A4000", "A4000"), + ("Quadro RTX 6000", "RTX6000"), + ("H100 80GB HBM3", "H100"), + ("H100 PCIe", "H100"), + ("H100 NVL", "H100NVL"), + ("H200", "H200"), + ("H200 NVL", "H200NVL"), + ("B200", "B200"), + ("B300 SXM6 AC", "B300"), + ("B300 SXM6 PC", "B300"), + ("A100-SXM4-80GB", "A100"), + ("A100 80GB PCIe", "A100"), + ("A10 Tensor Core GPU", "A10"), + ("T4 Tensor Core GPU", "T4"), + ("Tesla V100 Tensor Core GPU", "V100"), + ("Tesla P100", "P100"), + ("L40S", "L40S"), + ("L4", "L4"), + ], +) +def test_get_dstack_gpu_name(lium_name, expected): + assert get_dstack_gpu_name(lium_name) == expected + # Every name in the table must be one an offer can be published with + assert find_accelerators(names=[expected], vendors=[AcceleratorVendor.NVIDIA]) + + +def test_get_location(): + assert get_location("US", "CA") == "us-ca" + assert get_location("JP", "13") == "jp-13" + assert get_location("DE", None) == "de" + assert get_location("DE", "") == "de" + assert get_location(None, "CA") == ""