diff --git a/src/gpuhunt/__main__.py b/src/gpuhunt/__main__.py index 620e538..28c4f2a 100644 --- a/src/gpuhunt/__main__.py +++ b/src/gpuhunt/__main__.py @@ -24,6 +24,7 @@ def main(): "nebius", "oci", "runpod", + "seeweb", "vastai", "vultr", ], @@ -116,6 +117,10 @@ def main(): from gpuhunt.providers.runpod import RunpodProvider provider = RunpodProvider() + elif args.provider == "seeweb": + from gpuhunt.providers.seeweb import SeewebProvider + + provider = SeewebProvider(os.getenv("SEEWEB_API_TOKEN")) elif args.provider == "vastai": from gpuhunt.providers.vastai import VastAIProvider diff --git a/src/gpuhunt/_internal/catalog.py b/src/gpuhunt/_internal/catalog.py index 95a4478..864bcd2 100644 --- a/src/gpuhunt/_internal/catalog.py +++ b/src/gpuhunt/_internal/catalog.py @@ -37,6 +37,7 @@ "digitalocean", "hotaisle", "jarvislabs", + "seeweb", "vastai", "vultr", ] diff --git a/src/gpuhunt/providers/seeweb.py b/src/gpuhunt/providers/seeweb.py new file mode 100644 index 0000000..0817445 --- /dev/null +++ b/src/gpuhunt/providers/seeweb.py @@ -0,0 +1,185 @@ +import logging +import os +import re + +import requests + +from gpuhunt._internal.constraints import find_accelerators +from gpuhunt._internal.models import AcceleratorVendor, QueryFilter, RawCatalogItem +from gpuhunt.providers import AbstractProvider + +logger = logging.getLogger(__name__) + +API_URL = "https://api.seeweb.it/ecs/v2" +TIMEOUT = 30 + +# Maps a Seeweb plan's ``gpu_label`` (the ``gpu_label`` field of the ``/plans`` response) to a +# canonical GPU name and per-card memory in GiB. Keys are the exact ``gpu_label`` strings returned +# by the live ``/plans`` endpoint. Any GPU label not found here (and not resolvable by +# ``find_accelerators``) is skipped with a warning, which makes missing mappings easy to spot. +SEEWEB_GPU_MAP: dict[str, tuple[str, float]] = { + "a30": ("A30", 24), + "l4 24gb": ("L4", 24), + "l40s 48gb": ("L40S", 48), + "a100 80gb": ("A100", 80), + "h100 80gb": ("H100", 80), + "h200 141gb": ("H200", 141), + "rtx a6000 48gb": ("A6000", 48), + "rtx 6000 24gb": ("RTX6000", 24), + "rtx pro 6000 96gb": ("RTXPRO6000", 96), +} + +# Non-NVIDIA accelerators Seeweb offers that the NVIDIA-only MVP intentionally skips (quietly). +_NON_NVIDIA_MARKERS = ("MI300", "TENSTORRENT", "GRAYSKULL", "WORMHOLE") + + +class SeewebProvider(AbstractProvider): + """Online provider for Seeweb Cloud Server GPU. + + Seeweb's plan/pricing endpoints require authentication, so this is an online provider queried + live with a token. The dstack Seeweb backend builds a private ``gpuhunt.Catalog`` with this + provider and the project's token (like the vastai/jarvislabs backends), so offers work without + a published catalog. ``python -m gpuhunt seeweb`` can still dump a catalog for inspection. + """ + + NAME = "seeweb" + + def __init__(self, token: str | None = None): + token = token or os.getenv("SEEWEB_API_TOKEN") + if not token: + raise ValueError("Set the SEEWEB_API_TOKEN environment variable.") + self.token = token + + def get( + self, query_filter: QueryFilter | None = None, balance_resources: bool = True + ) -> list[RawCatalogItem]: + response = requests.get( + f"{API_URL}/plans", + headers={"X-APITOKEN": self.token}, + timeout=TIMEOUT, + ) + response.raise_for_status() + data = response.json() + if not isinstance(data, dict) or not isinstance(data.get("plans"), list): + raise ValueError("Unexpected response from Seeweb /plans endpoint") + + offers: list[RawCatalogItem] = [] + for plan in data["plans"]: + if not isinstance(plan, dict): + logger.warning("Skipping malformed Seeweb plan: %r", plan) + continue + offers.extend(_convert_plan(plan)) + return sorted(offers, key=lambda item: item.price if item.price is not None else 0.0) + + +def _convert_plan(plan: dict) -> list[RawCatalogItem]: + plan_name = plan.get("name") + gpu_label = plan.get("gpu_label") + # In the /plans response, "available" means that the plan is active. It does not indicate + # current capacity in any particular region. + if plan.get("available") is False: + return [] + if not plan_name or not gpu_label: + # This includes CPU-only plans, which are outside the NVIDIA GPU provider scope. + return [] + try: + gpu_count = _parse_int(plan.get("gpu")) + except (TypeError, ValueError): + logger.warning("Skipping Seeweb plan %s: invalid GPU count %r", plan_name, plan.get("gpu")) + return [] + if gpu_count <= 0: + return [] + if any(marker in str(gpu_label).upper() for marker in _NON_NVIDIA_MARKERS): + logger.debug("Skipping non-NVIDIA Seeweb plan %s (%s)", plan_name, gpu_label) + return [] + + resolved = _normalize_gpu(str(gpu_label)) + if resolved is None: + logger.warning("Skipping Seeweb plan %s: unknown gpu_label %r", plan_name, gpu_label) + return [] + gpu_name, gpu_memory = resolved + + try: + cpu = _parse_int(plan.get("cpu")) + memory = _parse_float(plan.get("ram")) / 1024 + disk_size = _parse_float(plan.get("disk")) + price = _parse_float(plan.get("hourly_price")) + except (TypeError, ValueError) as e: + logger.warning("Skipping Seeweb plan %s: %s", plan_name, e) + return [] + if cpu <= 0 or memory <= 0 or disk_size <= 0 or price < 0: + logger.warning( + "Skipping Seeweb plan %s: invalid non-positive resources or price", plan_name + ) + return [] + + offers = [] + seen_regions = set() + # These are regions where the plan is active/compatible, not a real-time capacity signal. + # Consumers that need current capacity should query /plans/availables separately. + regions = plan.get("available_regions") + if not isinstance(regions, list): + logger.warning("Skipping Seeweb plan %s: invalid available_regions", plan_name) + return [] + for region in regions: + if not isinstance(region, dict) or not isinstance(region.get("location"), str): + logger.warning("Skipping malformed region for Seeweb plan %s: %r", plan_name, region) + continue + location = region["location"].strip() + if not location or location in seen_regions: + continue + seen_regions.add(location) + offers.append( + RawCatalogItem( + instance_name=plan_name, + location=location, + price=price, + cpu=cpu, + # Seeweb returns RAM in MiB; gpuhunt represents memory in GiB. + memory=memory, + gpu_count=gpu_count, + gpu_name=gpu_name, + gpu_memory=gpu_memory, + gpu_vendor=AcceleratorVendor.NVIDIA.value, + spot=False, + disk_size=disk_size, + ) + ) + return offers + + +def _normalize_gpu(gpu_label: str) -> tuple[str, float] | None: + """Resolve a Seeweb ``gpu_label`` to a ``(canonical_name, memory_gib)`` pair.""" + label = " ".join(gpu_label.strip().split()) + # 1. Exact match against the curated map (with and without the NVIDIA prefix). + for key in ( + label.casefold(), + re.sub(r"^nvidia\s+", "", label, flags=re.IGNORECASE).casefold(), + ): + if key in SEEWEB_GPU_MAP: + return SEEWEB_GPU_MAP[key] + # 2. Fall back to gpuhunt's known-accelerator table. + cleaned = re.sub(r"^NVIDIA\s+", "", label.upper()).strip() + explicit_memory = re.search(r"(\d+)\s*GB", cleaned) + candidate = re.sub(r"\d+\s*GB", "", cleaned).strip().replace(" ", "") + if candidate: + accelerators = find_accelerators(names=[candidate], vendors=[AcceleratorVendor.NVIDIA]) + if accelerators: + info = accelerators[0] + memory = float(explicit_memory.group(1)) if explicit_memory else float(info.memory) + return info.name, memory + return None + + +def _parse_int(value: object) -> int: + match = re.search(r"\d+", str(value)) + if not match: + raise ValueError(f"invalid integer value {value!r}") + return int(match.group()) + + +def _parse_float(value: object) -> float: + match = re.search(r"\d+(?:\.\d+)?", str(value)) + if not match: + raise ValueError(f"invalid numeric value {value!r}") + return float(match.group()) diff --git a/src/tests/providers/test_seeweb.py b/src/tests/providers/test_seeweb.py new file mode 100644 index 0000000..fd1d936 --- /dev/null +++ b/src/tests/providers/test_seeweb.py @@ -0,0 +1,245 @@ +import pytest +import requests + +from gpuhunt.providers import seeweb as seeweb_module +from gpuhunt.providers.seeweb import SeewebProvider, _normalize_gpu + + +class FakeResponse: + def __init__(self, payload, error=None): + self._payload = payload + self._error = error + + def raise_for_status(self): + if self._error is not None: + raise self._error + + def json(self): + return self._payload + + +@pytest.fixture +def plans_payload() -> dict: + # Mirrors the live /plans shape: ram is in MB, gpu is a count string, gpu_label has a memory + # suffix, available_regions carries compatible region codes under "location". + regions = [{"location": "it-fr2"}, {"location": "it-mi2"}] + return { + "status": "ok", + "plans": [ + { + "name": "ECS1GPU7", + "cpu": "8", + "ram": "32768", + "disk": "500", + "gpu": "1", + "gpu_label": "L40s 48GB", + "hourly_price": 0.85, + "available": True, + "id": 7, + "host_type": "ECS", + "available_regions": regions, + }, + { + "name": "ECS2GPU3", + "cpu": "32", + "ram": "245760", + "disk": "2000", + "gpu": "2", + "gpu_label": "A100 80GB", + "hourly_price": 1.98, + "available": True, + "available_regions": [{"location": "it-fr2"}], + }, + # CPU-only plan (gpu == "0") must be skipped. + { + "name": "eCS4", + "cpu": "4", + "ram": "8192", + "disk": "160", + "gpu": "0", + "gpu_label": None, + "hourly_price": 0.063, + "available": True, + "available_regions": regions, + }, + # Non-NVIDIA accelerator must be skipped by the NVIDIA-only MVP. + { + "name": "ECS1GPU9", + "cpu": "32", + "ram": "262144", + "disk": "2000", + "gpu": "1", + "gpu_label": "MI300X", + "hourly_price": 1.6, + "available": True, + "available_regions": [{"location": "it-fr2"}], + }, + ], + } + + +def test_get_builds_gpu_offers_per_region(monkeypatch, plans_payload): + monkeypatch.setattr( + seeweb_module.requests, "get", lambda *a, **kw: FakeResponse(plans_payload) + ) + offers = SeewebProvider(token="dummy").get() + + # L40S in 2 regions + A100 in 1 region; CPU-only and MI300X plans skipped. + assert len(offers) == 3 + assert {o.instance_name for o in offers} == {"ECS1GPU7", "ECS2GPU3"} + + l40s = [o for o in offers if o.instance_name == "ECS1GPU7"] + assert {o.location for o in l40s} == {"it-fr2", "it-mi2"} + sample = l40s[0] + assert sample.gpu_name == "L40S" + assert sample.gpu_memory == 48 + assert sample.gpu_count == 1 + assert sample.cpu == 8 + assert sample.memory == 32.0 # 32768 MB -> 32 GiB + assert sample.disk_size == 500.0 + assert sample.gpu_vendor == "nvidia" + assert sample.spot is False + assert sample.provider_data == {} + + a100 = next(o for o in offers if o.instance_name == "ECS2GPU3") + assert a100.gpu_count == 2 + assert a100.gpu_name == "A100" + assert a100.gpu_memory == 80 + assert a100.memory == 240.0 # 245760 MB -> 240 GiB + + # Offers are sorted by ascending price. + prices = [o.price for o in offers] + assert prices == sorted(prices) + + +def test_get_sends_token_and_timeout(monkeypatch, plans_payload): + request = {} + + def fake_get(*args, **kwargs): + request["args"] = args + request["kwargs"] = kwargs + return FakeResponse(plans_payload) + + monkeypatch.setattr(seeweb_module.requests, "get", fake_get) + SeewebProvider(token="dummy").get() + + assert request["args"] == ("https://api.seeweb.it/ecs/v2/plans",) + assert request["kwargs"]["headers"] == {"X-APITOKEN": "dummy"} + assert request["kwargs"]["timeout"] == 30 + + +def test_token_defaults_to_environment(monkeypatch): + monkeypatch.setenv("SEEWEB_API_TOKEN", "from-env") + assert SeewebProvider().token == "from-env" + + +def test_missing_token_is_rejected(monkeypatch): + monkeypatch.delenv("SEEWEB_API_TOKEN", raising=False) + with pytest.raises(ValueError, match="SEEWEB_API_TOKEN"): + SeewebProvider() + + +def test_http_error_is_propagated(monkeypatch): + error = requests.HTTPError("unauthorized") + monkeypatch.setattr( + seeweb_module.requests, + "get", + lambda *args, **kwargs: FakeResponse({}, error=error), + ) + with pytest.raises(requests.HTTPError, match="unauthorized"): + SeewebProvider(token="dummy").get() + + +def test_unexpected_response_is_rejected(monkeypatch): + monkeypatch.setattr( + seeweb_module.requests, + "get", + lambda *args, **kwargs: FakeResponse({"plans": {}}), + ) + with pytest.raises(ValueError, match="Unexpected response"): + SeewebProvider(token="dummy").get() + + +def test_unknown_gpu_label_is_skipped(monkeypatch, caplog): + payload = { + "plans": [ + { + "name": "mystery", + "cpu": "8", + "ram": "32768", + "disk": "100", + "gpu": "1", + "gpu_label": "Totally Unknown GPU", + "hourly_price": 1.0, + "available_regions": [{"location": "it-fr2"}], + } + ] + } + monkeypatch.setattr(seeweb_module.requests, "get", lambda *a, **kw: FakeResponse(payload)) + assert SeewebProvider(token="dummy").get() == [] + assert "unknown gpu_label" in caplog.text + + +def test_inactive_malformed_and_duplicate_regions_are_excluded(monkeypatch, caplog): + base = { + "cpu": "8 cores", + "ram": "32768 MB", + "disk": "500 GB", + "gpu": "1 GPU", + "gpu_label": "NVIDIA L40s 48GB", + "hourly_price": "$0.85", + "available": True, + } + payload = { + "plans": [ + { + **base, + "name": "active", + "available_regions": [ + {"location": "it-fr2"}, + {"location": "it-fr2"}, + {"description": "missing code"}, + ], + }, + { + **base, + "name": "inactive", + "available": False, + "available_regions": [{"location": "it-fr2"}], + }, + { + **base, + "name": "bad-ram", + "ram": "unknown", + "available_regions": [{"location": "it-fr2"}], + }, + { + **base, + "name": "no-regions", + "available_regions": None, + }, + ] + } + monkeypatch.setattr(seeweb_module.requests, "get", lambda *a, **kw: FakeResponse(payload)) + + offers = SeewebProvider(token="dummy").get() + + assert [(offer.instance_name, offer.location) for offer in offers] == [("active", "it-fr2")] + assert offers[0].cpu == 8 + assert offers[0].memory == 32 + assert offers[0].disk_size == 500 + assert offers[0].price == 0.85 + assert "invalid numeric value" in caplog.text + assert "invalid available_regions" in caplog.text + + +def test_normalize_gpu_known_and_mapped(): + # Exact map hits (real Seeweb labels). + assert _normalize_gpu("A30") == ("A30", 24) + assert _normalize_gpu("L40s 48GB") == ("L40S", 48) + assert _normalize_gpu(" NVIDIA L40S 48GB ") == ("L40S", 48) + assert _normalize_gpu("RTX A6000 48GB") == ("A6000", 48) + assert _normalize_gpu("RTX PRO 6000 96GB") == ("RTXPRO6000", 96) + # find_accelerators fallback with explicit memory parsed from the label. + assert _normalize_gpu("H100 80GB") == ("H100", 80) + assert _normalize_gpu("definitely not a gpu") is None