From 168f678a944647ed73850333a5f1f1fbd155fc35 Mon Sep 17 00:00:00 2001 From: surcyf123 Date: Thu, 10 Sep 2026 04:46:18 -0300 Subject: [PATCH 1/4] Add Lium catalog provider Lium (https://lium.io) rents NVIDIA GPU pods and publishes its whole rentable inventory as one unauthenticated JSON feed with per-GPU hourly prices, so the provider needs no credentials and is queried live as an online provider. - Feed: https://lium.io/api/public/v1/nodes (contract documented at https://docs.lium.io/developers/public-nodes-feed; fields under /public/v1 are only ever added). - A node can be rented in part: one offer is produced per GPU count between `min_rentable_gpu_count` and `available_gpu_count`, with CPUs, RAM and disk in proportion to the GPU share, as Lium allocates them (the same expansion the jarvislabs provider does). - `price` is the per-GPU-hour price times the offer's GPU count. - `gpu_name` is normalized to the KNOWN_NVIDIA_GPUS names (`RTX 5090` -> `RTX5090`, `H100 80GB HBM3` -> `H100`, `RTX PRO 6000 Blackwell Server Edition` -> `RTXPRO6000`); every model in the live feed resolves to a known GPU, and a model that does not is skipped with a warning, like the seeweb and jarvislabs providers do. `gpu_memory` goes through `correct_gpu_memory_gib`. - `location` is `-` (`us-ca`, `jp-13`). - `spot` is True for nodes in Lium's spot tier (provider may reclaim). - `instance_name` is the node id, which is what the rent API takes. - Unit tests run against a recorded feed sample; the integrity test queries the live feed like the other online providers. --- .github/workflows/catalogs.yml | 15 ++ README.md | 1 + src/gpuhunt/__main__.py | 5 + src/gpuhunt/_internal/catalog.py | 1 + src/gpuhunt/_internal/default.py | 1 + src/gpuhunt/providers/lium.py | 213 ++++++++++++++++++ src/integrity_tests/test_lium.py | 60 ++++++ src/tests/_internal/test_default.py | 2 +- src/tests/providers/test_lium.py | 321 ++++++++++++++++++++++++++++ 9 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 src/gpuhunt/providers/lium.py create mode 100644 src/integrity_tests/test_lium.py create mode 100644 src/tests/providers/test_lium.py diff --git a/.github/workflows/catalogs.yml b/.github/workflows/catalogs.yml index 6510475..7f2d4c8 100644 --- a/.github/workflows/catalogs.yml +++ b/.github/workflows/catalogs.yml @@ -403,6 +403,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 @@ -511,6 +525,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 705fb42..54c9f83 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ print(*items, sep="\n") * Hot Aisle * JarvisLabs * LambdaLabs +* Lium * Nebius * OCI * Runpod diff --git a/src/gpuhunt/__main__.py b/src/gpuhunt/__main__.py index b1d3a1d..4a88323 100644 --- a/src/gpuhunt/__main__.py +++ b/src/gpuhunt/__main__.py @@ -22,6 +22,7 @@ def main(): "hotaisle", "jarvislabs", "lambdalabs", + "lium", "nebius", "oci", "runpod", @@ -78,6 +79,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 814fff9..7d6214b 100644 --- a/src/gpuhunt/_internal/catalog.py +++ b/src/gpuhunt/_internal/catalog.py @@ -39,6 +39,7 @@ "digitalocean", "hotaisle", "jarvislabs", + "lium", "seeweb", "vastai", "vultr", diff --git a/src/gpuhunt/_internal/default.py b/src/gpuhunt/_internal/default.py index a9dbea4..92fac4c 100644 --- a/src/gpuhunt/_internal/default.py +++ b/src/gpuhunt/_internal/default.py @@ -18,6 +18,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..c859cbc --- /dev/null +++ b/src/gpuhunt/providers/lium.py @@ -0,0 +1,213 @@ +import logging +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. A node with N GPUs can be rented in part: 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, RAM and disk. 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_gb = node.get("disk_gb") + 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) + min_gpu_count = max(1, int(node.get("min_rentable_gpu_count") or 1)) + 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) + + offers: list[CatalogItem] = [] + for count in range(min_gpu_count, available_gpu_count + 1): + # A partial rental gets the host's CPUs, RAM and disk in proportion to its GPU share. + share = count / gpu_count + cpu = int(cpu_count * share) + memory = round(ram_gb * share, 2) + if cpu < 1 or memory <= 0: + logger.warning( + "Skipping Lium node %s with %d GPU(s): less than one CPU or no memory", + node_id, + count, + ) + continue + disk_size = None + if disk_gb: + disk_size = round(float(disk_gb) * share, 2) or None + offers.append( + CatalogItem( + provider=LiumProvider.NAME, + instance_name=node_id, + location=location, + price=round(price_per_gpu_hour * count, 5), + cpu=cpu, + memory=memory, + gpu_vendor=AcceleratorVendor.NVIDIA, + gpu_count=count, + gpu_name=gpu_name, + gpu_memory=float(gpu_memory), + spot=node.get("tier") == "spot", + disk_size=disk_size, + provider_data=cast(JSONObject, dict(provider_data)), + ) + ) + return offers + + +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|\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("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 cdb9db3..1268021 100644 --- a/src/tests/_internal/test_default.py +++ b/src/tests/_internal/test_default.py @@ -28,7 +28,7 @@ def offline_catalog(monkeypatch): class TestDefaultCatalog: def test_skips_providers_with_missing_creds(self, offline_catalog) -> None: catalog = default_catalog() - assert sorted(p.NAME for p in catalog.providers) == ["vastai", "vultr"] + assert sorted(p.NAME for p in catalog.providers) == ["lium", "vastai", "vultr"] def test_loads_providers_with_creds(self, offline_catalog, monkeypatch) -> None: monkeypatch.setenv("HOTAISLE_API_KEY", "key") diff --git a/src/tests/providers/test_lium.py b/src/tests/providers/test_lium.py new file mode 100644 index 0000000..57650ce --- /dev/null +++ b/src/tests/providers/test_lium.py @@ -0,0 +1,321 @@ +import copy + +import pytest +import requests + +from gpuhunt._internal.constraints import find_accelerators +from gpuhunt._internal.models import AcceleratorVendor +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). +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, + "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, + "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, + "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, + "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 == 251 + assert offer.disk_size == 935 + 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 + assert h200[1].memory == 251.88 # 2015 / 8 + assert h200[1].disk_size == 446.88 # 3575 / 8 + assert h200[5].price == 19.95 + assert h200[5].cpu == 120 + assert h200[5].memory == 1259.38 + # 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_defaults_to_one(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] == [1, 2] + + def test_missing_disk_size(self, requests_mock): + requests_mock.get(NODES_URL, json={"nodes": [make_node(disk_gb=None)]}) + + offers = LiumProvider().get() + + assert [o.disk_size for o in offers] == [None] + + 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"), + ("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") == "" From 71dd7f7497e448ad25b580fa5c4d4f6dd70dcbbf Mon Sep 17 00:00:00 2001 From: surcyf123 Date: Sun, 27 Sep 2026 07:14:18 +0000 Subject: [PATCH 2/4] Lium: subtract the host RAM reserve and default a missing minimum order to the whole node --- src/gpuhunt/providers/lium.py | 37 +++++++++++++++++++++++------- src/tests/providers/test_lium.py | 39 ++++++++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/src/gpuhunt/providers/lium.py b/src/gpuhunt/providers/lium.py index c859cbc..b976d8e 100644 --- a/src/gpuhunt/providers/lium.py +++ b/src/gpuhunt/providers/lium.py @@ -1,4 +1,5 @@ import logging +import math import re from typing import cast @@ -22,9 +23,11 @@ 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. A node with N GPUs can be rented in part: 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, RAM and disk. One offer is produced per rentable GPU + 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, + disk and RAM (after the RAM the host keeps for itself). 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. """ @@ -84,7 +87,9 @@ def _make_offers(node: dict) -> list[CatalogItem]: logger.warning("Skipping Lium node %s: non-positive GPU count or price", node_id) return [] available_gpu_count = min(available_gpu_count, gpu_count) - min_gpu_count = max(1, int(node.get("min_rentable_gpu_count") or 1)) + # 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", @@ -110,15 +115,17 @@ def _make_offers(node: dict) -> list[CatalogItem]: if rent_url: provider_data["rent_url"] = str(rent_url) + pod_ram_gb = ram_gb - get_host_ram_reserve_gb(ram_gb) offers: list[CatalogItem] = [] for count in range(min_gpu_count, available_gpu_count + 1): - # A partial rental gets the host's CPUs, RAM and disk in proportion to its GPU share. + # A rental gets the host's CPUs, RAM (after the host reserve) and disk in proportion + # to its GPU share. share = count / gpu_count cpu = int(cpu_count * share) - memory = round(ram_gb * share, 2) - if cpu < 1 or memory <= 0: + memory = max(1.0, math.floor(pod_ram_gb * share * 100) / 100) + if cpu < 1: logger.warning( - "Skipping Lium node %s with %d GPU(s): less than one CPU or no memory", + "Skipping Lium node %s with %d GPU(s): less than one CPU", node_id, count, ) @@ -146,6 +153,20 @@ def _make_offers(node: dict) -> list[CatalogItem]: return offers +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): diff --git a/src/tests/providers/test_lium.py b/src/tests/providers/test_lium.py index 57650ce..49b1db9 100644 --- a/src/tests/providers/test_lium.py +++ b/src/tests/providers/test_lium.py @@ -136,7 +136,7 @@ def test_whole_node_offer(self, requests_mock, feed): 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 == 251 + assert offer.memory == 247 # 251 minus the 4 GiB host reserve assert offer.disk_size == 935 assert offer.gpu_vendor == AcceleratorVendor.NVIDIA assert offer.gpu_count == 8 @@ -160,11 +160,12 @@ def test_partial_rental_gets_proportional_resources(self, requests_mock, feed): } assert h200[1].price == 3.99 assert h200[1].cpu == 24 # 192 / 8 - assert h200[1].memory == 251.88 # 2015 / 8 + # The host keeps max(4, 1% of 2015) = 20.15 GiB; the rest is split and rounded down. + assert h200[1].memory == 249.35 # (2015 - 20.15) / 8 assert h200[1].disk_size == 446.88 # 3575 / 8 assert h200[5].price == 19.95 assert h200[5].cpu == 120 - assert h200[5].memory == 1259.38 + assert h200[5].memory == 1246.78 # 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()) @@ -219,7 +220,7 @@ def test_skips_node_when_fewer_gpus_free_than_min_order(self, requests_mock): assert LiumProvider().get() == [] - def test_missing_min_rentable_defaults_to_one(self, requests_mock): + def test_missing_min_rentable_offers_whole_node_only(self, requests_mock): requests_mock.get( NODES_URL, json={ @@ -231,7 +232,35 @@ def test_missing_min_rentable_defaults_to_one(self, requests_mock): offers = LiumProvider().get() - assert [o.gpu_count for o in offers] == [1, 2] + 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] == [6, 12] # (16 - 4) / 2 per GPU def test_missing_disk_size(self, requests_mock): requests_mock.get(NODES_URL, json={"nodes": [make_node(disk_gb=None)]}) From 0fc9b50c4f5a9a9ec8fb7724fa841acf2ce0d94c Mon Sep 17 00:00:00 2001 From: surcyf123 Date: Thu, 15 Oct 2026 23:17:18 +0000 Subject: [PATCH 3/4] Lium: size disk from free disk as the pod's volume + storage quotas Use disk_free_gb, not total disk_gb, and sum the separately rounded-down 2/3 volume and 1/3 container storage quotas (docs.lium.io/pod-users/create-pod). Skip nodes with no free disk reported and offers whose quotas round to zero, since disk_size=None passes every disk filter. Co-Authored-By: Claude Opus 5.5 --- src/gpuhunt/providers/lium.py | 43 ++++++++++++++++++++++----- src/tests/providers/test_lium.py | 50 +++++++++++++++++++++++++++----- 2 files changed, 78 insertions(+), 15 deletions(-) diff --git a/src/gpuhunt/providers/lium.py b/src/gpuhunt/providers/lium.py index 226ed5d..702dfba 100644 --- a/src/gpuhunt/providers/lium.py +++ b/src/gpuhunt/providers/lium.py @@ -25,7 +25,7 @@ class LiumProvider(OnlineProvider): 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, - disk and RAM (after the RAM the host keeps for itself). Most nodes publish + free disk and RAM (after the RAM the host keeps for itself). 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. @@ -82,7 +82,15 @@ def _make_offers(node: dict) -> list[CatalogItem]: except (TypeError, ValueError) as e: logger.warning("Skipping Lium node %s: %s", node_id, e) return [] - disk_gb = node.get("disk_gb") + 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 [] @@ -118,8 +126,8 @@ def _make_offers(node: dict) -> list[CatalogItem]: pod_ram_gb = ram_gb - get_host_ram_reserve_gb(ram_gb) 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 disk in proportion - # to its GPU share. + # 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) memory = max(1.0, math.floor(pod_ram_gb * share * 100) / 100) @@ -130,9 +138,14 @@ def _make_offers(node: dict) -> list[CatalogItem]: count, ) continue - disk_size = None - if disk_gb: - disk_size = round(float(disk_gb) * share, 2) or None + disk_size = get_disk_quota_gb(disk_free_gb * share) + if disk_size < 1: + logger.debug( + "Skipping Lium node %s with %d GPU(s): its disk quotas round to zero", + node_id, + count, + ) + continue offers.append( CatalogItem( provider=LiumProvider.NAME, @@ -146,13 +159,27 @@ def _make_offers(node: dict) -> list[CatalogItem]: gpu_name=gpu_name, gpu_memory=float(gpu_memory), spot=node.get("tier") == "spot", - disk_size=disk_size, + disk_size=float(disk_size), provider_data=cast(JSONObject, dict(provider_data)), ) ) return offers +def get_disk_quota_gb(disk_gb: float) -> int: + """ + Disk in GB a pod gets from its share of the host's free disk: the volume quota (2/3) and the + container storage quota (1/3), each rounded down, as documented at + https://docs.lium.io/pod-users/create-pod. + + >>> get_disk_quota_gb(1467 / 8) + 183 + >>> get_disk_quota_gb(1) + 0 + """ + return int(disk_gb * 2 / 3) + 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. diff --git a/src/tests/providers/test_lium.py b/src/tests/providers/test_lium.py index 4ff5b33..c14fd58 100644 --- a/src/tests/providers/test_lium.py +++ b/src/tests/providers/test_lium.py @@ -3,11 +3,13 @@ import pytest import requests -from gpuhunt._internal.constraints import find_accelerators -from gpuhunt._internal.models import AcceleratorVendor +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, @@ -23,6 +25,7 @@ "cpu_count": 384, "ram_gb": 251, "disk_gb": 935, + "disk_free_gb": 800, "tier": "secure", "country": "Japan", "country_code": "JP", @@ -42,6 +45,7 @@ "cpu_count": 192, "ram_gb": 2015, "disk_gb": 3575, + "disk_free_gb": 1467, "tier": "secure", "country": "United States", "country_code": "US", @@ -61,6 +65,7 @@ "cpu_count": 24, "ram_gb": 62, "disk_gb": 1832, + "disk_free_gb": 1500, "tier": "spot", "country": "United States", "country_code": "US", @@ -80,6 +85,7 @@ "cpu_count": 240, "ram_gb": 1772, "disk_gb": 19383, + "disk_free_gb": 15000, "tier": "secure", "country": "United States", "country_code": "US", @@ -137,7 +143,8 @@ def test_whole_node_offer(self, requests_mock, feed): assert offer.price == 4.0 # 8 GPUs x $0.5 per GPU-hour assert offer.cpu == 384 assert offer.memory == 247 # 251 minus the 4 GiB host reserve - assert offer.disk_size == 935 + # int(800 * 2 / 3) + int(800 / 3) of the free disk, not the 935 GB total + assert offer.disk_size == 799 assert offer.gpu_vendor == AcceleratorVendor.NVIDIA assert offer.gpu_count == 8 assert offer.gpu_name == "RTX5090" @@ -162,7 +169,8 @@ def test_partial_rental_gets_proportional_resources(self, requests_mock, feed): assert h200[1].cpu == 24 # 192 / 8 # The host keeps max(4, 1% of 2015) = 20.15 GiB; the rest is split and rounded down. assert h200[1].memory == 249.35 # (2015 - 20.15) / 8 - assert h200[1].disk_size == 446.88 # 3575 / 8 + # Free disk, not total: int(1467 / 8 * 2 / 3) + int(1467 / 8 / 3) = 122 + 61. + assert h200[1].disk_size == 183 assert h200[5].price == 19.95 assert h200[5].cpu == 120 assert h200[5].memory == 1246.78 @@ -262,12 +270,40 @@ def test_small_host_keeps_minimum_reserve(self, requests_mock): assert [o.memory for o in offers] == [6, 12] # (16 - 4) / 2 per GPU - def test_missing_disk_size(self, requests_mock): - requests_mock.get(NODES_URL, json={"nodes": [make_node(disk_gb=None)]}) + @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=2 + ) + requests_mock.get(NODES_URL, json={"nodes": [node]}) offers = LiumProvider().get() - assert [o.disk_size for o in offers] == [None] + # 1 GPU: 1 GB -> int(0.67) + int(0.33) = 0, skipped; 2 GPUs: 2 GB -> 1 + 0 = 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 183 GB of quota. + assert not matches(h200[0], QueryFilter(min_disk_size=300)) + assert matches(h200[1], QueryFilter(min_disk_size=300)) # 2 GPUs: 244 + 122 = 366 def test_skips_unknown_gpu_model(self, requests_mock, caplog): requests_mock.get( From a4054125c118c3c3e82e49ff9fca91ecbde11c65 Mon Sep 17 00:00:00 2001 From: surcyf123 Date: Sat, 17 Oct 2026 02:25:34 +0000 Subject: [PATCH 4/4] Lium: report whole-GiB memory and the job's storage quota as lower bounds The feed rounds RAM and free disk to the nearest GB, so size offers from the lowest true value and round down to whole GiB, matching the pod's memory.max. Disk is now the container storage quota (1/3 of the share) that holds /workflow, not volume + storage. Co-Authored-By: Claude Opus 5.5 --- src/gpuhunt/providers/lium.py | 35 +++++++++++++++++----------- src/tests/providers/test_lium.py | 40 +++++++++++++++++++++----------- 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/src/gpuhunt/providers/lium.py b/src/gpuhunt/providers/lium.py index 702dfba..e106d03 100644 --- a/src/gpuhunt/providers/lium.py +++ b/src/gpuhunt/providers/lium.py @@ -25,7 +25,8 @@ class LiumProvider(OnlineProvider): 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). Most nodes publish + 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. @@ -123,14 +124,18 @@ def _make_offers(node: dict) -> list[CatalogItem]: if rent_url: provider_data["rent_url"] = str(rent_url) - pod_ram_gb = ram_gb - get_host_ram_reserve_gb(ram_gb) + # 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) - memory = max(1.0, math.floor(pod_ram_gb * share * 100) / 100) + # 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", @@ -138,10 +143,10 @@ def _make_offers(node: dict) -> list[CatalogItem]: count, ) continue - disk_size = get_disk_quota_gb(disk_free_gb * share) + 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 disk quotas round to zero", + "Skipping Lium node %s with %d GPU(s): its storage quota rounds to zero", node_id, count, ) @@ -153,7 +158,7 @@ def _make_offers(node: dict) -> list[CatalogItem]: location=location, price=round(price_per_gpu_hour * count, 5), cpu=cpu, - memory=memory, + memory=float(memory), gpu_vendor=AcceleratorVendor.NVIDIA, gpu_count=count, gpu_name=gpu_name, @@ -166,18 +171,20 @@ def _make_offers(node: dict) -> list[CatalogItem]: return offers -def get_disk_quota_gb(disk_gb: float) -> int: +def get_storage_quota_gb(disk_gb: float) -> int: """ - Disk in GB a pod gets from its share of the host's free disk: the volume quota (2/3) and the - container storage quota (1/3), each rounded down, as documented at - https://docs.lium.io/pod-users/create-pod. + Disk in GB available to a job on a pod with this share of the host's free disk. - >>> get_disk_quota_gb(1467 / 8) - 183 - >>> get_disk_quota_gb(1) + 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 * 2 / 3) + int(disk_gb / 3) + return int(disk_gb / 3) def get_host_ram_reserve_gb(ram_gb: float) -> float: diff --git a/src/tests/providers/test_lium.py b/src/tests/providers/test_lium.py index c14fd58..631839b 100644 --- a/src/tests/providers/test_lium.py +++ b/src/tests/providers/test_lium.py @@ -142,9 +142,9 @@ def test_whole_node_offer(self, requests_mock, feed): 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 == 247 # 251 minus the 4 GiB host reserve - # int(800 * 2 / 3) + int(800 / 3) of the free disk, not the 935 GB total - assert offer.disk_size == 799 + 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" @@ -167,13 +167,13 @@ def test_partial_rental_gets_proportional_resources(self, requests_mock, feed): } assert h200[1].price == 3.99 assert h200[1].cpu == 24 # 192 / 8 - # The host keeps max(4, 1% of 2015) = 20.15 GiB; the rest is split and rounded down. - assert h200[1].memory == 249.35 # (2015 - 20.15) / 8 - # Free disk, not total: int(1467 / 8 * 2 / 3) + int(1467 / 8 / 3) = 122 + 61. - assert h200[1].disk_size == 183 + # 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.78 + 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()) @@ -268,7 +268,7 @@ def test_small_host_keeps_minimum_reserve(self, requests_mock): offers = LiumProvider().get() - assert [o.memory for o in offers] == [6, 12] # (16 - 4) / 2 per GPU + 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): @@ -286,13 +286,13 @@ def test_skips_node_missing_free_disk(self, requests_mock): 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=2 + 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: 1 GB -> int(0.67) + int(0.33) = 0, skipped; 2 GPUs: 2 GB -> 1 + 0 = 1 + # 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): @@ -301,9 +301,21 @@ def test_disk_filter_uses_the_pod_quota(self, requests_mock, 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 183 GB of quota. - assert not matches(h200[0], QueryFilter(min_disk_size=300)) - assert matches(h200[1], QueryFilter(min_disk_size=300)) # 2 GPUs: 244 + 122 = 366 + # 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(