Skip to content

Commit 4fb15e6

Browse files
author
Andrey Cheptsov
committed
Add JarvisLabs provider
1 parent d00bc64 commit 4fb15e6

2 files changed

Lines changed: 53 additions & 37 deletions

File tree

src/gpuhunt/providers/jarvislabs.py

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
API_URL = "https://backendprod.jarvislabs.net"
1313
SERVER_META_PATH = "/misc/server_meta"
1414
TIMEOUT = 30
15-
VM_MIN_STORAGE_GB = 100
1615
# JarvisLabs exposes offer regions in server_meta, but VM provisioning calls must be sent
1716
# to region-specific API hosts and server_meta does not include those hosts. Keep this
1817
# allowlist in sync with the known provisioning hosts and do not advertise offers for
@@ -22,6 +21,12 @@
2221
"india-noida-01": "https://backendn.jarvislabs.net",
2322
"europe-01": "https://backendeu.jarvislabs.net",
2423
}
24+
# dstack provisions JarvisLabs GPU VMs by passing a GPU type back to the API.
25+
# Keep ambiguous API names with spaces out of the catalog; otherwise the
26+
# normalized gpuhunt name cannot be converted back safely without provider_data.
27+
JARVISLABS_GPU_NAME_OVERRIDES = {
28+
"A100-80GB": ("A100", 80.0),
29+
}
2530

2631

2732
class JarvisLabsProvider(AbstractProvider):
@@ -42,9 +47,7 @@ def get(
4247

4348
def fetch_offers(self, query_filter: QueryFilter | None = None) -> list[RawCatalogItem]:
4449
response = self._make_request("GET", SERVER_META_PATH)
45-
return convert_response_to_raw_catalog_items(
46-
response.json(), disk_size=_disk_size(query_filter)
47-
)
50+
return convert_response_to_raw_catalog_items(response.json())
4851

4952
def _make_request(self, method: str, path: str) -> Response:
5053
response = requests.request(
@@ -57,17 +60,15 @@ def _make_request(self, method: str, path: str) -> Response:
5760
return response
5861

5962

60-
def convert_response_to_raw_catalog_items(
61-
data: dict, disk_size: float = VM_MIN_STORAGE_GB
62-
) -> list[RawCatalogItem]:
63+
def convert_response_to_raw_catalog_items(data: dict) -> list[RawCatalogItem]:
6364
offers = []
6465
for gpu in data.get("server_meta") or []:
65-
offers.extend(_make_gpu_catalog_items(gpu, disk_size=disk_size))
66-
offers.extend(_make_cpu_catalog_items(data.get("cpu_meta") or {}, disk_size=disk_size))
66+
offers.extend(_make_gpu_catalog_items(gpu))
67+
offers.extend(_make_cpu_catalog_items(data.get("cpu_meta") or {}))
6768
return offers
6869

6970

70-
def _make_gpu_catalog_items(gpu: dict, disk_size: float) -> list[RawCatalogItem]:
71+
def _make_gpu_catalog_items(gpu: dict) -> list[RawCatalogItem]:
7172
region = gpu.get("region")
7273
if not region:
7374
return []
@@ -94,7 +95,11 @@ def _make_gpu_catalog_items(gpu: dict, disk_size: float) -> list[RawCatalogItem]
9495
logger.warning("Skipping JarvisLabs GPU offer without price: %s", gpu_type)
9596
return []
9697

97-
gpu_name, gpu_memory = _gpu_name_and_memory(gpu_type, gpu.get("vram"))
98+
gpu_spec = _gpu_name_and_memory(gpu_type, gpu.get("vram"))
99+
if gpu_spec is None:
100+
logger.warning("Skipping JarvisLabs GPU offer with ambiguous gpu_type: %s", gpu_type)
101+
return []
102+
gpu_name, gpu_memory = gpu_spec
98103
if gpu_memory is None:
99104
logger.warning("Skipping JarvisLabs GPU offer with unknown VRAM: %s", gpu_type)
100105
return []
@@ -115,7 +120,6 @@ def _make_gpu_catalog_items(gpu: dict, disk_size: float) -> list[RawCatalogItem]
115120
available_devices=_available_devices(gpu),
116121
max_gpus_per_instance=_max_gpus_per_instance(gpu),
117122
spot=False,
118-
disk_size=disk_size,
119123
)
120124

121125
spot_price = _as_float(gpu.get("spot_price"))
@@ -131,7 +135,6 @@ def _make_gpu_catalog_items(gpu: dict, disk_size: float) -> list[RawCatalogItem]
131135
available_devices=_spot_available_devices(gpu),
132136
max_gpus_per_instance=_max_gpus_per_instance(gpu),
133137
spot=True,
134-
disk_size=disk_size,
135138
)
136139
)
137140
return items
@@ -148,7 +151,6 @@ def _make_gpu_catalog_items_for_price(
148151
available_devices: int,
149152
max_gpus_per_instance: int,
150153
spot: bool,
151-
disk_size: float,
152154
) -> list[RawCatalogItem]:
153155
items = []
154156
for gpu_count in _supported_gpu_counts(
@@ -167,13 +169,13 @@ def _make_gpu_catalog_items_for_price(
167169
gpu_name=gpu_name,
168170
gpu_memory=gpu_memory,
169171
spot=spot,
170-
disk_size=disk_size,
172+
disk_size=None,
171173
)
172174
)
173175
return items
174176

175177

176-
def _make_cpu_catalog_items(cpu_meta: dict, disk_size: float) -> list[RawCatalogItem]:
178+
def _make_cpu_catalog_items(cpu_meta: dict) -> list[RawCatalogItem]:
177179
offers = []
178180
# The JarvisLabs SDK resolves CPU VMs from cpu_meta.combinations and creates them via
179181
# templates/vm/cpu/create; cpu_meta.workload_type is not the GPU workload selector.
@@ -208,7 +210,7 @@ def _make_cpu_catalog_items(cpu_meta: dict, disk_size: float) -> list[RawCatalog
208210
gpu_name=None,
209211
gpu_memory=None,
210212
spot=False,
211-
disk_size=disk_size,
213+
disk_size=None,
212214
)
213215
)
214216
return offers
@@ -234,17 +236,11 @@ def _max_gpus_per_instance(gpu: dict) -> int:
234236
return _as_int(gpu.get("num_gpus")) or 1
235237

236238

237-
def _disk_size(query_filter: QueryFilter | None) -> float:
238-
if query_filter is None or query_filter.min_disk_size is None:
239-
return float(VM_MIN_STORAGE_GB)
240-
return float(max(VM_MIN_STORAGE_GB, query_filter.min_disk_size))
241-
242-
243-
def _gpu_name_and_memory(gpu_type: str, vram: object) -> tuple[str, float | None]:
244-
gpu_memory = _as_float(vram)
245-
if gpu_type == "A100-80GB":
246-
return "A100", gpu_memory or 80.0
247-
return gpu_type.replace(" ", ""), gpu_memory
239+
def _gpu_name_and_memory(gpu_type: str, vram: object) -> tuple[str, float | None] | None:
240+
if any(c.isspace() for c in gpu_type):
241+
return None
242+
gpu_name, default_memory = JARVISLABS_GPU_NAME_OVERRIDES.get(gpu_type, (gpu_type, None))
243+
return gpu_name, _as_float(vram) or default_memory
248244

249245

250246
def _gpu_instance_name(gpu_name: str, gpu_count: int) -> str:

src/tests/providers/test_jarvislabs.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def test_convert_response_to_raw_catalog_items():
115115
assert a100.gpu_name == "A100"
116116
assert a100.gpu_memory == 80
117117
assert a100.location == "india-noida-01"
118-
assert a100.disk_size == 100
118+
assert a100.disk_size is None
119119
assert a100.provider_data == {}
120120

121121
a100_spot = next(o for o in offers if o.instance_name == "A100-1x" and o.spot)
@@ -125,21 +125,18 @@ def test_convert_response_to_raw_catalog_items():
125125
assert h100.gpu_count == 1
126126
assert h100.location == "europe-01"
127127
assert h100.provider_data == {}
128-
assert h100.disk_size == 100
128+
assert h100.disk_size is None
129129

130130
cpu = next(o for o in offers if o.gpu_count == 0)
131131
assert cpu.instance_name == "cpu-4x16"
132132
assert cpu.location == "india-noida-01"
133133
assert cpu.cpu == 4
134134
assert cpu.memory == 16
135135
assert cpu.provider_data == {}
136-
assert cpu.disk_size == 100
136+
assert cpu.disk_size is None
137137

138138
assert not any(o.location == "unknown-region" for o in offers)
139139

140-
offers = convert_response_to_raw_catalog_items(SERVER_META_RESPONSE, disk_size=250)
141-
assert all(o.disk_size == 250 for o in offers)
142-
143140

144141
def test_convert_response_warns_and_skips_unsupported_regions(caplog):
145142
convert_response_to_raw_catalog_items(SERVER_META_RESPONSE)
@@ -148,6 +145,26 @@ def test_convert_response_warns_and_skips_unsupported_regions(caplog):
148145
assert "Skipping JarvisLabs CPU VM offer in unsupported region unknown-region" in caplog.text
149146

150147

148+
def test_convert_response_skips_ambiguous_gpu_types_with_spaces(caplog):
149+
response = {
150+
"server_meta": [
151+
{
152+
"gpu_type": "H100 NVL",
153+
"region": "india-noida-01",
154+
"num_free_devices": 1,
155+
"price_per_hour": 2.99,
156+
"vram": "94",
157+
"cpus_per_gpu": 16,
158+
"ram_per_gpu": 200,
159+
"workload_type": "vm",
160+
},
161+
],
162+
}
163+
164+
assert convert_response_to_raw_catalog_items(response) == []
165+
assert "Skipping JarvisLabs GPU offer with ambiguous gpu_type: H100 NVL" in caplog.text
166+
167+
151168
def test_convert_response_skips_malformed_specs(caplog):
152169
response = {
153170
"server_meta": [
@@ -201,12 +218,15 @@ def test_fetch_offers(requests_mock):
201218

202219
assert requests_mock.last_request.headers["Authorization"] == "Bearer test-token"
203220
assert len(offers) == 9
221+
assert all(o.disk_size is None for o in offers)
204222

205223
offers = provider.fetch_offers(query_filter=QueryFilter(min_disk_size=250))
206-
assert all(o.disk_size == 250 for o in offers)
224+
assert len(offers) == 9
225+
assert all(o.disk_size is None for o in offers)
207226

208227
offers = provider.fetch_offers(query_filter=QueryFilter(min_disk_size=50))
209-
assert all(o.disk_size == 100 for o in offers)
228+
assert len(offers) == 9
229+
assert all(o.disk_size is None for o in offers)
210230

211231

212232
def test_catalog_query(requests_mock, monkeypatch):
@@ -223,6 +243,6 @@ def test_catalog_query(requests_mock, monkeypatch):
223243
assert len(catalog.query(provider=["jarvislabs"], gpu_name="A100", min_gpu_memory=80)) == 2
224244
assert len(catalog.query(provider=["jarvislabs"], max_gpu_count=0)) == 1
225245
assert len(catalog.query(provider=["jarvislabs"], min_disk_size=250)) == 9
226-
assert len(catalog.query(provider=["jarvislabs"], max_disk_size=50)) == 0
246+
assert len(catalog.query(provider=["jarvislabs"], max_disk_size=50)) == 9
227247
assert len(catalog.query(provider=["jarvislabs"], gpu_name="L4", spot=False)) == 3
228248
assert len(catalog.query(provider=["jarvislabs"], gpu_name="L4", spot=True)) == 2

0 commit comments

Comments
 (0)