Skip to content

Commit 8d8a33b

Browse files
authored
Add Hotaisle Provider (#163)
1 parent 731c951 commit 8d8a33b

5 files changed

Lines changed: 138 additions & 1 deletion

File tree

src/gpuhunt/__main__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ def main():
1717
"cudo",
1818
"datacrunch",
1919
"gcp",
20+
"hotaisle",
2021
"lambdalabs",
2122
"nebius",
2223
"oci",
@@ -57,6 +58,12 @@ def main():
5758
from gpuhunt.providers.gcp import GCPProvider
5859

5960
provider = GCPProvider(os.getenv("GCP_PROJECT_ID"))
61+
elif args.provider == "hotaisle":
62+
from gpuhunt.providers.hotaisle import HotAisleProvider
63+
64+
provider = HotAisleProvider(
65+
api_key=os.getenv("HOTAISLE_API_KEY"), team_handle=os.getenv("HOTAISLE_TEAM_HANDLE")
66+
)
6067
elif args.provider == "lambdalabs":
6168
from gpuhunt.providers.lambdalabs import LambdaLabsProvider
6269

src/gpuhunt/_internal/catalog.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"runpod",
3333
"cloudrift",
3434
]
35-
ONLINE_PROVIDERS = ["cudo", "tensordock", "vastai", "vultr"]
35+
ONLINE_PROVIDERS = ["cudo", "hotaisle", "tensordock", "vastai", "vultr"]
3636
RELOAD_INTERVAL = 15 * 60 # 15 minutes
3737

3838

src/gpuhunt/_internal/default.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,17 @@ def default_catalog() -> Catalog:
2323
("gpuhunt.providers.vastai", "VastAIProvider"),
2424
("gpuhunt.providers.cudo", "CudoProvider"),
2525
("gpuhunt.providers.vultr", "VultrProvider"),
26+
("gpuhunt.providers.hotaisle", "HotAisleProvider"),
2627
]:
2728
try:
2829
module = importlib.import_module(module)
2930
provider = getattr(module, provider)()
3031
catalog.add_provider(provider)
3132
except ImportError:
3233
logger.warning("Failed to import provider %s", provider)
34+
except ValueError as e:
35+
# Skip providers that require missing environment variables. Eg: HotAisleProvider
36+
logger.warning("Skipping provider %s: %s", provider, e)
3337
return catalog
3438

3539

src/gpuhunt/providers/hotaisle.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import logging
2+
import os
3+
from typing import Optional
4+
5+
import requests
6+
from requests import Response
7+
8+
from gpuhunt._internal.constraints import KNOWN_AMD_GPUS
9+
from gpuhunt._internal.models import AcceleratorVendor, QueryFilter, RawCatalogItem
10+
from gpuhunt.providers import AbstractProvider
11+
12+
logger = logging.getLogger(__name__)
13+
14+
API_URL = "https://admin.hotaisle.app/api"
15+
16+
17+
class HotAisleProvider(AbstractProvider):
18+
NAME = "hotaisle"
19+
20+
def __init__(self, api_key: Optional[str] = None, team_handle: Optional[str] = None):
21+
"""Hotaisle requries an API key and team handle to access the API."""
22+
self.api_key = api_key or os.getenv("HOTAISLE_API_KEY")
23+
self.team_handle = team_handle or os.getenv("HOTAISLE_TEAM_HANDLE")
24+
25+
if not self.api_key:
26+
raise ValueError("Set the HOTAISLE_API_KEY environment variable.")
27+
if not self.team_handle:
28+
raise ValueError("Set the HOTAISLE_TEAM_HANDLE environment variable.")
29+
30+
def get(
31+
self, query_filter: Optional[QueryFilter] = None, balance_resources: bool = True
32+
) -> list[RawCatalogItem]:
33+
offers = self.fetch_offers()
34+
return sorted(offers, key=lambda i: i.price)
35+
36+
def fetch_offers(self) -> list[RawCatalogItem]:
37+
"""Fetch available virtual machines from HotAisle API.
38+
See API documentation(https://admin.hotaisle.app/api/docs)
39+
for details."""
40+
url = f"/teams/{self.team_handle}/virtual_machines/available/"
41+
response = self._make_request("GET", url)
42+
return convert_response_to_raw_catalog_items(response)
43+
44+
def _make_request(self, method: str, url: str) -> Response:
45+
full_url = f"{API_URL}{url}"
46+
headers = {
47+
"accept": "application/json",
48+
"Authorization": f"Token {self.api_key}",
49+
}
50+
51+
response = requests.request(method=method, url=full_url, headers=headers, timeout=30)
52+
response.raise_for_status()
53+
return response
54+
55+
56+
def get_gpu_memory(gpu_name: str) -> Optional[float]:
57+
for gpu in KNOWN_AMD_GPUS:
58+
if gpu.name.upper() == gpu_name.upper():
59+
return float(gpu.memory)
60+
logger.warning(f"Unknown AMD GPU {gpu_name}")
61+
return None
62+
63+
64+
def convert_response_to_raw_catalog_items(response: Response) -> list[RawCatalogItem]:
65+
data = response.json()
66+
offers = []
67+
for item in data:
68+
price_in_cents = item["OnDemandPrice"]
69+
price = float(price_in_cents) / 100
70+
specs = item["Specs"]
71+
cpu_cores = specs["cpu_cores"]
72+
ram_capacity_bytes = specs["ram_capacity"]
73+
memory_gb = ram_capacity_bytes / (1024**3)
74+
disk_capacity_bytes = specs["disk_capacity"]
75+
disk_gb = disk_capacity_bytes / (1024**3)
76+
cpus = specs["cpus"]
77+
cpu_model = cpus["model"]
78+
gpus = specs["gpus"]
79+
gpu = gpus[0]
80+
gpu_count = gpu["count"]
81+
gpu_name = gpu["model"]
82+
gpu_vendor = AcceleratorVendor.AMD.value # All GPUs are AMD with HotAisle.
83+
gpu_memory = get_gpu_memory(gpu_name)
84+
85+
# Create instance name: cpu_model-cores-ram-gpucount-gpu
86+
instance_name = f"{gpu_count}x {gpu_name} {cpu_cores}x {cpu_model}"
87+
88+
offer = RawCatalogItem(
89+
instance_name=instance_name,
90+
location="us-michigan-1", # Hardcoded for now, as HotAisle only has one location.
91+
price=price,
92+
cpu=cpu_cores,
93+
memory=memory_gb,
94+
gpu_count=gpu_count,
95+
gpu_name=gpu_name,
96+
gpu_memory=gpu_memory,
97+
gpu_vendor=gpu_vendor,
98+
spot=False,
99+
disk_size=disk_gb,
100+
)
101+
offers.append(offer)
102+
103+
return offers
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import os
2+
3+
import pytest
4+
5+
from gpuhunt.providers.hotaisle import HotAisleProvider
6+
7+
8+
@pytest.fixture
9+
def provider():
10+
api_key = os.environ.get("HOTAISLE_API_KEY")
11+
team_handle = os.environ.get("HOTAISLE_TEAM_HANDLE")
12+
return HotAisleProvider(api_key=api_key, team_handle=team_handle)
13+
14+
15+
@pytest.fixture
16+
def offers(provider):
17+
"""Fixture that provides the list of offers from HotAisle."""
18+
return provider.get()
19+
20+
21+
def test_positive_prices(offers):
22+
"""Test that all offers have positive prices."""
23+
assert all(offer.price > 0 for offer in offers)

0 commit comments

Comments
 (0)