jambu-gpu 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jambu_gpu/__init__.py +5 -0
- jambu_gpu/__main__.py +4 -0
- jambu_gpu/cli/__init__.py +0 -0
- jambu_gpu/cli/_common.py +117 -0
- jambu_gpu/cli/inspect.py +207 -0
- jambu_gpu/cli/lifecycle.py +127 -0
- jambu_gpu/cli/main.py +103 -0
- jambu_gpu/cli/providers.py +96 -0
- jambu_gpu/cli/run.py +37 -0
- jambu_gpu/cli/setup.py +165 -0
- jambu_gpu/cli/status.py +214 -0
- jambu_gpu/cli/validate.py +57 -0
- jambu_gpu/core/__init__.py +0 -0
- jambu_gpu/core/beacon.py +124 -0
- jambu_gpu/core/config.py +596 -0
- jambu_gpu/core/credentials.py +75 -0
- jambu_gpu/core/durations.py +47 -0
- jambu_gpu/core/engine.py +951 -0
- jambu_gpu/core/errors.py +68 -0
- jambu_gpu/core/events.py +115 -0
- jambu_gpu/core/execution.py +169 -0
- jambu_gpu/core/health.py +62 -0
- jambu_gpu/core/model_inspect.py +368 -0
- jambu_gpu/core/models.py +283 -0
- jambu_gpu/core/policy.py +233 -0
- jambu_gpu/core/state.py +361 -0
- jambu_gpu/core/tool_parsers.py +63 -0
- jambu_gpu/providers/__init__.py +4 -0
- jambu_gpu/providers/base.py +125 -0
- jambu_gpu/providers/registry.py +63 -0
- jambu_gpu/providers/vast/__init__.py +3 -0
- jambu_gpu/providers/vast/client.py +230 -0
- jambu_gpu/providers/vast/config.py +51 -0
- jambu_gpu/providers/vast/mapper.py +221 -0
- jambu_gpu/providers/vast/provider.py +422 -0
- jambu_gpu/runtime/__init__.py +6 -0
- jambu_gpu/runtime/base.py +99 -0
- jambu_gpu/runtime/vllm.py +224 -0
- jambu_gpu/watchdog/__init__.py +3 -0
- jambu_gpu/watchdog/agent_body.py +547 -0
- jambu_gpu/watchdog/builder.py +178 -0
- jambu_gpu-0.1.0.dist-info/METADATA +82 -0
- jambu_gpu-0.1.0.dist-info/RECORD +46 -0
- jambu_gpu-0.1.0.dist-info/WHEEL +4 -0
- jambu_gpu-0.1.0.dist-info/entry_points.txt +2 -0
- jambu_gpu-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Translation between Vast.ai payloads and the normalized domain model.
|
|
2
|
+
|
|
3
|
+
Every Vast-specific field name, unit and status string is confined to this
|
|
4
|
+
module (spec section 21: "provider state mapping" belongs to the adapter).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from ...core.models import ComputeSpec, Endpoint, Instance, InstanceState, Offer
|
|
13
|
+
from .config import VastOptions
|
|
14
|
+
|
|
15
|
+
PROVIDER_NAME = "vast"
|
|
16
|
+
|
|
17
|
+
# Vast `actual_status` / `cur_state` -> normalized state (spec section 8).
|
|
18
|
+
_STATE_MAP = {
|
|
19
|
+
"created": InstanceState.PROVISIONING,
|
|
20
|
+
"scheduling": InstanceState.PROVISIONING,
|
|
21
|
+
"loading": InstanceState.STARTING,
|
|
22
|
+
"starting": InstanceState.STARTING,
|
|
23
|
+
"running": InstanceState.READY,
|
|
24
|
+
"stopping": InstanceState.STOPPED,
|
|
25
|
+
"stopped": InstanceState.STOPPED,
|
|
26
|
+
"exited": InstanceState.STOPPED,
|
|
27
|
+
"inactive": InstanceState.STOPPED,
|
|
28
|
+
"offline": InstanceState.UNKNOWN,
|
|
29
|
+
"error": InstanceState.FAILED,
|
|
30
|
+
"failed": InstanceState.FAILED,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def map_state(raw: dict[str, Any]) -> InstanceState:
|
|
35
|
+
"""Normalize Vast's several overlapping status fields into one state."""
|
|
36
|
+
actual = str(raw.get("actual_status") or "").lower()
|
|
37
|
+
cur = str(raw.get("cur_state") or "").lower()
|
|
38
|
+
intended = str(raw.get("intended_status") or "").lower()
|
|
39
|
+
status_msg = str(raw.get("status_msg") or "").lower()
|
|
40
|
+
|
|
41
|
+
if "error" in status_msg or "failed" in status_msg:
|
|
42
|
+
return InstanceState.FAILED
|
|
43
|
+
|
|
44
|
+
for value in (actual, cur):
|
|
45
|
+
if value in _STATE_MAP:
|
|
46
|
+
state = _STATE_MAP[value]
|
|
47
|
+
# Vast reports `running` while the container is still being pulled.
|
|
48
|
+
if state is InstanceState.READY and intended == "running":
|
|
49
|
+
if raw.get("start_date") and not raw.get("public_ipaddr"):
|
|
50
|
+
return InstanceState.STARTING
|
|
51
|
+
return state
|
|
52
|
+
|
|
53
|
+
if intended == "stopped":
|
|
54
|
+
return InstanceState.STOPPED
|
|
55
|
+
return InstanceState.UNKNOWN
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def vram_gb(value: Any) -> float:
|
|
59
|
+
"""Vast reports GPU RAM in MB on offers and instances; be tolerant anyway."""
|
|
60
|
+
try:
|
|
61
|
+
number = float(value or 0)
|
|
62
|
+
except (TypeError, ValueError):
|
|
63
|
+
return 0.0
|
|
64
|
+
return round(number / 1024.0, 1) if number > 512 else round(number, 1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def map_offer(raw: dict[str, Any]) -> Offer:
|
|
68
|
+
return Offer(
|
|
69
|
+
id=str(raw.get("id") or raw.get("ask_contract_id") or ""),
|
|
70
|
+
gpu_name=str(raw.get("gpu_name") or "unknown"),
|
|
71
|
+
gpu_count=int(raw.get("num_gpus") or 1),
|
|
72
|
+
gpu_vram_gb=vram_gb(raw.get("gpu_ram")),
|
|
73
|
+
disk_gb=float(raw.get("disk_space") or 0),
|
|
74
|
+
hourly_cost_usd=float(raw.get("dph_total") or 0.0),
|
|
75
|
+
region=raw.get("geolocation"),
|
|
76
|
+
score=float(raw.get("score") or 0.0) if raw.get("score") is not None else None,
|
|
77
|
+
raw=raw,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def extract_endpoints(raw: dict[str, Any], wanted: dict[int, str]) -> dict[str, Endpoint]:
|
|
82
|
+
"""Map internal container ports to their public address.
|
|
83
|
+
|
|
84
|
+
``wanted`` maps internal port -> endpoint name.
|
|
85
|
+
"""
|
|
86
|
+
host = raw.get("public_ipaddr") or raw.get("ssh_host") or ""
|
|
87
|
+
host = str(host).strip().rstrip("/")
|
|
88
|
+
ports = raw.get("ports") or {}
|
|
89
|
+
endpoints: dict[str, Endpoint] = {}
|
|
90
|
+
for internal, name in wanted.items():
|
|
91
|
+
binding = ports.get(f"{internal}/tcp") or ports.get(str(internal))
|
|
92
|
+
if not binding or not host:
|
|
93
|
+
continue
|
|
94
|
+
entry = binding[0] if isinstance(binding, list) else binding
|
|
95
|
+
external = entry.get("HostPort") if isinstance(entry, dict) else None
|
|
96
|
+
if not external:
|
|
97
|
+
continue
|
|
98
|
+
endpoints[name] = Endpoint(
|
|
99
|
+
name=name,
|
|
100
|
+
url=f"http://{host}:{external}",
|
|
101
|
+
internal_port=int(internal),
|
|
102
|
+
external_port=int(external),
|
|
103
|
+
)
|
|
104
|
+
return endpoints
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def map_instance(raw: dict[str, Any], wanted_ports: dict[int, str]) -> Instance:
|
|
108
|
+
created = raw.get("start_date") or raw.get("created_at")
|
|
109
|
+
try:
|
|
110
|
+
created_at = float(created) if created else time.time()
|
|
111
|
+
except (TypeError, ValueError):
|
|
112
|
+
created_at = time.time()
|
|
113
|
+
|
|
114
|
+
return Instance(
|
|
115
|
+
id=str(raw.get("id")),
|
|
116
|
+
provider=PROVIDER_NAME,
|
|
117
|
+
state=map_state(raw),
|
|
118
|
+
created_at=created_at,
|
|
119
|
+
gpu_name=str(raw.get("gpu_name") or ""),
|
|
120
|
+
gpu_count=int(raw.get("num_gpus") or 0),
|
|
121
|
+
hourly_cost_usd=float(raw.get("dph_total") or 0.0) or None,
|
|
122
|
+
host=raw.get("public_ipaddr"),
|
|
123
|
+
ssh_host=raw.get("ssh_host"),
|
|
124
|
+
ssh_port=int(raw["ssh_port"]) if raw.get("ssh_port") else None,
|
|
125
|
+
ssh_user="root",
|
|
126
|
+
endpoints=extract_endpoints(raw, wanted_ports),
|
|
127
|
+
label=str(raw.get("label") or ""),
|
|
128
|
+
raw=raw,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_search_query(
|
|
133
|
+
spec: ComputeSpec, options: VastOptions, include_vram: bool = True
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
"""Translate a normalized ComputeSpec into a Vast offer query."""
|
|
136
|
+
query: dict[str, Any] = {
|
|
137
|
+
"rentable": {"eq": True},
|
|
138
|
+
"rented": {"eq": False},
|
|
139
|
+
"external": {"eq": False},
|
|
140
|
+
"num_gpus": {"eq": int(spec.gpu.count)},
|
|
141
|
+
"disk_space": {"gte": float(spec.disk_gb)},
|
|
142
|
+
"type": "bid" if spec.spot else "on-demand",
|
|
143
|
+
"order": [[options.order_by, options.order_dir]],
|
|
144
|
+
"limit": int(options.search_limit),
|
|
145
|
+
}
|
|
146
|
+
if options.verified_only:
|
|
147
|
+
query["verified"] = {"eq": True}
|
|
148
|
+
if include_vram and spec.gpu.min_vram_gb:
|
|
149
|
+
# Vast stores GPU RAM in MB.
|
|
150
|
+
query["gpu_ram"] = {"gte": float(spec.gpu.min_vram_gb) * 1024.0}
|
|
151
|
+
if options.min_reliability:
|
|
152
|
+
query["reliability2"] = {"gte": float(options.min_reliability)}
|
|
153
|
+
if options.min_inet_down_mbps:
|
|
154
|
+
query["inet_down"] = {"gte": float(options.min_inet_down_mbps)}
|
|
155
|
+
if options.min_cuda:
|
|
156
|
+
query["cuda_max_good"] = {"gte": float(options.min_cuda)}
|
|
157
|
+
# gpu_name is deliberately NOT sent server-side: Vast's field only
|
|
158
|
+
# accepts an exact match ({"eq": ...}), but name_filter is documented and
|
|
159
|
+
# used everywhere else (offer_matches below) as a case-insensitive
|
|
160
|
+
# substring - "A6000" must match Vast's "RTX A6000". An exact-match
|
|
161
|
+
# server-side filter would silently return zero offers for any filter
|
|
162
|
+
# that isn't the full canonical name. offer_matches() below does the
|
|
163
|
+
# real (substring) filtering once results are back.
|
|
164
|
+
if spec.max_hourly_cost_usd:
|
|
165
|
+
query["dph_total"] = {"lte": float(spec.max_hourly_cost_usd)}
|
|
166
|
+
if spec.region:
|
|
167
|
+
query["geolocation"] = {"in": [r.strip() for r in str(spec.region).split(",")]}
|
|
168
|
+
if options.search_filters:
|
|
169
|
+
query.update(options.search_filters)
|
|
170
|
+
return query
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def build_env(spec: ComputeSpec, ports: list[int]) -> dict[str, str]:
|
|
174
|
+
"""Vast expects port publications as pseudo env keys (``-p 8000:8000``)."""
|
|
175
|
+
env: dict[str, str] = {str(k): str(v) for k, v in spec.env.items()}
|
|
176
|
+
for port in ports:
|
|
177
|
+
env[f"-p {port}:{port}"] = "1"
|
|
178
|
+
if ports:
|
|
179
|
+
env.setdefault("OPEN_BUTTON_PORT", str(ports[0]))
|
|
180
|
+
return env
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def build_create_payload(
|
|
184
|
+
spec: ComputeSpec,
|
|
185
|
+
options: VastOptions,
|
|
186
|
+
ports: list[int],
|
|
187
|
+
onstart: str,
|
|
188
|
+
price: Optional[float] = None,
|
|
189
|
+
) -> dict[str, Any]:
|
|
190
|
+
payload: dict[str, Any] = {
|
|
191
|
+
"client_id": "me",
|
|
192
|
+
"image": spec.image,
|
|
193
|
+
"disk": float(spec.disk_gb),
|
|
194
|
+
"label": spec.label,
|
|
195
|
+
"onstart": onstart,
|
|
196
|
+
"env": build_env(spec, ports),
|
|
197
|
+
"runtype": options.runtype,
|
|
198
|
+
"target_state": "running",
|
|
199
|
+
"cancel_unavail": True,
|
|
200
|
+
}
|
|
201
|
+
if options.image_login:
|
|
202
|
+
payload["image_login"] = options.image_login
|
|
203
|
+
if spec.spot and price:
|
|
204
|
+
payload["price"] = float(price)
|
|
205
|
+
return payload
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def offer_matches(offer: Offer, spec: ComputeSpec) -> bool:
|
|
209
|
+
"""Client-side guard: Vast's filters are advisory, verify what we got."""
|
|
210
|
+
if offer.gpu_count < spec.gpu.count:
|
|
211
|
+
return False
|
|
212
|
+
if spec.gpu.min_vram_gb and offer.gpu_vram_gb + 0.5 < float(spec.gpu.min_vram_gb):
|
|
213
|
+
return False
|
|
214
|
+
if offer.disk_gb and offer.disk_gb + 0.5 < float(spec.disk_gb):
|
|
215
|
+
return False
|
|
216
|
+
if spec.max_hourly_cost_usd and offer.hourly_cost_usd > float(spec.max_hourly_cost_usd):
|
|
217
|
+
return False
|
|
218
|
+
if spec.gpu.name_filter:
|
|
219
|
+
if spec.gpu.name_filter.lower() not in offer.gpu_name.lower():
|
|
220
|
+
return False
|
|
221
|
+
return True
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Vast.ai provider adapter.
|
|
2
|
+
|
|
3
|
+
Owns: authentication, credential declaration, API communication, GPU and
|
|
4
|
+
pricing discovery, instance create/start/stop/destroy, network + SSH config,
|
|
5
|
+
state mapping and error translation.
|
|
6
|
+
|
|
7
|
+
Owns nothing about idle policy, budgets, workload semantics, model config,
|
|
8
|
+
runtime selection or project configuration - those live in the core.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import shlex
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from typing import Any, Iterator, Optional
|
|
18
|
+
|
|
19
|
+
from ...core.config import RuntimeConfig
|
|
20
|
+
from ...core.credentials import CredentialResolver
|
|
21
|
+
from ...core.errors import (
|
|
22
|
+
CapacityUnavailableError,
|
|
23
|
+
ProviderError,
|
|
24
|
+
ProviderTimeoutError,
|
|
25
|
+
ProvisioningError,
|
|
26
|
+
)
|
|
27
|
+
from ...core.models import (
|
|
28
|
+
ComputeSpec,
|
|
29
|
+
Execution,
|
|
30
|
+
Instance,
|
|
31
|
+
InstanceState,
|
|
32
|
+
InstanceStatus,
|
|
33
|
+
LogStream,
|
|
34
|
+
Offer,
|
|
35
|
+
ProviderCapabilities,
|
|
36
|
+
ValidationResult,
|
|
37
|
+
Workload,
|
|
38
|
+
)
|
|
39
|
+
from ..base import GPUProvider, WatchdogSpec
|
|
40
|
+
from . import mapper
|
|
41
|
+
from .client import VastClient, VastNotFound
|
|
42
|
+
from .config import API_KEY_ENV, VastOptions
|
|
43
|
+
|
|
44
|
+
# Executed inside the remote watchdog; stdlib only, no package imports.
|
|
45
|
+
WATCHDOG_ACTIONS = '''
|
|
46
|
+
import json as _json
|
|
47
|
+
import os as _os
|
|
48
|
+
import time as _time
|
|
49
|
+
import urllib.error as _urlerror
|
|
50
|
+
import urllib.request as _urlrequest
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _vast_call(method, path, body=None):
|
|
54
|
+
base = _os.environ.get("VAST_API_BASE", "https://console.vast.ai/api/v0")
|
|
55
|
+
key = _os.environ.get("VAST_API_KEY", "")
|
|
56
|
+
if not key:
|
|
57
|
+
raise RuntimeError("VAST_API_KEY is not available on the instance")
|
|
58
|
+
payload = _json.dumps(body).encode() if body is not None else None
|
|
59
|
+
request = _urlrequest.Request(base.rstrip("/") + path, data=payload, method=method)
|
|
60
|
+
request.add_header("Authorization", "Bearer " + key)
|
|
61
|
+
request.add_header("Content-Type", "application/json")
|
|
62
|
+
request.add_header("Accept", "application/json")
|
|
63
|
+
last = None
|
|
64
|
+
for attempt in range(5):
|
|
65
|
+
try:
|
|
66
|
+
with _urlrequest.urlopen(request, timeout=30) as response:
|
|
67
|
+
raw = response.read().decode() or "{}"
|
|
68
|
+
try:
|
|
69
|
+
return _json.loads(raw)
|
|
70
|
+
except ValueError:
|
|
71
|
+
return {"raw": raw}
|
|
72
|
+
except _urlerror.HTTPError as exc:
|
|
73
|
+
if exc.code == 404:
|
|
74
|
+
return {"success": True, "already_gone": True}
|
|
75
|
+
last = exc
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
last = exc
|
|
78
|
+
_time.sleep(min(2 ** attempt, 20))
|
|
79
|
+
raise RuntimeError("vast.ai call failed after retries: %s" % last)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _vast_instance_id(ctx):
|
|
83
|
+
value = ctx.get("instance_id")
|
|
84
|
+
if value:
|
|
85
|
+
return str(value)
|
|
86
|
+
for name in ("VAST_CONTAINERLABEL", "CONTAINER_LABEL", "CONTAINER_ID"):
|
|
87
|
+
label = str(_os.environ.get(name) or "")
|
|
88
|
+
if label.startswith("C."):
|
|
89
|
+
label = label[2:]
|
|
90
|
+
if label.isdigit():
|
|
91
|
+
return label
|
|
92
|
+
raise RuntimeError("cannot determine the vast.ai instance id from inside the container")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def provider_stop(ctx):
|
|
96
|
+
_vast_call("PUT", "/instances/%s/" % _vast_instance_id(ctx), {"state": "stopped"})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def provider_destroy(ctx):
|
|
100
|
+
_vast_call("DELETE", "/instances/%s/" % _vast_instance_id(ctx))
|
|
101
|
+
'''
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class VastProvider(GPUProvider):
|
|
105
|
+
name = "vast"
|
|
106
|
+
display_name = "Vast.ai"
|
|
107
|
+
required_credentials = [API_KEY_ENV]
|
|
108
|
+
capabilities = ProviderCapabilities(
|
|
109
|
+
stop=True,
|
|
110
|
+
destroy=True,
|
|
111
|
+
persistent_disk=True,
|
|
112
|
+
spot_instances=True,
|
|
113
|
+
ssh=True,
|
|
114
|
+
public_ports=True,
|
|
115
|
+
gpu_selection=True,
|
|
116
|
+
pricing_query=True,
|
|
117
|
+
remote_exec=True,
|
|
118
|
+
logs=True,
|
|
119
|
+
# A stopped Vast instance no longer bills GPU time but still bills
|
|
120
|
+
# storage, so the core may choose to escalate to destroy.
|
|
121
|
+
stop_eliminates_billing=False,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def __init__(self, config: RuntimeConfig, credentials: CredentialResolver) -> None:
|
|
125
|
+
super().__init__(config, credentials)
|
|
126
|
+
self.options = VastOptions.from_mapping(config.provider.options)
|
|
127
|
+
self._client: Optional[VastClient] = None
|
|
128
|
+
|
|
129
|
+
# -- plumbing -----------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def client(self) -> VastClient:
|
|
133
|
+
if self._client is None:
|
|
134
|
+
key = self.credentials.get(API_KEY_ENV)
|
|
135
|
+
if not key:
|
|
136
|
+
from ...core.errors import AuthenticationError
|
|
137
|
+
|
|
138
|
+
raise AuthenticationError(
|
|
139
|
+
f"{API_KEY_ENV} is not set. Export it or add it to .env "
|
|
140
|
+
"(get one at https://cloud.vast.ai/account/)."
|
|
141
|
+
)
|
|
142
|
+
self._client = VastClient(key, api_base=self.options.api_base)
|
|
143
|
+
return self._client
|
|
144
|
+
|
|
145
|
+
def _wanted_ports(self) -> dict[int, str]:
|
|
146
|
+
ports: dict[int, str] = {}
|
|
147
|
+
if self.config.runtime.engine != "none":
|
|
148
|
+
ports[self.config.runtime.port] = "model"
|
|
149
|
+
if self.config.lifecycle.watchdog.enabled:
|
|
150
|
+
ports[self.config.lifecycle.watchdog.port] = "watchdog"
|
|
151
|
+
return ports
|
|
152
|
+
|
|
153
|
+
# -- contract -----------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def validate(self, config: RuntimeConfig) -> ValidationResult:
|
|
156
|
+
result = ValidationResult()
|
|
157
|
+
|
|
158
|
+
missing = self.credentials.missing(self.required_credentials)
|
|
159
|
+
if missing:
|
|
160
|
+
result.error(
|
|
161
|
+
f"missing credential(s): {', '.join(missing)}. "
|
|
162
|
+
f"Searched: {', '.join(self.credentials.describe_sources())}",
|
|
163
|
+
"credentials",
|
|
164
|
+
)
|
|
165
|
+
return result # never reach out to the API without a key
|
|
166
|
+
|
|
167
|
+
if config.compute.spot and not self.capabilities.spot_instances:
|
|
168
|
+
result.error("provider does not support spot instances", "compute.spot")
|
|
169
|
+
if config.lifecycle.auto_stop and not self.capabilities.stop:
|
|
170
|
+
result.error("provider cannot stop instances", "lifecycle.auto_stop")
|
|
171
|
+
if config.workload.mode == "remote" and not self.capabilities.remote_exec:
|
|
172
|
+
result.error("provider cannot execute remote workloads", "workload.mode")
|
|
173
|
+
if config.compute.gpu.count > 8:
|
|
174
|
+
result.warn("more than 8 GPUs on a single Vast machine is rare", "compute.gpu.count")
|
|
175
|
+
if not self.capabilities.stop_eliminates_billing and not config.lifecycle.auto_destroy:
|
|
176
|
+
result.warn(
|
|
177
|
+
"a stopped Vast instance still bills storage; set "
|
|
178
|
+
"lifecycle.auto_destroy: true to remove it entirely",
|
|
179
|
+
"lifecycle.auto_destroy",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
user = self.client.whoami()
|
|
184
|
+
except ProviderError as exc:
|
|
185
|
+
result.error(str(exc), "credentials")
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
balance = user.get("credit") or user.get("balance")
|
|
189
|
+
if balance is not None:
|
|
190
|
+
try:
|
|
191
|
+
if float(balance) <= 0:
|
|
192
|
+
result.error(f"vast.ai account has no credit (balance {balance})", "account")
|
|
193
|
+
elif float(balance) < 5:
|
|
194
|
+
result.warn(f"vast.ai balance is low ({balance})", "account")
|
|
195
|
+
except (TypeError, ValueError):
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
spec = self.build_spec_preview(config)
|
|
199
|
+
try:
|
|
200
|
+
offers = self.find_offers(spec, limit=5)
|
|
201
|
+
except ProviderError as exc:
|
|
202
|
+
result.warn(f"could not query offers: {exc}", "compute")
|
|
203
|
+
return result
|
|
204
|
+
if not offers:
|
|
205
|
+
result.error(
|
|
206
|
+
"no vast.ai offer matches compute.gpu "
|
|
207
|
+
f"(>= {config.compute.gpu.min_vram_gb} GB VRAM x {config.compute.gpu.count}, "
|
|
208
|
+
f"{config.compute.disk_gb} GB disk"
|
|
209
|
+
+ (
|
|
210
|
+
f", <= ${spec.max_hourly_cost_usd}/h"
|
|
211
|
+
if spec.max_hourly_cost_usd
|
|
212
|
+
else ""
|
|
213
|
+
)
|
|
214
|
+
+ ")",
|
|
215
|
+
"compute",
|
|
216
|
+
)
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
def build_spec_preview(self, config: RuntimeConfig) -> ComputeSpec:
|
|
220
|
+
"""A spec good enough for offer discovery during validation."""
|
|
221
|
+
from ...core.models import GpuSpec
|
|
222
|
+
|
|
223
|
+
return ComputeSpec(
|
|
224
|
+
gpu=GpuSpec(
|
|
225
|
+
min_vram_gb=config.compute.gpu.min_vram_gb,
|
|
226
|
+
count=config.compute.gpu.count,
|
|
227
|
+
name_filter=config.compute.gpu.name_filter,
|
|
228
|
+
cuda_min=config.compute.gpu.cuda_min,
|
|
229
|
+
),
|
|
230
|
+
disk_gb=config.compute.disk_gb,
|
|
231
|
+
spot=config.compute.spot,
|
|
232
|
+
region=config.compute.region,
|
|
233
|
+
max_hourly_cost_usd=config.budget.max_hourly_cost_usd,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def find_offers(self, spec: ComputeSpec, limit: int = 10) -> list[Offer]:
|
|
237
|
+
query = mapper.build_search_query(spec, self.options)
|
|
238
|
+
raw_offers = self.client.search_offers(query)
|
|
239
|
+
if not raw_offers and spec.gpu.min_vram_gb:
|
|
240
|
+
# Some Vast deployments report gpu_ram in GB; retry unfiltered.
|
|
241
|
+
query = mapper.build_search_query(spec, self.options, include_vram=False)
|
|
242
|
+
raw_offers = self.client.search_offers(query)
|
|
243
|
+
|
|
244
|
+
offers = [mapper.map_offer(raw) for raw in raw_offers]
|
|
245
|
+
offers = [o for o in offers if mapper.offer_matches(o, spec)]
|
|
246
|
+
offers.sort(key=lambda o: o.hourly_cost_usd)
|
|
247
|
+
return offers[:limit]
|
|
248
|
+
|
|
249
|
+
def setup(self, spec: ComputeSpec) -> Instance:
|
|
250
|
+
offers = self.find_offers(spec, limit=self.options.search_limit)
|
|
251
|
+
if not offers:
|
|
252
|
+
raise CapacityUnavailableError(
|
|
253
|
+
"no vast.ai capacity matches the requested compute spec "
|
|
254
|
+
f"({spec.gpu.count}x >= {spec.gpu.min_vram_gb} GB VRAM, "
|
|
255
|
+
f"{spec.disk_gb} GB disk)"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
ports = sorted(self._wanted_ports())
|
|
259
|
+
last_error: Optional[Exception] = None
|
|
260
|
+
|
|
261
|
+
# Offers go stale between search and create; walk the list.
|
|
262
|
+
for offer in offers[:5]:
|
|
263
|
+
payload = mapper.build_create_payload(
|
|
264
|
+
spec, self.options, ports, spec.onstart, price=offer.hourly_cost_usd
|
|
265
|
+
)
|
|
266
|
+
try:
|
|
267
|
+
created = self.client.create_instance(offer.id, payload)
|
|
268
|
+
except (CapacityUnavailableError, VastNotFound) as exc:
|
|
269
|
+
last_error = exc
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
instance_id = str(created.get("new_contract"))
|
|
273
|
+
instance = self._read_instance(instance_id)
|
|
274
|
+
if instance is None:
|
|
275
|
+
instance = Instance(
|
|
276
|
+
id=instance_id,
|
|
277
|
+
provider=self.name,
|
|
278
|
+
state=InstanceState.PROVISIONING,
|
|
279
|
+
gpu_name=offer.gpu_name,
|
|
280
|
+
gpu_count=offer.gpu_count,
|
|
281
|
+
hourly_cost_usd=offer.hourly_cost_usd,
|
|
282
|
+
label=spec.label,
|
|
283
|
+
)
|
|
284
|
+
instance.hourly_cost_usd = instance.hourly_cost_usd or offer.hourly_cost_usd
|
|
285
|
+
return instance
|
|
286
|
+
|
|
287
|
+
raise CapacityUnavailableError(
|
|
288
|
+
f"every matching vast.ai offer was taken before provisioning: {last_error}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def start(self, instance: Instance) -> Instance:
|
|
292
|
+
self.client.set_instance_state(instance.id, "running")
|
|
293
|
+
deadline = time.time() + 300
|
|
294
|
+
while time.time() < deadline:
|
|
295
|
+
current = self._read_instance(instance.id)
|
|
296
|
+
if current is None:
|
|
297
|
+
raise ProvisioningError(f"instance {instance.id} disappeared while starting")
|
|
298
|
+
if current.state in (InstanceState.READY, InstanceState.BUSY, InstanceState.IDLE):
|
|
299
|
+
return current
|
|
300
|
+
if current.state is InstanceState.FAILED:
|
|
301
|
+
raise ProvisioningError(
|
|
302
|
+
f"instance {instance.id} failed to start: "
|
|
303
|
+
f"{current.raw.get('status_msg', 'no detail')}"
|
|
304
|
+
)
|
|
305
|
+
time.sleep(5)
|
|
306
|
+
raise ProviderTimeoutError(f"instance {instance.id} did not start within 5 minutes")
|
|
307
|
+
|
|
308
|
+
def run(self, instance: Instance, workload: Workload) -> Execution:
|
|
309
|
+
"""Remote execution over SSH (capability: remote_exec)."""
|
|
310
|
+
self.require_capability("remote_exec")
|
|
311
|
+
if not instance.ssh_host or not instance.ssh_port:
|
|
312
|
+
raise ProviderError(
|
|
313
|
+
f"instance {instance.id} has no SSH endpoint yet; retry once it is running"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
inner = " ".join(shlex.quote(part) for part in workload.command)
|
|
317
|
+
if workload.cwd:
|
|
318
|
+
inner = f"cd {shlex.quote(workload.cwd)} && {inner}"
|
|
319
|
+
exports = " ".join(
|
|
320
|
+
f"export {key}={shlex.quote(str(value))};" for key, value in workload.env.items()
|
|
321
|
+
)
|
|
322
|
+
remote_command = f"{exports} {inner}".strip()
|
|
323
|
+
|
|
324
|
+
ssh_command = [
|
|
325
|
+
"ssh",
|
|
326
|
+
"-p",
|
|
327
|
+
str(instance.ssh_port),
|
|
328
|
+
"-o",
|
|
329
|
+
"StrictHostKeyChecking=accept-new",
|
|
330
|
+
"-o",
|
|
331
|
+
"UserKnownHostsFile=/dev/null",
|
|
332
|
+
"-o",
|
|
333
|
+
"LogLevel=ERROR",
|
|
334
|
+
f"{instance.ssh_user}@{instance.ssh_host}",
|
|
335
|
+
remote_command,
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
execution = Execution(
|
|
339
|
+
id=uuid.uuid4().hex[:12], workload_id=workload.id, started_at=time.time()
|
|
340
|
+
)
|
|
341
|
+
process = subprocess.run(ssh_command, check=False)
|
|
342
|
+
execution.finished_at = time.time()
|
|
343
|
+
execution.returncode = process.returncode
|
|
344
|
+
execution.state = "finished" if process.returncode == 0 else "failed"
|
|
345
|
+
return execution
|
|
346
|
+
|
|
347
|
+
def status(self, instance: Instance) -> InstanceStatus:
|
|
348
|
+
raw = self.client.get_instance(instance.id)
|
|
349
|
+
if raw is None:
|
|
350
|
+
return InstanceStatus(
|
|
351
|
+
state=InstanceState.DESTROYED,
|
|
352
|
+
instance=None,
|
|
353
|
+
exists=False,
|
|
354
|
+
message="instance no longer exists on vast.ai",
|
|
355
|
+
)
|
|
356
|
+
current = mapper.map_instance(raw, self._wanted_ports())
|
|
357
|
+
return InstanceStatus(
|
|
358
|
+
state=current.state,
|
|
359
|
+
instance=current,
|
|
360
|
+
exists=True,
|
|
361
|
+
hourly_cost_usd=current.hourly_cost_usd,
|
|
362
|
+
message=str(raw.get("status_msg") or "").strip(),
|
|
363
|
+
raw=raw,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def stop(self, instance: Instance) -> None:
|
|
367
|
+
try:
|
|
368
|
+
self.client.set_instance_state(instance.id, "stopped")
|
|
369
|
+
except VastNotFound:
|
|
370
|
+
return # already gone: stopping is idempotent
|
|
371
|
+
except ProviderError as exc:
|
|
372
|
+
message = str(exc).lower()
|
|
373
|
+
if "not running" in message or "already" in message:
|
|
374
|
+
return
|
|
375
|
+
raise
|
|
376
|
+
|
|
377
|
+
def destroy(self, instance: Instance) -> None:
|
|
378
|
+
try:
|
|
379
|
+
self.client.destroy_instance(instance.id)
|
|
380
|
+
except VastNotFound:
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
def logs(self, instance: Instance) -> LogStream:
|
|
384
|
+
url = self.client.request_logs(instance.id, tail=800)
|
|
385
|
+
if not url:
|
|
386
|
+
return LogStream(iter(()), source="vast")
|
|
387
|
+
|
|
388
|
+
def _lines() -> Iterator[str]:
|
|
389
|
+
# Vast uploads the log asynchronously; give it a few seconds.
|
|
390
|
+
for attempt in range(8):
|
|
391
|
+
try:
|
|
392
|
+
text = self.client.fetch_url(url)
|
|
393
|
+
except ProviderError:
|
|
394
|
+
text = ""
|
|
395
|
+
if text:
|
|
396
|
+
yield from text.splitlines()
|
|
397
|
+
return
|
|
398
|
+
time.sleep(1.5 * (attempt + 1) / 2)
|
|
399
|
+
|
|
400
|
+
return LogStream(_lines(), source=f"vast:{instance.id}")
|
|
401
|
+
|
|
402
|
+
def refresh(self, instance: Instance) -> Instance:
|
|
403
|
+
current = self._read_instance(instance.id)
|
|
404
|
+
return current or instance
|
|
405
|
+
|
|
406
|
+
# -- watchdog -----------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
def watchdog_spec(self, spec_or_instance: Any = None) -> Optional[WatchdogSpec]:
|
|
409
|
+
key = self.credentials.get(API_KEY_ENV) or ""
|
|
410
|
+
return WatchdogSpec(
|
|
411
|
+
actions_source=WATCHDOG_ACTIONS,
|
|
412
|
+
env={"VAST_API_KEY": key, "VAST_API_BASE": self.options.api_base},
|
|
413
|
+
context={"provider": self.name},
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# -- internals ----------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
def _read_instance(self, instance_id: str) -> Optional[Instance]:
|
|
419
|
+
raw = self.client.get_instance(instance_id)
|
|
420
|
+
if raw is None:
|
|
421
|
+
return None
|
|
422
|
+
return mapper.map_instance(raw, self._wanted_ports())
|