"""RunPod und Vast.ai - mieten, nachsehen, loeschen. Erfahrungen aus einem frueheren Projekt, dort jede Falle einmal bezahlt: - RunPod-GraphQL ohne User-Agent: Cloudflare `error code: 1010`. - GraphQL antwortet auch bei Fehlern mit HTTP 200, der Fehler steht in `errors`. - Schluessel als Kopfzeile, nie als ?api_key= (landet sonst in Protokollen). - `desiredStatus: RUNNING` mit `runtime: null`: bezahlt, Container nie gestartet. - `podTerminate` liefert null statt Quittung, der Pod lief 12 s spaeter noch. Loeschen heisst darum: Befehl in Schleife, dann **nachsehen**. - `gpuTypes` ist das Sortiment, nicht der Bestand - frei ist nur, was `dataCenters { gpuAvailability }` meldet. - Feste 80 GB Platte/32 GB RAM verhindern Mieten auf kleinen Wirten; der Bedarf waechst mit den Gewichten. - Vast: Mining-Karten (cmp) und Altkarten aussortieren; angegebene Leitung ist unzuverlaessig. """ import json import time from typing import Any import httpx from . import konfig GRAPHQL = "https://api.runpod.io/graphql" VAST_URL = "https://console.vast.ai" ABBILD = "ollama/ollama:latest" # Speicherbandbreite in GB/s (Herstellerangaben) - sie bestimmt das Tempo # eines Sprachmodells, nicht die Rechenleistung. Vast liefert sie je Angebot. BANDBREITE_GBS: dict[str, float] = { "A40": 696, "RTX A6000": 768, "L40": 864, "L40S": 864, "RTX 6000 Ada": 960, "RTX PRO 5000": 1344, "RTX PRO 6000": 1792, "A100 PCIe": 1935, "A100 SXM": 2039, "H100 PCIe": 2000, "H100 SXM": 3350, "H100 NVL": 3900, "H200 SXM": 4800, "B200": 8000, "RTX 5090": 1792, "RTX 5080": 960, "RTX 4090": 1008, "RTX 4080": 717, "RTX 4080 SUPER": 736, "RTX 3090": 936, "RTX 3090 Ti": 1008, "RTX A5000": 768, "RTX A4500": 640, "RTX A4000": 448, "RTX 4000 Ada": 360, "RTX 2000 Ada": 224, "L4": 300, "A30": 933, "Tesla V100": 900, } BESTAND_RANG = {"High": 3, "Medium": 2, "Low": 1} BESTAND_WORT = {"High": "viel frei", "Medium": "mittel", "Low": "knapp"} class Fehler(Exception): pass # ------------------------------------------------------------------ RunPod def _gq(abfrage: str) -> dict[str, Any]: if not konfig.RUNPOD_API_KEY: return {"fehler": "kein RUNPOD_API_KEY"} try: a = httpx.post(GRAPHQL, json={"query": abfrage}, timeout=60, headers={ "Content-Type": "application/json", "User-Agent": konfig.KENNUNG, "Authorization": f"Bearer {konfig.RUNPOD_API_KEY}"}) except httpx.HTTPError as f: return {"fehler": f"{f.__class__.__name__}: {f}"} if a.status_code != 200: return {"fehler": a.text[:300]} d = a.json() if d.get("errors"): return {"fehler": json.dumps(d["errors"])[:300], **d} return d def wirt_bedarf(gb: float) -> tuple[int, int, int]: platte = min(120, max(25, int(gb * 1.5) + 15)) kerne = 4 if gb < 20 else 8 ram = max(16, min(64, int(gb) + 8)) return platte, kerne, ram def runpod_angebote(m: dict[str, Any]) -> list[dict[str, Any]]: if not konfig.RUNPOD_API_KEY: return [] lage: dict[str, str] = {} a = _gq("{ dataCenters { id gpuAvailability { gpuTypeId available stockStatus } } }") for dc in ((a.get("data") or {}).get("dataCenters") or []): for g in (dc.get("gpuAvailability") or []): if not g.get("available"): continue k, s = g.get("gpuTypeId"), g.get("stockStatus") or "Low" if BESTAND_RANG.get(s, 0) > BESTAND_RANG.get(lage.get(k, ""), 0): lage[k] = s typen = _gq("{ gpuTypes { id displayName memoryInGb communityPrice " "securePrice communityCloud secureCloud } }") typen = (typen.get("data") or {}).get("gpuTypes") or [] aus: list[dict[str, Any]] = [] for wolke, feld, flagge in (("SECURE", "securePrice", "secureCloud"), ("COMMUNITY", "communityPrice", "communityCloud")): liste = sorted((t for t in typen if (t.get("memoryInGb") or 0) >= m["vram"] and t.get(flagge) and t.get(feld) and t["id"] in lage # AMD (MI300X) laeuft mit dem CUDA-Abbild nicht and not str(t["displayName"]).upper().startswith("MI")), key=lambda t: t[feld])[:6] for t in liste: aus.append({ "anbieter": "runpod", "id": str(t["id"]), "wolke": wolke, "name": t["displayName"], "gb": t["memoryInGb"], "preis": round(float(t[feld]), 4), "lage": BESTAND_WORT.get(lage[t["id"]], "knapp"), "bandbreite": BANDBREITE_GBS.get(t["displayName"]), "anzahl": 1, "inet_down": None, "inet_down_cost": 0, }) return aus def runpod_mieten(karte: dict[str, Any], m: dict[str, Any], sparsam: bool = False) -> tuple[str | None, str | None]: """(Pod-Kennung, Fehlertext von RunPod). Der Text war bis 16.10.2026 verschluckt - "nicht mietbar" ohne Grund hilft niemandem. `sparsam`: nur das Noetigste an RAM und Kernen. Am 16.10.2026 lehnte RunPod eine RTX 4090 zweimal mit SUPPLY_CONSTRAINT ab ("no longer any instances available with the requested specifications") - bei 26 GB RAM und 4 Kernen. Ollama legt die Gewichte in den Grafikspeicher, der Arbeitsspeicher wird kaum gebraucht.""" platte, kerne, ram = wirt_bedarf(m["gb"]) if sparsam: kerne, ram = 2, 15 a = _gq('''mutation { podFindAndDeployOnDemand(input:{ cloudType: %s, gpuCount: 1, volumeInGb: 0, containerDiskInGb: %d, minVcpuCount: %d, minMemoryInGb: %d, gpuTypeId: "%s", name: "%s", imageName: "%s", ports: "11434/http", env: [{key:"OLLAMA_HOST", value:"0.0.0.0"}, {key:"OLLAMA_KEEP_ALIVE", value:"-1"}, {key:"OLLAMA_CONTEXT_LENGTH", value:"%d"}] }) { id costPerHr } }''' % (karte["wolke"], platte, kerne, ram, karte["id"], konfig.LABEL, ABBILD, m["kontext"])) d = (a.get("data") or {}).get("podFindAndDeployOnDemand") if d and d.get("id"): return d["id"], None return None, a.get("fehler") or "keine Kennung zurückbekommen" def runpod_laeuft(pod_id: str) -> bool: """Container wirklich gestartet? `runtime` allein genuegt nicht - waehrend das Abbild gezogen wird, steht dort schon {"uptimeInSeconds": 0}.""" a = _gq('{ pod(input:{podId:"%s"}) { runtime { uptimeInSeconds ' 'ports { privatePort publicPort } } } }' % pod_id) r = ((a.get("data") or {}).get("pod") or {}).get("runtime") return bool(r and (r.get("ports") or (r.get("uptimeInSeconds") or 0) > 0)) def runpod_existiert(pod_id: str) -> bool: a = _gq('{ pod(input:{podId:"%s"}) { id } }' % pod_id) if a.get("fehler"): return True # im Zweifel laeuft er noch return bool((a.get("data") or {}).get("pod")) def runpod_loeschen(pod_id: str, frist_s: int = 120) -> bool: ende = time.monotonic() + frist_s while time.monotonic() < ende: _gq('mutation { podTerminate(input:{podId:"%s"}) }' % pod_id) for _ in range(6): time.sleep(5) if not runpod_existiert(pod_id): return True return False def runpod_eigene() -> list[dict[str, Any]] | None: """Pods mit unserem Label. None = Abfrage gescheitert (nicht: keine).""" if not konfig.RUNPOD_API_KEY: return [] a = _gq("{ myself { pods { id name desiredStatus costPerHr } } }") if a.get("fehler"): return None pods = ((a.get("data") or {}).get("myself") or {}).get("pods") or [] return [p for p in pods if p.get("name") == konfig.LABEL] def runpod_guthaben() -> float | None: a = _gq("{ myself { clientBalance } }") try: return float(a["data"]["myself"]["clientBalance"]) except (KeyError, TypeError, ValueError): return None # -------------------------------------------------------------------- Vast def _vast(method: str, pfad: str, daten: dict | None = None, abfrage: dict | None = None, timeout: float = 120) -> dict[str, Any]: if not konfig.VAST_API_KEY: return {"fehler": "kein VAST_API_KEY"} if not pfad.startswith("/api/v"): pfad = "/api/v0" + pfad params = ({k: v if isinstance(v, str) else json.dumps(v) for k, v in abfrage.items()} if abfrage else None) try: a = httpx.request(method, f"{VAST_URL}{pfad}", params=params, timeout=timeout, json=daten if daten is not None else ( {} if method in ("POST", "PUT", "DELETE") else None), headers={"Authorization": f"Bearer {konfig.VAST_API_KEY}", "User-Agent": konfig.KENNUNG}) except httpx.HTTPError as f: return {"fehler": f"{f.__class__.__name__}: {f}"} if a.status_code >= 400: return {"fehler": a.text[:300], "status": a.status_code} try: return a.json() except ValueError: return {"fehler": a.text[:300]} def vast_platte(m: dict[str, Any]) -> int: return min(900, max(25, int(m["gb"] * 1.35) + 30)) def vast_angebote(m: dict[str, Any]) -> list[dict[str, Any]]: if not konfig.VAST_API_KEY: return [] d = _vast("POST", "/bundles/", { "verified": {"eq": True}, "external": {"eq": False}, "rentable": {"eq": True}, "rented": {"eq": False}, "direct_port_count": {"gte": 1}, "cuda_max_good": {"gte": 12.4}, "compute_cap": {"gte": 750}, "gpu_total_ram": {"gte": int(m["vram"] * 1024)}, "inet_down": {"gte": 400}, "type": "on-demand", "order": [["dph_total", "asc"]], "limit": 12, "allocated_storage": vast_platte(m), }) alt = ("p100", "titan", "gtx 10", "gtx 9", "cmp") aus = [] for a in d.get("offers") or []: if any(n in str(a.get("gpu_name", "")).lower() for n in alt): continue aus.append({ "anbieter": "vast", "id": str(a["id"]), "wolke": "VAST", "name": f"{a.get('num_gpus') or 1}x {a.get('gpu_name')}", "gb": round((a.get("gpu_total_ram") or 0) / 1024), "preis": round(float(a.get("dph_total") or 0), 4), "lage": a.get("geolocation") or "Vast.ai", "bandbreite": a.get("gpu_mem_bw"), "anzahl": int(a.get("num_gpus") or 1), "inet_down": a.get("inet_down"), "inet_down_cost": a.get("inet_down_cost") or 0, }) return aus def vast_mieten(karte: dict[str, Any], m: dict[str, Any]) -> tuple[str | None, str | None]: d = _vast("PUT", f"/asks/{karte['id']}/", { "client_id": "me", "image": ABBILD, "disk": vast_platte(m), "label": konfig.LABEL, "runtype": "args", "env": {"OLLAMA_HOST": "0.0.0.0:11434", "OLLAMA_KEEP_ALIVE": "-1", "OLLAMA_CONTEXT_LENGTH": str(m["kontext"]), "-p 11434:11434": "1"}, "args": None, "force": False, "cancel_unavail": True, }, timeout=180) if d.get("success") and d.get("new_contract"): return str(d["new_contract"]), None return None, d.get("fehler") or d.get("msg") or d.get("error") or json.dumps(d)[:300] def vast_instanz(iid: str) -> dict | None | bool: """Instanz, None wenn es sie nicht gibt, False wenn die Abfrage scheiterte.""" d = _vast("GET", f"/instances/{iid}/", abfrage={"owner": "me"}) if d.get("fehler"): return None if d.get("status") == 404 else False return d.get("instances") or None def vast_existiert(iid: str) -> bool: return vast_instanz(iid) is not None # False (Abfragefehler) zaehlt als "da" def vast_loeschen(iid: str, frist_s: int = 120) -> bool: ende = time.monotonic() + frist_s while time.monotonic() < ende: _vast("DELETE", f"/instances/{iid}/") for _ in range(6): time.sleep(5) if not vast_existiert(iid): return True return False def vast_basen(inst: dict) -> list[str]: ports = inst.get("ports") or {} kandidaten = [p.get("HostPort") for p in (ports.get("11434/tcp") or []) if p.get("HostPort")] kandidaten += [inst.get("direct_port_end"), inst.get("direct_port_start")] basen: list[str] = [] for host in (inst.get("public_ipaddr"), inst.get("ssh_host")): for port in kandidaten: if host and port: b = f"http://{str(host).strip()}:{port}" if b not in basen: basen.append(b) return basen def vast_eigene() -> list[dict[str, Any]] | None: if not konfig.VAST_API_KEY: return [] d = _vast("GET", "/api/v1/instances/", abfrage={ "select_filters": {"label": {"eq": konfig.LABEL}}, "order_by": [{"col": "id", "dir": "asc"}], "limit": 25}) if d.get("fehler"): return None return [i for i in (d.get("instances") or []) if i.get("label") == konfig.LABEL] def vast_guthaben() -> float | None: d = _vast("GET", "/users/current/") try: return float(d["credit"]) except (KeyError, TypeError, ValueError): return None # ---------------------------------------------------------- gemeinsam def loeschen(anbieter: str, kennung: str) -> bool: return (vast_loeschen(kennung) if anbieter == "vast" else runpod_loeschen(kennung)) def existiert(anbieter: str, kennung: str) -> bool: return (vast_existiert(kennung) if anbieter == "vast" else runpod_existiert(kennung)) def bewerten(k: dict[str, Any], gb: float, mb_s_runpod: float = 150) -> None: """Tempo je Dollar der ersten Stunde; Ladezeit und Downloadgebuehr zaehlen mit.""" if k["anbieter"] == "vast" and k.get("inet_down"): mb_s = min(k["inet_down"], 2500) / 8 * 0.5 else: mb_s = mb_s_runpod k["ladezeit_s"] = int(100 + gb * 1000 / max(mb_s, 1)) k["download_usd"] = round((k.get("inet_down_cost") or 0) * gb, 4) bw = k.get("bandbreite") if not bw or not k["preis"]: k["wert"] = None return wirksam = bw * 0.9 ** max(0, k["anzahl"] - 1) erste = k["preis"] * (1 + k["ladezeit_s"] / 3600) + k["download_usd"] k["bandbreite"] = round(wirksam) k["wert"] = round(wirksam / erste) def angebote(m: dict[str, Any]) -> list[dict[str, Any]]: liste = runpod_angebote(m) + vast_angebote(m) for k in liste: bewerten(k, m["gb"]) liste.sort(key=lambda x: (x["wert"] is None, -(x["wert"] or 0), x["preis"], 0 if x["anbieter"] == "runpod" else 1)) return liste