diff --git a/.github/workflows/catalogs.yml b/.github/workflows/catalogs.yml index f055f16..48d239d 100644 --- a/.github/workflows/catalogs.yml +++ b/.github/workflows/catalogs.yml @@ -433,6 +433,22 @@ jobs: - name: Run integrity tests run: uv run pytest src/integrity_tests/test_lium.py + test-runcrate: + name: Test Runcrate 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 + env: + RUNCRATE_API_KEY: ${{ secrets.RUNCRATE_API_KEY }} + run: uv run pytest src/integrity_tests/test_runcrate.py + test-seeweb: name: Test Seeweb integrity runs-on: ubuntu-latest @@ -545,6 +561,7 @@ jobs: - test-hotaisle - test-jarvislabs - test-lium + - test-runcrate - test-seeweb - test-vastai - test-vultr diff --git a/README.md b/README.md index 5240ff9..7e3453f 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Its public plan locations can omit available VDM plans. * Lium * Nebius * OCI +* Runcrate * Runpod * Seeweb * Vast AI diff --git a/src/gpuhunt/__main__.py b/src/gpuhunt/__main__.py index 6953c9f..3232a28 100644 --- a/src/gpuhunt/__main__.py +++ b/src/gpuhunt/__main__.py @@ -26,6 +26,7 @@ def main(): "lium", "nebius", "oci", + "runcrate", "runpod", "seeweb", "vastai", @@ -117,6 +118,10 @@ def main(): region=os.getenv("OCI_CLI_REGION"), ) ) + elif args.provider == "runcrate": + from gpuhunt.providers.runcrate import RuncrateProvider + + provider = RuncrateProvider.from_env() elif args.provider == "runpod": from gpuhunt.providers.runpod import RunpodProvider diff --git a/src/gpuhunt/_internal/catalog.py b/src/gpuhunt/_internal/catalog.py index 14a47b3..1f83406 100644 --- a/src/gpuhunt/_internal/catalog.py +++ b/src/gpuhunt/_internal/catalog.py @@ -41,6 +41,7 @@ "hotaisle", "jarvislabs", "lium", + "runcrate", "seeweb", "vastai", "vultr", diff --git a/src/gpuhunt/_internal/default.py b/src/gpuhunt/_internal/default.py index f9fca0d..5c8a152 100644 --- a/src/gpuhunt/_internal/default.py +++ b/src/gpuhunt/_internal/default.py @@ -20,6 +20,7 @@ ("gpuhunt.providers.hotaisle", "HotAisleProvider"), ("gpuhunt.providers.jarvislabs", "JarvisLabsProvider"), ("gpuhunt.providers.lium", "LiumProvider"), + ("gpuhunt.providers.runcrate", "RuncrateProvider"), ("gpuhunt.providers.seeweb", "SeewebProvider"), ("gpuhunt.providers.vastai", "VastAIProvider"), ("gpuhunt.providers.vultr", "VultrProvider"), diff --git a/src/gpuhunt/providers/runcrate.py b/src/gpuhunt/providers/runcrate.py new file mode 100644 index 0000000..af29778 --- /dev/null +++ b/src/gpuhunt/providers/runcrate.py @@ -0,0 +1,176 @@ +import logging +from typing import cast + +import requests +from typing_extensions import TypedDict + +from gpuhunt._internal.constraints import is_nvidia_superchip +from gpuhunt._internal.models import ( + AcceleratorVendor, + CatalogItem, + CPUArchitecture, + JSONObject, + QueryFilter, +) +from gpuhunt.providers.base import OnlineProvider, get_creds_env + +logger = logging.getLogger(__name__) + +# Documented at https://www.runcrate.ai/docs/api-reference/instances/list-available-instance-types. +# Returns every offer that can be launched right now in one unpaginated response. +INSTANCE_TYPES_URL = "https://www.runcrate.ai/api/v1/instances/types" +TIMEOUT = 30 + +# Runcrate `gpu_type` to the gpuhunt GPU name and per-GPU memory in GiB. The memory sizes are +# the ones in `KNOWN_NVIDIA_GPUS`. A type that is not listed here is skipped with a warning. +GPU_MAP: dict[str, tuple[str, float]] = { + "A10": ("A10", 24), + "A100": ("A100", 40), + "A100_80G": ("A100", 80), + "A4000": ("A4000", 16), + "A40": ("A40", 48), + "A5000": ("A5000", 24), + "A6000": ("A6000", 48), + "B200": ("B200", 180), + "B300": ("B300", 270), + "GH200": ("GH200", 96), + "H100": ("H100", 80), + "H200": ("H200", 141), + "L4": ("L4", 24), + "L40": ("L40", 48), + "L40S": ("L40S", 48), + "RTX4000Ada": ("RTX4000Ada", 20), + "RTX6000Ada": ("RTX6000Ada", 48), + "RTXPro6000": ("RTXPRO6000", 96), + "V100": ("V100", 16), + "V100_32G": ("V100", 32), +} + + +class RuncrateProvider(OnlineProvider): + """Online provider for Runcrate (https://www.runcrate.ai) GPU and CPU VMs. + + The instance types endpoint needs an API key, so offers are queried live with + `RUNCRATE_API_KEY`. Every offer is a fixed VM shape in one region at an on-demand hourly + price for the whole VM. `instance_name` is the offer `id`, which the create instance API + takes as `instance_type_id`. Offer ids change as capacity changes, so they are only valid + for a launch shortly after the query. + """ + + NAME = "runcrate" + + def __init__(self, api_key: str): + self.api_key = api_key + + @classmethod + def from_env(cls) -> "RuncrateProvider": + return cls(api_key=get_creds_env("RUNCRATE_API_KEY")) + + def get( + self, + query_filter: QueryFilter | None = None, + balance_resources: bool = True, + apply_filter: bool = False, + ) -> list[CatalogItem]: + response = requests.get( + INSTANCE_TYPES_URL, + headers={"Authorization": f"Bearer {self.api_key}"}, + timeout=TIMEOUT, + ) + response.raise_for_status() + data = response.json() + if not isinstance(data, dict) or not isinstance(data.get("data"), list): + raise ValueError("Unexpected response from Runcrate instance types endpoint") + + offers: list[CatalogItem] = [] + for instance_type in data["data"]: + if not isinstance(instance_type, dict): + logger.warning("Skipping malformed Runcrate instance type: %r", instance_type) + continue + offer = _make_offer(instance_type) + if offer is not None: + offers.append(offer) + return sorted(offers, key=lambda i: i.price) + + +class RuncrateCatalogItemProviderData(TypedDict): + # Runcrate GPU type, which the create instance API also accepts with `gpu_count` and + # `region` in place of `instance_type_id`. + gpu_type: str + + +def _make_offer(instance_type: dict) -> CatalogItem | None: + offer_id = instance_type.get("id") + if not isinstance(offer_id, str) or not offer_id: + logger.warning("Skipping Runcrate instance type without id: %r", instance_type) + return None + try: + gpu_type = _required_str(instance_type, "gpu_type") + region = _required_str(instance_type, "region") + gpu_count = _required_int(instance_type, "gpu_count") + cpu = _required_int(instance_type, "cpu_cores") + memory = _required_number(instance_type, "memory_gb") + disk_size = _required_number(instance_type, "storage_gb") + price = _required_number(instance_type, "hourly_rate") + except ValueError as e: + logger.warning("Skipping Runcrate instance type %s: %s", offer_id, e) + return None + if gpu_count < 0 or cpu <= 0 or memory <= 0 or disk_size <= 0 or price <= 0: + logger.warning( + "Skipping Runcrate instance type %s: non-positive resources or price", offer_id + ) + return None + + gpu_name = None + gpu_memory = None + gpu_vendor = None + cpu_arch = CPUArchitecture.X86 + if gpu_count > 0: + if gpu_type not in GPU_MAP: + logger.warning( + "Skipping Runcrate instance type %s: unknown gpu_type %r", offer_id, gpu_type + ) + return None + gpu_name, gpu_memory = GPU_MAP[gpu_type] + gpu_vendor = AcceleratorVendor.NVIDIA + if is_nvidia_superchip(gpu_name): + cpu_arch = CPUArchitecture.ARM + + provider_data = RuncrateCatalogItemProviderData(gpu_type=gpu_type) + return CatalogItem( + provider=RuncrateProvider.NAME, + instance_name=offer_id, + location=region, + price=price, + cpu=cpu, + memory=memory, + gpu_vendor=gpu_vendor, + gpu_count=gpu_count, + gpu_name=gpu_name, + gpu_memory=gpu_memory, + spot=False, + disk_size=disk_size, + cpu_arch=cpu_arch, + provider_data=cast(JSONObject, dict(provider_data)), + ) + + +def _required_str(instance_type: dict, name: str) -> str: + value = instance_type.get(name) + if not isinstance(value, str) or not value: + raise ValueError(f"missing {name}") + return value + + +def _required_int(instance_type: dict, name: str) -> int: + value = instance_type.get(name) + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"missing {name}") + return value + + +def _required_number(instance_type: dict, name: str) -> float: + value = instance_type.get(name) + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError(f"missing {name}") + return float(value) diff --git a/src/integrity_tests/test_runcrate.py b/src/integrity_tests/test_runcrate.py new file mode 100644 index 0000000..0ab8285 --- /dev/null +++ b/src/integrity_tests/test_runcrate.py @@ -0,0 +1,55 @@ +import warnings + +import pytest +import requests + +from gpuhunt import AcceleratorVendor, CatalogItem +from gpuhunt._internal.constraints import find_accelerators +from gpuhunt.providers.base import get_creds_env +from gpuhunt.providers.runcrate import GPU_MAP, INSTANCE_TYPES_URL, TIMEOUT, RuncrateProvider +from integrity_tests.base import OffersIntegrityTests + + +class TestRuncrateOffers(OffersIntegrityTests): + @pytest.fixture(scope="class") + def offers(self) -> list[CatalogItem]: + return RuncrateProvider.from_env().get() + + def test_gpu_offers_present(self, offers: list[CatalogItem]) -> None: + assert any(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 if o.gpu_count > 0} + assert vendors == {AcceleratorVendor.NVIDIA} + + # Unknown types 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: + if offer.gpu_count > 0: + assert offer.gpu_name and find_accelerators(names=[offer.gpu_name]), str(offer) + + def test_disk_size_present(self, offers: list[CatalogItem]) -> None: + assert all(o.disk_size for o in offers) + + def test_spot_not_present(self, offers: list[CatalogItem]) -> None: + assert not any(o.spot for o in offers) + + +def test_real_world_runcrate_gpu_types(): + """Warn about live ``gpu_type`` values that would be skipped as unknown GPUs.""" + response = requests.get( + INSTANCE_TYPES_URL, + headers={"Authorization": f"Bearer {get_creds_env('RUNCRATE_API_KEY')}"}, + timeout=TIMEOUT, + ) + response.raise_for_status() + unknown = { + t["gpu_type"] + for t in response.json()["data"] + if t.get("gpu_count") and t.get("gpu_type") not in GPU_MAP + } + if unknown: + warnings.warn( + f"Found {len(unknown)} Runcrate GPU types 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 c062a48..2c38702 100644 --- a/src/tests/_internal/test_default.py +++ b/src/tests/_internal/test_default.py @@ -12,6 +12,7 @@ "HOTAISLE_API_KEY", "HOTAISLE_TEAM_HANDLE", "JL_API_KEY", + "RUNCRATE_API_KEY", "SEEWEB_API_TOKEN", ] diff --git a/src/tests/providers/test_runcrate.py b/src/tests/providers/test_runcrate.py new file mode 100644 index 0000000..f89a6fc --- /dev/null +++ b/src/tests/providers/test_runcrate.py @@ -0,0 +1,317 @@ +import copy + +import pytest +import requests + +import gpuhunt._internal.catalog as internal_catalog +from gpuhunt import Catalog, MissingCredsError +from gpuhunt._internal.constraints import find_accelerators +from gpuhunt._internal.models import AcceleratorVendor, CPUArchitecture +from gpuhunt.providers.runcrate import GPU_MAP, INSTANCE_TYPES_URL, TIMEOUT, RuncrateProvider + +# Recorded from GET /api/v1/instances/types on 2026-10-25 (trimmed to nine of 152 offers, and +# `subscription_terms` to one entry). Offer ids are replaced with placeholders. +RECORDED_RESPONSE = { + "data": [ + { + "id": "offer-1-cpu", + "gpu_type": "CPU", + "gpu_count": 0, + "cpu_cores": 4, + "memory_gb": 16, + "storage_gb": 250, + "region": "helsinki-finland-5", + "deployment_type": "vm", + "hourly_rate": 0.16, + "subscription_terms": [ + { + "term_months": 1, + "discount_pct": 0.05, + "effective_hourly_rate": 0.152, + "term_hours": 730, + "total_price": 110.96, + "monthly_equivalent": 110.96, + "saving": 5.84, + } + ], + }, + { + "id": "offer-2-a6000", + "gpu_type": "A6000", + "gpu_count": 1, + "cpu_cores": 6, + "memory_gb": 48, + "storage_gb": 256, + "region": "wichita-usa-1", + "deployment_type": "vm", + "hourly_rate": 0.74, + }, + { + "id": "offer-3-a6000", + "gpu_type": "A6000", + "gpu_count": 4, + "cpu_cores": 30, + "memory_gb": 192, + "storage_gb": 1024, + "region": "desmoines-usa-1", + "deployment_type": "vm", + "hourly_rate": 2.96, + }, + { + "id": "offer-4-a100-80g", + "gpu_type": "A100_80G", + "gpu_count": 8, + "cpu_cores": 240, + "memory_gb": 1280, + "storage_gb": 9578, + "region": "omaha-usa-2", + "deployment_type": "vm", + "hourly_rate": 13.84, + }, + { + "id": "offer-5-a100", + "gpu_type": "A100", + "gpu_count": 1, + "cpu_cores": 14, + "memory_gb": 117, + "storage_gb": 1700, + "region": "calgary-canada-1", + "deployment_type": "vm", + "hourly_rate": 1.99, + }, + { + "id": "offer-6-gh200", + "gpu_type": "GH200", + "gpu_count": 1, + "cpu_cores": 64, + "memory_gb": 432, + "storage_gb": 4096, + "region": "dulles-usa-3", + "deployment_type": "vm", + "hourly_rate": 2.98, + }, + { + "id": "offer-7-v100", + "gpu_type": "V100", + "gpu_count": 4, + "cpu_cores": 20, + "memory_gb": 90, + "storage_gb": 1000, + "region": "helsinki-finland-1", + "deployment_type": "vm", + "hourly_rate": 2.2, + }, + { + "id": "offer-8-rtxpro6000", + "gpu_type": "RTXPro6000", + "gpu_count": 1, + "cpu_cores": 16, + "memory_gb": 192, + "storage_gb": 750, + "region": "beltsville-usa-1", + "deployment_type": "vm", + "hourly_rate": 2.85, + }, + { + "id": "offer-9-h100", + "gpu_type": "H100", + "gpu_count": 8, + "cpu_cores": 252, + "memory_gb": 1440, + "storage_gb": 6600, + "region": "montreal-canada-2", + "deployment_type": "vm", + "hourly_rate": 29.52, + }, + ], + "meta": {"total": 9}, +} + + +@pytest.fixture +def response() -> dict: + return copy.deepcopy(RECORDED_RESPONSE) + + +def make_instance_type(**overrides) -> dict: + instance_type = copy.deepcopy(RECORDED_RESPONSE["data"][1]) + instance_type.update(overrides) + return instance_type + + +def offers_by_id(offers): + return {o.instance_name: o for o in offers} + + +class TestGet: + def test_one_offer_per_instance_type_sorted_by_price(self, requests_mock, response): + requests_mock.get(INSTANCE_TYPES_URL, json=response) + + offers = RuncrateProvider(api_key="key").get() + + assert len(offers) == 9 + prices = [o.price for o in offers] + assert prices == sorted(prices) + + def test_sends_api_key(self, requests_mock, response): + requests_mock.get(INSTANCE_TYPES_URL, json=response) + + RuncrateProvider(api_key="rc_live_test").get() + + assert requests_mock.last_request.headers["Authorization"] == "Bearer rc_live_test" + assert requests_mock.last_request.timeout == TIMEOUT + + def test_gpu_offer(self, requests_mock, response): + requests_mock.get(INSTANCE_TYPES_URL, json=response) + + offer = offers_by_id(RuncrateProvider(api_key="key").get())["offer-3-a6000"] + + assert offer.provider == "runcrate" + assert offer.location == "desmoines-usa-1" + assert offer.price == 2.96 # the whole VM, not per GPU + assert offer.cpu == 30 + assert offer.memory == 192 + assert offer.disk_size == 1024 + assert offer.gpu_vendor == AcceleratorVendor.NVIDIA + assert offer.gpu_count == 4 + assert offer.gpu_name == "A6000" + assert offer.gpu_memory == 48 + assert offer.spot is False + assert offer.cpu_arch == CPUArchitecture.X86 + assert offer.flags == [] + assert offer.provider_data == {"gpu_type": "A6000"} + + def test_cpu_offer(self, requests_mock, response): + requests_mock.get(INSTANCE_TYPES_URL, json=response) + + offer = offers_by_id(RuncrateProvider(api_key="key").get())["offer-1-cpu"] + + assert offer.gpu_count == 0 + assert offer.gpu_name is None + assert offer.gpu_memory is None + assert offer.gpu_vendor is None + assert offer.cpu == 4 + assert offer.memory == 16 + assert offer.price == 0.16 + + @pytest.mark.parametrize( + ("offer_id", "gpu_name", "gpu_memory"), + [ + ("offer-4-a100-80g", "A100", 80), + ("offer-5-a100", "A100", 40), + ("offer-7-v100", "V100", 16), + ("offer-8-rtxpro6000", "RTXPRO6000", 96), + ("offer-9-h100", "H100", 80), + ], + ) + def test_gpu_type_normalized(self, requests_mock, response, offer_id, gpu_name, gpu_memory): + requests_mock.get(INSTANCE_TYPES_URL, json=response) + + offer = offers_by_id(RuncrateProvider(api_key="key").get())[offer_id] + + assert offer.gpu_name == gpu_name + assert offer.gpu_memory == gpu_memory + + def test_superchip_is_arm(self, requests_mock, response): + requests_mock.get(INSTANCE_TYPES_URL, json=response) + + offer = offers_by_id(RuncrateProvider(api_key="key").get())["offer-6-gh200"] + + assert offer.gpu_name == "GH200" + assert offer.cpu_arch == CPUArchitecture.ARM + + def test_skips_unknown_gpu_type(self, requests_mock, caplog): + requests_mock.get( + INSTANCE_TYPES_URL, + json={"data": [make_instance_type(id="new", gpu_type="X1000"), make_instance_type()]}, + ) + + offers = RuncrateProvider(api_key="key").get() + + assert [o.gpu_name for o in offers] == ["A6000"] + assert "unknown gpu_type 'X1000'" in caplog.text + + @pytest.mark.parametrize( + "overrides", + [ + {"hourly_rate": None}, + {"hourly_rate": "0.74"}, + {"cpu_cores": True}, + {"region": ""}, + {"gpu_count": 1.5}, + {"id": None}, + ], + ids=repr, + ) + def test_skips_instance_type_with_missing_fields(self, requests_mock, caplog, overrides): + requests_mock.get(INSTANCE_TYPES_URL, json={"data": [make_instance_type(**overrides)]}) + + assert RuncrateProvider(api_key="key").get() == [] + assert "Skipping Runcrate instance type" in caplog.text + + @pytest.mark.parametrize( + "overrides", + [{"hourly_rate": 0}, {"cpu_cores": 0}, {"memory_gb": 0}, {"storage_gb": 0}], + ids=repr, + ) + def test_skips_non_positive_resources_or_price(self, requests_mock, overrides): + requests_mock.get(INSTANCE_TYPES_URL, json={"data": [make_instance_type(**overrides)]}) + + assert RuncrateProvider(api_key="key").get() == [] + + def test_skips_malformed_instance_types(self, requests_mock, caplog): + requests_mock.get( + INSTANCE_TYPES_URL, json={"data": ["not an instance type", make_instance_type()]} + ) + + assert len(RuncrateProvider(api_key="key").get()) == 1 + assert "malformed" in caplog.text + + def test_unexpected_response_is_rejected(self, requests_mock): + requests_mock.get(INSTANCE_TYPES_URL, json=[make_instance_type()]) + + with pytest.raises(ValueError, match="Unexpected response"): + RuncrateProvider(api_key="key").get() + + def test_http_error_is_propagated(self, requests_mock): + requests_mock.get( + INSTANCE_TYPES_URL, + status_code=401, + json={"error": {"code": "unauthorized", "message": "Invalid or revoked API key"}}, + ) + + with pytest.raises(requests.HTTPError): + RuncrateProvider(api_key="key").get() + + +class TestCatalogQuery: + def test_query_filters_runcrate_offers(self, requests_mock, monkeypatch, response): + # Other test modules replace the provider lists without restoring them. + monkeypatch.setattr(internal_catalog, "ONLINE_PROVIDERS", ["runcrate"]) + requests_mock.get(INSTANCE_TYPES_URL, json=response) + catalog = Catalog(auto_reload=False) + catalog.add_provider(RuncrateProvider(api_key="key")) + + offers = catalog.query(provider=["runcrate"], gpu_name=["A100"], min_gpu_memory=80) + + assert [o.instance_name for o in offers] == ["offer-4-a100-80g"] + + +def test_from_env_reads_api_key(monkeypatch): + monkeypatch.setenv("RUNCRATE_API_KEY", "rc_live_test") + + assert RuncrateProvider.from_env().api_key == "rc_live_test" + + +def test_from_env_without_api_key_is_rejected(monkeypatch): + monkeypatch.delenv("RUNCRATE_API_KEY", raising=False) + + with pytest.raises(MissingCredsError): + RuncrateProvider.from_env() + + +@pytest.mark.parametrize(("gpu_type", "gpu"), GPU_MAP.items(), ids=list(GPU_MAP)) +def test_gpu_map_uses_known_gpus(gpu_type, gpu): + gpu_name, gpu_memory = gpu + known = find_accelerators(names=[gpu_name], vendors=[AcceleratorVendor.NVIDIA]) + assert gpu_memory in {info.memory for info in known}