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.
Files changed (46) hide show
  1. jambu_gpu/__init__.py +5 -0
  2. jambu_gpu/__main__.py +4 -0
  3. jambu_gpu/cli/__init__.py +0 -0
  4. jambu_gpu/cli/_common.py +117 -0
  5. jambu_gpu/cli/inspect.py +207 -0
  6. jambu_gpu/cli/lifecycle.py +127 -0
  7. jambu_gpu/cli/main.py +103 -0
  8. jambu_gpu/cli/providers.py +96 -0
  9. jambu_gpu/cli/run.py +37 -0
  10. jambu_gpu/cli/setup.py +165 -0
  11. jambu_gpu/cli/status.py +214 -0
  12. jambu_gpu/cli/validate.py +57 -0
  13. jambu_gpu/core/__init__.py +0 -0
  14. jambu_gpu/core/beacon.py +124 -0
  15. jambu_gpu/core/config.py +596 -0
  16. jambu_gpu/core/credentials.py +75 -0
  17. jambu_gpu/core/durations.py +47 -0
  18. jambu_gpu/core/engine.py +951 -0
  19. jambu_gpu/core/errors.py +68 -0
  20. jambu_gpu/core/events.py +115 -0
  21. jambu_gpu/core/execution.py +169 -0
  22. jambu_gpu/core/health.py +62 -0
  23. jambu_gpu/core/model_inspect.py +368 -0
  24. jambu_gpu/core/models.py +283 -0
  25. jambu_gpu/core/policy.py +233 -0
  26. jambu_gpu/core/state.py +361 -0
  27. jambu_gpu/core/tool_parsers.py +63 -0
  28. jambu_gpu/providers/__init__.py +4 -0
  29. jambu_gpu/providers/base.py +125 -0
  30. jambu_gpu/providers/registry.py +63 -0
  31. jambu_gpu/providers/vast/__init__.py +3 -0
  32. jambu_gpu/providers/vast/client.py +230 -0
  33. jambu_gpu/providers/vast/config.py +51 -0
  34. jambu_gpu/providers/vast/mapper.py +221 -0
  35. jambu_gpu/providers/vast/provider.py +422 -0
  36. jambu_gpu/runtime/__init__.py +6 -0
  37. jambu_gpu/runtime/base.py +99 -0
  38. jambu_gpu/runtime/vllm.py +224 -0
  39. jambu_gpu/watchdog/__init__.py +3 -0
  40. jambu_gpu/watchdog/agent_body.py +547 -0
  41. jambu_gpu/watchdog/builder.py +178 -0
  42. jambu_gpu-0.1.0.dist-info/METADATA +82 -0
  43. jambu_gpu-0.1.0.dist-info/RECORD +46 -0
  44. jambu_gpu-0.1.0.dist-info/WHEEL +4 -0
  45. jambu_gpu-0.1.0.dist-info/entry_points.txt +2 -0
  46. 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())
@@ -0,0 +1,6 @@
1
+ from .base import ModelRuntime, RuntimeRegistry, runtime_registry
2
+ from .vllm import VllmRuntime
3
+
4
+ runtime_registry.register(VllmRuntime)
5
+
6
+ __all__ = ["ModelRuntime", "RuntimeRegistry", "runtime_registry", "VllmRuntime"]