Skip to content

Commit b3f2f5d

Browse files
authored
Merge pull request #2274 from transformerlab/add/provider-show-gpus
Add show_gpus to every compute provider (API + CLI + GUI)
2 parents 3bab7d5 + 7b0175d commit b3f2f5d

28 files changed

Lines changed: 931 additions & 68 deletions

File tree

.agents/skills/transformerlab-cli/SKILL.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,13 @@ lab --format json provider info PROVIDER_ID
630630
# Health check (verifies the CLI can reach the provider's backend)
631631
lab --format json provider check PROVIDER_ID
632632

633+
# Show the GPUs available on a provider (accepts an id OR a name, like `delete`).
634+
# Reports live availability where the backend can report it (Slurm, SkyPilot,
635+
# RunPod, Lambda, Vast, Local), otherwise the provider's catalog of launchable
636+
# GPU types (AWS, GCP, Azure, Nebius). Each row is a GPU name + count; an empty
637+
# result ("No GPU information available") is normal for dstack and CPU-only hosts.
638+
lab --format json provider gpus PROVIDER_ID_OR_NAME
639+
633640
# Toggle availability without deleting
634641
lab provider enable PROVIDER_ID
635642
lab provider disable PROVIDER_ID
@@ -1027,6 +1034,7 @@ This applies to launching jobs, fetching logs, checking cluster status, and ever
10271034
| `lab provider update <id>` | Update provider config | No |
10281035
| `lab provider delete <id>` | Delete a provider (`--no-interactive` to skip prompt) | No |
10291036
| `lab provider check <id>` | Check provider health | No |
1037+
| `lab provider gpus <id_or_name>` | Show available GPUs on a provider (live where supported, else catalog) | No |
10301038
| `lab provider verify-lifecycle <id>` | Verify provider lifecycle via a storage probe (`--no-wait` to launch only; see `--help` for polling options) | No |
10311039
| `lab provider enable <id>` | Enable a provider | No |
10321040
| `lab provider disable <id>` | Disable a provider | No |

.agents/skills/transformerlab-cli/references/commands.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,28 @@ Delete a compute provider.
531531

532532
Check connectivity and health of a provider.
533533

534+
### `provider gpus <provider_id_or_name>`
535+
536+
Show the GPUs available on a provider. The argument accepts either a provider id
537+
or a name (resolved the same way as `provider delete`). Output is a `GPU | Count`
538+
table, or `--format json` returns `{provider_id, provider_type, gpus: [{gpu, count}]}`.
539+
540+
Semantics: **live availability where the backend can report it** — Slurm (free
541+
GPUs per node), SkyPilot (catalog across enabled clouds), RunPod, Lambda
542+
(regions with capacity), Vast.ai (rentable offers), Local (detected GPUs) —
543+
otherwise the provider's **catalog of launchable GPU types** (AWS, GCP, Azure,
544+
Nebius, and the fallback for the live providers). `count` is the available
545+
quantity for live sources, or the max launchable count per node for catalog
546+
sources; there is no live-vs-catalog flag in the output. An empty list
547+
(`No GPU information available`) is expected for dstack (no enumeration endpoint)
548+
and CPU-only local hosts. The command never errors on backend failures — it
549+
degrades to the catalog or an empty list.
550+
551+
```bash
552+
lab provider gpus my-skypilot
553+
lab --format json provider gpus PROVIDER_ID
554+
```
555+
534556
### `provider enable <provider_id>`
535557

536558
Enable a disabled provider.

api/test/api/test_request_logs.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ def list_jobs(self, cluster_name):
6161
def check(self):
6262
return True
6363

64+
def show_gpus(self):
65+
return []
66+
6467

6568
def _make_job_dict(
6669
job_id: str = "test-1",

api/test/test_show_gpus.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
"""Tests for each compute provider's show_gpus() method.
2+
3+
Catalog-only providers (AWS/Azure/GCP/Nebius) read a static module map and
4+
ignore ``self``, so they're exercised as unbound methods with a stub self. Live
5+
providers are constructed minimally and have their HTTP/exec helper mocked to
6+
assert both the live-parse path and the catalog/empty fallback. show_gpus() must
7+
never raise — failures degrade to the catalog (or an empty list).
8+
"""
9+
10+
from types import SimpleNamespace
11+
from unittest.mock import MagicMock, patch
12+
13+
import pytest
14+
15+
from transformerlab.compute_providers.base import gpu_catalog_from_map_keys
16+
from transformerlab.compute_providers.models import GpuInfo
17+
from transformerlab.compute_providers.aws import AWSProvider, _GPU_INSTANCE_MAP
18+
from transformerlab.compute_providers.azure import AzureProvider, _GPU_VM_SIZE_MAP
19+
from transformerlab.compute_providers.gcp import GCPProvider, _ATTACHED_GPU_MAP, _ACCELERATOR_MACHINE_MAP
20+
from transformerlab.compute_providers.nebius import NebiusProvider, _GPU_PLATFORM_PRESET_MAP
21+
from transformerlab.compute_providers.local import LocalProvider
22+
from transformerlab.compute_providers.runpod import RunpodProvider, _RUNPOD_GPU_NAME_MAP
23+
from transformerlab.compute_providers.lambda_labs import LambdaProvider, _GPU_INSTANCE_TYPE_MAP
24+
from transformerlab.compute_providers.vastai import VastAIProvider
25+
from transformerlab.compute_providers.slurm import SLURMProvider
26+
27+
# SkyPilot is an optional dependency: skypilot.py raises ImportError at import time
28+
# when the `sky` SDK isn't installed (e.g. the build CI job). Guard the import so
29+
# the rest of this module still collects, and skip the SkyPilot-specific tests.
30+
try:
31+
from transformerlab.compute_providers.skypilot import SkyPilotProvider
32+
33+
SKYPILOT_AVAILABLE = True
34+
except ImportError:
35+
SkyPilotProvider = None # type: ignore[assignment,misc]
36+
SKYPILOT_AVAILABLE = False
37+
38+
requires_skypilot = pytest.mark.skipif(not SKYPILOT_AVAILABLE, reason="sky SDK not installed")
39+
40+
41+
def _names(gpus):
42+
return {g.gpu for g in gpus}
43+
44+
45+
def _by_name(gpus):
46+
return {g.gpu: g.count for g in gpus}
47+
48+
49+
# ---------------------------------------------------------------------------
50+
# Shared catalog helper
51+
# ---------------------------------------------------------------------------
52+
53+
54+
def test_gpu_catalog_from_map_keys_collapses_to_max_count():
55+
keys = [("A100", 1), ("A100", 8), ("T4", 4)]
56+
result = gpu_catalog_from_map_keys(keys)
57+
assert all(isinstance(g, GpuInfo) for g in result)
58+
assert _by_name(result) == {"A100": 8, "T4": 4}
59+
# Sorted by GPU name for stable output.
60+
assert [g.gpu for g in result] == ["A100", "T4"]
61+
62+
63+
# ---------------------------------------------------------------------------
64+
# Catalog-only providers (self is unused)
65+
# ---------------------------------------------------------------------------
66+
67+
68+
def test_aws_show_gpus_is_catalog():
69+
gpus = AWSProvider.show_gpus(None)
70+
assert _by_name(gpus) == _by_name(gpu_catalog_from_map_keys(_GPU_INSTANCE_MAP.keys()))
71+
assert "A100" in _names(gpus) and "H100" in _names(gpus)
72+
73+
74+
def test_azure_show_gpus_is_catalog():
75+
gpus = AzureProvider.show_gpus(None)
76+
assert _by_name(gpus) == _by_name(gpu_catalog_from_map_keys(_GPU_VM_SIZE_MAP.keys()))
77+
78+
79+
def test_gcp_show_gpus_merges_both_maps():
80+
gpus = GCPProvider.show_gpus(None)
81+
expected = gpu_catalog_from_map_keys([*_ATTACHED_GPU_MAP.keys(), *_ACCELERATOR_MACHINE_MAP.keys()])
82+
assert _by_name(gpus) == _by_name(expected)
83+
# A100 comes from the accelerator-optimized map (max count 8).
84+
assert _by_name(gpus)["A100"] == 8
85+
86+
87+
def test_nebius_show_gpus_is_catalog():
88+
gpus = NebiusProvider.show_gpus(None)
89+
assert _by_name(gpus) == _by_name(gpu_catalog_from_map_keys(_GPU_PLATFORM_PRESET_MAP.keys()))
90+
91+
92+
# ---------------------------------------------------------------------------
93+
# Local
94+
# ---------------------------------------------------------------------------
95+
96+
97+
def test_local_show_gpus_aggregates_config_gpus():
98+
cfg = {"gpu": [{"name": "NVIDIA A100"}, {"name": "NVIDIA A100"}, {"name": "cpu"}, {"name": "NVIDIA H100"}]}
99+
with patch("transformerlab.compute_providers.local._read_local_provider_config", return_value=cfg):
100+
gpus = LocalProvider().show_gpus()
101+
assert _by_name(gpus) == {"NVIDIA A100": 2, "NVIDIA H100": 1}
102+
103+
104+
def test_local_show_gpus_empty_without_config():
105+
with patch("transformerlab.compute_providers.local._read_local_provider_config", return_value=None):
106+
assert LocalProvider().show_gpus() == []
107+
108+
109+
# ---------------------------------------------------------------------------
110+
# Runpod (live /gpu-types -> catalog fallback)
111+
# ---------------------------------------------------------------------------
112+
113+
114+
def test_runpod_show_gpus_live():
115+
provider = RunpodProvider(api_key="k")
116+
resp = MagicMock()
117+
resp.json.return_value = [
118+
{"id": "NVIDIA A100", "displayName": "A100", "maxGpuCount": 8},
119+
{"id": "NVIDIA H100", "displayName": "H100", "maxGpuCount": 4},
120+
]
121+
with patch.object(provider, "_make_request", return_value=resp):
122+
gpus = provider.show_gpus()
123+
assert _by_name(gpus) == {"A100": 8, "H100": 4}
124+
125+
126+
def test_runpod_show_gpus_falls_back_to_catalog():
127+
provider = RunpodProvider(api_key="k")
128+
with patch.object(provider, "_make_request", side_effect=RuntimeError("boom")):
129+
gpus = provider.show_gpus()
130+
assert _names(gpus) == set(_RUNPOD_GPU_NAME_MAP.keys())
131+
assert all(g.count == 1 for g in gpus)
132+
133+
134+
# ---------------------------------------------------------------------------
135+
# Lambda (live capacity -> catalog fallback)
136+
# ---------------------------------------------------------------------------
137+
138+
139+
def test_lambda_show_gpus_live_only_reports_available():
140+
provider = LambdaProvider(api_key="k", team_id="t")
141+
resp = MagicMock()
142+
resp.json.return_value = {
143+
"data": {
144+
"gpu_1x_a10": {"regions_with_capacity_available": [{"name": "us-west-1"}]},
145+
"gpu_8x_a100": {"regions_with_capacity_available": []}, # no capacity -> excluded
146+
}
147+
}
148+
with patch.object(provider, "_make_request", return_value=resp):
149+
gpus = provider.show_gpus()
150+
# gpu_1x_a10 -> ("A10", 1); the empty-capacity A100 entry is dropped.
151+
assert _by_name(gpus) == {"A10": 1}
152+
153+
154+
def test_lambda_show_gpus_falls_back_to_catalog_on_error():
155+
provider = LambdaProvider(api_key="k", team_id="t")
156+
with patch.object(provider, "_make_request", side_effect=RuntimeError("boom")):
157+
gpus = provider.show_gpus()
158+
assert _by_name(gpus) == _by_name(gpu_catalog_from_map_keys(_GPU_INSTANCE_TYPE_MAP.keys()))
159+
160+
161+
def test_lambda_show_gpus_falls_back_when_no_capacity_anywhere():
162+
provider = LambdaProvider(api_key="k", team_id="t")
163+
resp = MagicMock()
164+
resp.json.return_value = {"data": {"gpu_1x_a10": {"regions_with_capacity_available": []}}}
165+
with patch.object(provider, "_make_request", return_value=resp):
166+
gpus = provider.show_gpus()
167+
# Nothing available live -> show the full catalog instead of an empty list.
168+
assert _by_name(gpus) == _by_name(gpu_catalog_from_map_keys(_GPU_INSTANCE_TYPE_MAP.keys()))
169+
170+
171+
# ---------------------------------------------------------------------------
172+
# Vast.ai (live offers -> empty when none)
173+
# ---------------------------------------------------------------------------
174+
175+
176+
def test_vastai_show_gpus_aggregates_max_count_per_type():
177+
provider = VastAIProvider(api_key="k")
178+
resp = MagicMock()
179+
resp.json.return_value = {
180+
"offers": [
181+
{"gpu_name": "RTX_4090", "num_gpus": 1},
182+
{"gpu_name": "RTX_4090", "num_gpus": 4},
183+
{"gpu_name": "A100", "num_gpus": 8},
184+
{"gpu_name": "A100", "num_gpus": 0}, # ignored
185+
]
186+
}
187+
with patch.object(provider, "_make_request", return_value=resp):
188+
gpus = provider.show_gpus()
189+
assert _by_name(gpus) == {"RTX_4090": 4, "A100": 8}
190+
191+
192+
def test_vastai_show_gpus_empty_on_error():
193+
provider = VastAIProvider(api_key="k")
194+
with patch.object(provider, "_make_request", side_effect=RuntimeError("boom")):
195+
assert provider.show_gpus() == []
196+
197+
198+
# ---------------------------------------------------------------------------
199+
# SLURM (aggregate free GPUs across nodes; self only used for one helper)
200+
# ---------------------------------------------------------------------------
201+
202+
203+
def test_slurm_show_gpus_sums_free_across_nodes():
204+
nodes = [
205+
{"gpus": {"A100": 8}, "gpus_free": {"A100": 5}},
206+
{"gpus": {"A100": 8}, "gpus_free": {"A100": 2}},
207+
{"gpus": {"H100": 4}, "gpus_free": {}}, # free unknown -> fall back to total
208+
]
209+
stub = SimpleNamespace(_get_slurm_nodes_detailed=lambda: nodes)
210+
gpus = SLURMProvider.show_gpus(stub)
211+
assert _by_name(gpus) == {"A100": 7, "H100": 4}
212+
213+
214+
def test_slurm_show_gpus_empty_on_error():
215+
def _raise():
216+
raise RuntimeError("no slurm")
217+
218+
stub = SimpleNamespace(_get_slurm_nodes_detailed=_raise)
219+
assert SLURMProvider.show_gpus(stub) == []
220+
221+
222+
# ---------------------------------------------------------------------------
223+
# SkyPilot (server /list_accelerators -> [] on failure)
224+
# ---------------------------------------------------------------------------
225+
226+
227+
@requires_skypilot
228+
def test_skypilot_show_gpus_parses_accelerator_dict():
229+
info_a100 = SimpleNamespace(accelerator_name="A100", accelerator_count=8)
230+
info_a100_small = SimpleNamespace(accelerator_name="A100", accelerator_count=1)
231+
info_h100 = SimpleNamespace(accelerator_name="H100", accelerator_count=4)
232+
result = {"A100": [info_a100_small, info_a100], "H100": [info_h100]}
233+
234+
stub = SimpleNamespace(
235+
_server_common=SimpleNamespace(get_request_id=lambda resp: "rid"),
236+
_make_authenticated_request=MagicMock(return_value=MagicMock()),
237+
_get_request_result=lambda rid: result,
238+
)
239+
gpus = SkyPilotProvider.show_gpus(stub)
240+
assert _by_name(gpus) == {"A100": 8, "H100": 4}
241+
242+
243+
@requires_skypilot
244+
def test_skypilot_show_gpus_empty_on_error():
245+
stub = SimpleNamespace(
246+
_server_common=SimpleNamespace(get_request_id=lambda resp: "rid"),
247+
_make_authenticated_request=MagicMock(side_effect=RuntimeError("down")),
248+
_get_request_result=lambda rid: {},
249+
)
250+
assert SkyPilotProvider.show_gpus(stub) == []

api/transformerlab/compute_providers/aws.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313

1414
from transformerlab.shared.ssh_policy import get_add_if_verified_policy
1515

16-
from .base import ComputeProvider, format_status_snapshot
16+
from .base import ComputeProvider, format_status_snapshot, gpu_catalog_from_map_keys
1717
from .models import (
1818
ClusterConfig,
1919
ClusterState,
2020
ClusterStatus,
21+
GpuInfo,
2122
JobConfig,
2223
JobInfo,
2324
ResourceInfo,
@@ -659,6 +660,15 @@ def list_clusters(self) -> List[ClusterStatus]:
659660
)
660661
return statuses
661662

663+
def show_gpus(self) -> List[GpuInfo]:
664+
"""Return the catalog of GPU instance types AWS can launch.
665+
666+
AWS has no cheap live "list available GPUs" query (it would require
667+
enumerating instance-type offerings and quotas per region), so this
668+
returns the static catalog derived from the launch instance map.
669+
"""
670+
return gpu_catalog_from_map_keys(_GPU_INSTANCE_MAP.keys())
671+
662672
def get_cluster_resources(self, cluster_name: str) -> ResourceInfo:
663673
ec2 = self._get_ec2_client()
664674
instance = self._find_instance_by_cluster_name(ec2, cluster_name)

api/transformerlab/compute_providers/azure.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
import uuid
1111
from typing import Any, Dict, List, Optional, Union
1212

13-
from .base import ComputeProvider, format_status_snapshot
14-
from .models import ClusterConfig, ClusterState, ClusterStatus, JobConfig, JobInfo, ResourceInfo
13+
from .base import ComputeProvider, format_status_snapshot, gpu_catalog_from_map_keys
14+
from .models import ClusterConfig, ClusterState, ClusterStatus, GpuInfo, JobConfig, JobInfo, ResourceInfo
1515
from transformerlab.shared.ssh_policy import get_add_if_verified_policy
1616

1717
logger = logging.getLogger(__name__)
@@ -787,6 +787,14 @@ def list_clusters(self) -> List[ClusterStatus]:
787787
logger.warning("Error listing Azure VMs: %s", e)
788788
return statuses
789789

790+
def show_gpus(self) -> List[GpuInfo]:
791+
"""Return the catalog of GPU VM sizes Azure can launch.
792+
793+
Azure has no cheap live availability query here, so this returns the
794+
static catalog derived from the launch VM-size map.
795+
"""
796+
return gpu_catalog_from_map_keys(_GPU_VM_SIZE_MAP.keys())
797+
790798
def get_cluster_resources(self, cluster_name: str) -> ResourceInfo:
791799
compute_client = self._get_compute_client()
792800
try:

0 commit comments

Comments
 (0)