gpu4agents 0.5.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.
- gpu4agents/__init__.py +5 -0
- gpu4agents/__main__.py +4 -0
- gpu4agents/agent/__init__.py +1 -0
- gpu4agents/agent/install.py +173 -0
- gpu4agents/agent/jobs.py +193 -0
- gpu4agents/agent/mcp.py +646 -0
- gpu4agents/agent/skill/SKILL.md +68 -0
- gpu4agents/cli/__init__.py +0 -0
- gpu4agents/cli/_common.py +131 -0
- gpu4agents/cli/account.py +330 -0
- gpu4agents/cli/agent.py +74 -0
- gpu4agents/cli/inspect.py +207 -0
- gpu4agents/cli/lifecycle.py +127 -0
- gpu4agents/cli/main.py +163 -0
- gpu4agents/cli/providers.py +96 -0
- gpu4agents/cli/run.py +38 -0
- gpu4agents/cli/setup.py +164 -0
- gpu4agents/cli/status.py +224 -0
- gpu4agents/cli/timer.py +143 -0
- gpu4agents/cli/validate.py +57 -0
- gpu4agents/core/__init__.py +0 -0
- gpu4agents/core/beacon.py +133 -0
- gpu4agents/core/config.py +647 -0
- gpu4agents/core/credentials.py +95 -0
- gpu4agents/core/durations.py +47 -0
- gpu4agents/core/engine.py +1335 -0
- gpu4agents/core/errors.py +94 -0
- gpu4agents/core/events.py +117 -0
- gpu4agents/core/execution.py +169 -0
- gpu4agents/core/health.py +62 -0
- gpu4agents/core/hosts.py +58 -0
- gpu4agents/core/model_inspect.py +368 -0
- gpu4agents/core/models.py +313 -0
- gpu4agents/core/paths.py +42 -0
- gpu4agents/core/policy.py +264 -0
- gpu4agents/core/state.py +406 -0
- gpu4agents/core/sweep.py +478 -0
- gpu4agents/core/tool_parsers.py +63 -0
- gpu4agents/providers/__init__.py +4 -0
- gpu4agents/providers/base.py +137 -0
- gpu4agents/providers/registry.py +63 -0
- gpu4agents/providers/vast/__init__.py +3 -0
- gpu4agents/providers/vast/client.py +255 -0
- gpu4agents/providers/vast/config.py +56 -0
- gpu4agents/providers/vast/mapper.py +292 -0
- gpu4agents/providers/vast/provider.py +438 -0
- gpu4agents/runtime/__init__.py +6 -0
- gpu4agents/runtime/base.py +184 -0
- gpu4agents/runtime/vllm.py +295 -0
- gpu4agents/watchdog/__init__.py +3 -0
- gpu4agents/watchdog/agent_body.py +959 -0
- gpu4agents/watchdog/builder.py +248 -0
- gpu4agents-0.5.0.dist-info/METADATA +157 -0
- gpu4agents-0.5.0.dist-info/RECORD +59 -0
- gpu4agents-0.5.0.dist-info/WHEEL +4 -0
- gpu4agents-0.5.0.dist-info/entry_points.txt +2 -0
- gpu4agents-0.5.0.dist-info/licenses/LICENSE +21 -0
- jambu_gpu/__init__.py +9 -0
- jambu_gpu/__main__.py +4 -0
|
@@ -0,0 +1,438 @@
|
|
|
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
|
+
is_jambu_label,
|
|
39
|
+
)
|
|
40
|
+
from ..base import GPUProvider, WatchdogSpec
|
|
41
|
+
from . import mapper
|
|
42
|
+
from .client import VastClient, VastNotFound
|
|
43
|
+
from .config import API_KEY_ENV, ONSTART_MAX_BYTES, VastOptions
|
|
44
|
+
|
|
45
|
+
# Executed inside the remote watchdog; stdlib only, no package imports.
|
|
46
|
+
WATCHDOG_ACTIONS = '''
|
|
47
|
+
import json as _json
|
|
48
|
+
import os as _os
|
|
49
|
+
import time as _time
|
|
50
|
+
import urllib.error as _urlerror
|
|
51
|
+
import urllib.request as _urlrequest
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _vast_call(method, path, body=None):
|
|
55
|
+
base = _os.environ.get("VAST_API_BASE", "https://console.vast.ai/api/v0")
|
|
56
|
+
key = _os.environ.get("VAST_API_KEY", "")
|
|
57
|
+
if not key:
|
|
58
|
+
raise RuntimeError("VAST_API_KEY is not available on the instance")
|
|
59
|
+
payload = _json.dumps(body).encode() if body is not None else None
|
|
60
|
+
request = _urlrequest.Request(base.rstrip("/") + path, data=payload, method=method)
|
|
61
|
+
request.add_header("Authorization", "Bearer " + key)
|
|
62
|
+
request.add_header("Content-Type", "application/json")
|
|
63
|
+
request.add_header("Accept", "application/json")
|
|
64
|
+
last = None
|
|
65
|
+
for attempt in range(5):
|
|
66
|
+
try:
|
|
67
|
+
with _urlrequest.urlopen(request, timeout=30) as response:
|
|
68
|
+
raw = response.read().decode() or "{}"
|
|
69
|
+
try:
|
|
70
|
+
return _json.loads(raw)
|
|
71
|
+
except ValueError:
|
|
72
|
+
return {"raw": raw}
|
|
73
|
+
except _urlerror.HTTPError as exc:
|
|
74
|
+
if exc.code == 404:
|
|
75
|
+
return {"success": True, "already_gone": True}
|
|
76
|
+
last = exc
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
last = exc
|
|
79
|
+
_time.sleep(min(2 ** attempt, 20))
|
|
80
|
+
raise RuntimeError("vast.ai call failed after retries: %s" % last)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _vast_instance_id(ctx):
|
|
84
|
+
value = ctx.get("instance_id")
|
|
85
|
+
if value:
|
|
86
|
+
return str(value)
|
|
87
|
+
for name in ("VAST_CONTAINERLABEL", "CONTAINER_LABEL", "CONTAINER_ID"):
|
|
88
|
+
label = str(_os.environ.get(name) or "")
|
|
89
|
+
if label.startswith("C."):
|
|
90
|
+
label = label[2:]
|
|
91
|
+
if label.isdigit():
|
|
92
|
+
return label
|
|
93
|
+
raise RuntimeError("cannot determine the vast.ai instance id from inside the container")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def provider_stop(ctx):
|
|
97
|
+
_vast_call("PUT", "/instances/%s/" % _vast_instance_id(ctx), {"state": "stopped"})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def provider_destroy(ctx):
|
|
101
|
+
_vast_call("DELETE", "/instances/%s/" % _vast_instance_id(ctx))
|
|
102
|
+
'''
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class VastProvider(GPUProvider):
|
|
106
|
+
name = "vast"
|
|
107
|
+
display_name = "Vast.ai"
|
|
108
|
+
required_credentials = [API_KEY_ENV]
|
|
109
|
+
capabilities = ProviderCapabilities(
|
|
110
|
+
stop=True,
|
|
111
|
+
destroy=True,
|
|
112
|
+
persistent_disk=True,
|
|
113
|
+
spot_instances=True,
|
|
114
|
+
ssh=True,
|
|
115
|
+
public_ports=True,
|
|
116
|
+
gpu_selection=True,
|
|
117
|
+
pricing_query=True,
|
|
118
|
+
remote_exec=True,
|
|
119
|
+
logs=True,
|
|
120
|
+
list_instances=True,
|
|
121
|
+
# A stopped Vast instance no longer bills GPU time but still bills
|
|
122
|
+
# storage, so the core may choose to escalate to destroy.
|
|
123
|
+
stop_eliminates_billing=False,
|
|
124
|
+
)
|
|
125
|
+
# The same vLLM as vllm/vllm-openai:v0.32.0, packaged by Vast and cached
|
|
126
|
+
# on many hosts. The Docker Hub image (~12 GB) stalled past the 30-minute
|
|
127
|
+
# boot timeout on several hosts in live runs; this one is usually already on the host.
|
|
128
|
+
preferred_images = {"vllm": "vastai/vllm:v0.32.0-cuda-13.0"}
|
|
129
|
+
|
|
130
|
+
def __init__(self, config: RuntimeConfig, credentials: CredentialResolver) -> None:
|
|
131
|
+
super().__init__(config, credentials)
|
|
132
|
+
self.options = VastOptions.from_mapping(config.provider.options)
|
|
133
|
+
self._client: Optional[VastClient] = None
|
|
134
|
+
|
|
135
|
+
# -- plumbing -----------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def client(self) -> VastClient:
|
|
139
|
+
if self._client is None:
|
|
140
|
+
key = self.credentials.get(API_KEY_ENV)
|
|
141
|
+
if not key:
|
|
142
|
+
from ...core.errors import AuthenticationError
|
|
143
|
+
|
|
144
|
+
raise AuthenticationError(
|
|
145
|
+
f"{API_KEY_ENV} is not set. Export it or add it to .env "
|
|
146
|
+
"(get one at https://cloud.vast.ai/account/)."
|
|
147
|
+
)
|
|
148
|
+
self._client = VastClient(key, api_base=self.options.api_base)
|
|
149
|
+
return self._client
|
|
150
|
+
|
|
151
|
+
def _wanted_ports(self) -> dict[int, str]:
|
|
152
|
+
ports: dict[int, str] = {}
|
|
153
|
+
if self.config.runtime.engine != "none":
|
|
154
|
+
ports[self.config.runtime.port] = "model"
|
|
155
|
+
if self.config.lifecycle.watchdog.enabled:
|
|
156
|
+
ports[self.config.lifecycle.watchdog.port] = "watchdog"
|
|
157
|
+
return ports
|
|
158
|
+
|
|
159
|
+
# -- contract -----------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def validate(self, config: RuntimeConfig) -> ValidationResult:
|
|
162
|
+
result = ValidationResult()
|
|
163
|
+
|
|
164
|
+
missing = self.credentials.missing(self.required_credentials)
|
|
165
|
+
if missing:
|
|
166
|
+
result.error(
|
|
167
|
+
f"missing credential(s): {', '.join(missing)}. "
|
|
168
|
+
f"Searched: {', '.join(self.credentials.describe_sources())}",
|
|
169
|
+
"credentials",
|
|
170
|
+
)
|
|
171
|
+
return result # never reach out to the API without a key
|
|
172
|
+
|
|
173
|
+
if config.compute.spot and not self.capabilities.spot_instances:
|
|
174
|
+
result.error("provider does not support spot instances", "compute.spot")
|
|
175
|
+
if config.lifecycle.auto_stop and not self.capabilities.stop:
|
|
176
|
+
result.error("provider cannot stop instances", "lifecycle.auto_stop")
|
|
177
|
+
if config.workload.mode == "remote" and not self.capabilities.remote_exec:
|
|
178
|
+
result.error("provider cannot execute remote workloads", "workload.mode")
|
|
179
|
+
if config.compute.gpu.count > 8:
|
|
180
|
+
result.warn("more than 8 GPUs on a single Vast machine is rare", "compute.gpu.count")
|
|
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
|
+
min_cuda = mapper.effective_min_cuda(spec, self.options)
|
|
246
|
+
offers = [o for o in offers if mapper.offer_matches(o, spec, min_cuda)]
|
|
247
|
+
offers.sort(key=lambda o: o.hourly_cost_usd)
|
|
248
|
+
return offers[:limit]
|
|
249
|
+
|
|
250
|
+
def setup(self, spec: ComputeSpec) -> Instance:
|
|
251
|
+
# Fail before renting anything: Vast would refuse every offer anyway.
|
|
252
|
+
if len(spec.onstart.encode()) > ONSTART_MAX_BYTES:
|
|
253
|
+
raise ProvisioningError(
|
|
254
|
+
f"the boot script is {len(spec.onstart.encode())} bytes, over Vast's "
|
|
255
|
+
f"{ONSTART_MAX_BYTES}-byte onstart limit"
|
|
256
|
+
)
|
|
257
|
+
offers = self.find_offers(spec, limit=self.options.search_limit)
|
|
258
|
+
if not offers:
|
|
259
|
+
raise CapacityUnavailableError(
|
|
260
|
+
"no vast.ai capacity matches the requested compute spec "
|
|
261
|
+
f"({spec.gpu.count}x >= {spec.gpu.min_vram_gb} GB VRAM, "
|
|
262
|
+
f"{spec.disk_gb} GB disk)"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
ports = sorted(self._wanted_ports())
|
|
266
|
+
last_error: Optional[Exception] = None
|
|
267
|
+
|
|
268
|
+
# Offers go stale between search and create; walk the list.
|
|
269
|
+
for offer in offers[:5]:
|
|
270
|
+
payload = mapper.build_create_payload(
|
|
271
|
+
spec, self.options, ports, spec.onstart, price=offer.hourly_cost_usd
|
|
272
|
+
)
|
|
273
|
+
try:
|
|
274
|
+
created = self.client.create_instance(offer.id, payload)
|
|
275
|
+
except (CapacityUnavailableError, VastNotFound) as exc:
|
|
276
|
+
last_error = exc
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
instance_id = str(created.get("new_contract"))
|
|
280
|
+
instance = self._read_instance(instance_id)
|
|
281
|
+
if instance is None:
|
|
282
|
+
instance = Instance(
|
|
283
|
+
id=instance_id,
|
|
284
|
+
provider=self.name,
|
|
285
|
+
state=InstanceState.PROVISIONING,
|
|
286
|
+
gpu_name=offer.gpu_name,
|
|
287
|
+
gpu_count=offer.gpu_count,
|
|
288
|
+
hourly_cost_usd=offer.hourly_cost_usd,
|
|
289
|
+
label=spec.label,
|
|
290
|
+
)
|
|
291
|
+
instance.host_id = instance.host_id or offer.host_id
|
|
292
|
+
instance.hourly_cost_usd = instance.hourly_cost_usd or offer.hourly_cost_usd
|
|
293
|
+
return instance
|
|
294
|
+
|
|
295
|
+
raise CapacityUnavailableError(
|
|
296
|
+
f"every matching vast.ai offer was taken before provisioning: {last_error}"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def start(self, instance: Instance) -> Instance:
|
|
300
|
+
self.client.set_instance_state(instance.id, "running")
|
|
301
|
+
deadline = time.time() + 300
|
|
302
|
+
while time.time() < deadline:
|
|
303
|
+
current = self._read_instance(instance.id)
|
|
304
|
+
if current is None:
|
|
305
|
+
raise ProvisioningError(f"instance {instance.id} disappeared while starting")
|
|
306
|
+
if current.state in (InstanceState.READY, InstanceState.BUSY, InstanceState.IDLE):
|
|
307
|
+
return current
|
|
308
|
+
if current.state is InstanceState.FAILED:
|
|
309
|
+
raise ProvisioningError(
|
|
310
|
+
f"instance {instance.id} failed to start: "
|
|
311
|
+
f"{current.raw.get('status_msg', 'no detail')}"
|
|
312
|
+
)
|
|
313
|
+
time.sleep(5)
|
|
314
|
+
raise ProviderTimeoutError(f"instance {instance.id} did not start within 5 minutes")
|
|
315
|
+
|
|
316
|
+
def run(self, instance: Instance, workload: Workload) -> Execution:
|
|
317
|
+
"""Remote execution over SSH (capability: remote_exec)."""
|
|
318
|
+
self.require_capability("remote_exec")
|
|
319
|
+
if not instance.ssh_host or not instance.ssh_port:
|
|
320
|
+
raise ProviderError(
|
|
321
|
+
f"instance {instance.id} has no SSH endpoint yet; retry once it is running"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
inner = " ".join(shlex.quote(part) for part in workload.command)
|
|
325
|
+
if workload.cwd:
|
|
326
|
+
inner = f"cd {shlex.quote(workload.cwd)} && {inner}"
|
|
327
|
+
exports = " ".join(
|
|
328
|
+
f"export {key}={shlex.quote(str(value))};" for key, value in workload.env.items()
|
|
329
|
+
)
|
|
330
|
+
remote_command = f"{exports} {inner}".strip()
|
|
331
|
+
|
|
332
|
+
ssh_command = [
|
|
333
|
+
"ssh",
|
|
334
|
+
"-p",
|
|
335
|
+
str(instance.ssh_port),
|
|
336
|
+
"-o",
|
|
337
|
+
"StrictHostKeyChecking=accept-new",
|
|
338
|
+
"-o",
|
|
339
|
+
"UserKnownHostsFile=/dev/null",
|
|
340
|
+
"-o",
|
|
341
|
+
"LogLevel=ERROR",
|
|
342
|
+
f"{instance.ssh_user}@{instance.ssh_host}",
|
|
343
|
+
remote_command,
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
execution = Execution(
|
|
347
|
+
id=uuid.uuid4().hex[:12], workload_id=workload.id, started_at=time.time()
|
|
348
|
+
)
|
|
349
|
+
process = subprocess.run(ssh_command, check=False)
|
|
350
|
+
execution.finished_at = time.time()
|
|
351
|
+
execution.returncode = process.returncode
|
|
352
|
+
execution.state = "finished" if process.returncode == 0 else "failed"
|
|
353
|
+
return execution
|
|
354
|
+
|
|
355
|
+
def status(self, instance: Instance) -> InstanceStatus:
|
|
356
|
+
raw = self.client.get_instance(instance.id)
|
|
357
|
+
if raw is None:
|
|
358
|
+
return InstanceStatus(
|
|
359
|
+
state=InstanceState.DESTROYED,
|
|
360
|
+
instance=None,
|
|
361
|
+
exists=False,
|
|
362
|
+
message="instance no longer exists on vast.ai",
|
|
363
|
+
)
|
|
364
|
+
current = mapper.map_instance(raw, self._wanted_ports())
|
|
365
|
+
return InstanceStatus(
|
|
366
|
+
state=current.state,
|
|
367
|
+
instance=current,
|
|
368
|
+
exists=True,
|
|
369
|
+
hourly_cost_usd=current.hourly_cost_usd,
|
|
370
|
+
message=str(raw.get("status_msg") or "").strip(),
|
|
371
|
+
raw=raw,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def stop(self, instance: Instance) -> None:
|
|
375
|
+
try:
|
|
376
|
+
self.client.set_instance_state(instance.id, "stopped")
|
|
377
|
+
except VastNotFound:
|
|
378
|
+
return # already gone: stopping is idempotent
|
|
379
|
+
except ProviderError as exc:
|
|
380
|
+
message = str(exc).lower()
|
|
381
|
+
if "not running" in message or "already" in message:
|
|
382
|
+
return
|
|
383
|
+
raise
|
|
384
|
+
|
|
385
|
+
def destroy(self, instance: Instance) -> None:
|
|
386
|
+
try:
|
|
387
|
+
self.client.destroy_instance(instance.id)
|
|
388
|
+
except VastNotFound:
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
def list_instances(self) -> list[Instance]:
|
|
392
|
+
wanted = self._wanted_ports()
|
|
393
|
+
return [
|
|
394
|
+
mapper.map_instance(raw, wanted)
|
|
395
|
+
for raw in self.client.list_instances()
|
|
396
|
+
if is_jambu_label(raw.get("label"))
|
|
397
|
+
]
|
|
398
|
+
|
|
399
|
+
def logs(self, instance: Instance) -> LogStream:
|
|
400
|
+
url = self.client.request_logs(instance.id, tail=800)
|
|
401
|
+
if not url:
|
|
402
|
+
return LogStream(iter(()), source="vast")
|
|
403
|
+
|
|
404
|
+
def _lines() -> Iterator[str]:
|
|
405
|
+
# Vast uploads the log asynchronously; give it a few seconds.
|
|
406
|
+
for attempt in range(8):
|
|
407
|
+
try:
|
|
408
|
+
text = self.client.fetch_url(url)
|
|
409
|
+
except ProviderError:
|
|
410
|
+
text = ""
|
|
411
|
+
if text:
|
|
412
|
+
yield from text.splitlines()
|
|
413
|
+
return
|
|
414
|
+
time.sleep(1.5 * (attempt + 1) / 2)
|
|
415
|
+
|
|
416
|
+
return LogStream(_lines(), source=f"vast:{instance.id}")
|
|
417
|
+
|
|
418
|
+
def refresh(self, instance: Instance) -> Instance:
|
|
419
|
+
current = self._read_instance(instance.id)
|
|
420
|
+
return current or instance
|
|
421
|
+
|
|
422
|
+
# -- watchdog -----------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
def watchdog_spec(self, spec_or_instance: Any = None) -> Optional[WatchdogSpec]:
|
|
425
|
+
key = self.credentials.get(API_KEY_ENV) or ""
|
|
426
|
+
return WatchdogSpec(
|
|
427
|
+
actions_source=WATCHDOG_ACTIONS,
|
|
428
|
+
env={"VAST_API_KEY": key, "VAST_API_BASE": self.options.api_base},
|
|
429
|
+
context={"provider": self.name},
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# -- internals ----------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
def _read_instance(self, instance_id: str) -> Optional[Instance]:
|
|
435
|
+
raw = self.client.get_instance(instance_id)
|
|
436
|
+
if raw is None:
|
|
437
|
+
return None
|
|
438
|
+
return mapper.map_instance(raw, self._wanted_ports())
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Runtime contract (spec section 19).
|
|
2
|
+
|
|
3
|
+
Provider answers "where does the compute run?".
|
|
4
|
+
Runtime answers "how does the model run?".
|
|
5
|
+
Model answers "what model runs?".
|
|
6
|
+
These stay independent; a runtime never talks to a provider API.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from typing import Any, Optional, Type
|
|
14
|
+
|
|
15
|
+
from ..core.config import RuntimeConfig
|
|
16
|
+
from ..core.errors import ConfigError
|
|
17
|
+
from ..core.models import ValidationResult
|
|
18
|
+
|
|
19
|
+
ENDPOINT_NAME = "model"
|
|
20
|
+
|
|
21
|
+
# `-cu129` -> 12.9, `cu130` -> 13.0, `-cuda12.1-` -> 12.1: the CUDA toolkit an
|
|
22
|
+
# image tag was built for.
|
|
23
|
+
_CU_TAG = re.compile(r"(?:^|[-_.])cu(\d{2})(\d)(?![0-9])")
|
|
24
|
+
# `cuda12.1` (pytorch) and `cuda-13.0` (vastai/vllm) spellings.
|
|
25
|
+
_CUDA_TAG = re.compile(r"cuda[-_]?(\d+)\.(\d+)")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def image_tag(image: str) -> str:
|
|
29
|
+
"""The tag of ``repo[:tag][@digest]``; an untagged reference means ``latest``."""
|
|
30
|
+
name = image.split("@", 1)[0]
|
|
31
|
+
last = name.rsplit("/", 1)[-1]
|
|
32
|
+
return last.split(":", 1)[1] if ":" in last else "latest"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def image_repo(image: str) -> str:
|
|
36
|
+
name = image.split("@", 1)[0]
|
|
37
|
+
head, _, last = name.rpartition("/")
|
|
38
|
+
last = last.split(":", 1)[0]
|
|
39
|
+
return f"{head}/{last}" if head else last
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cuda_from_tag(tag: str) -> Optional[float]:
|
|
43
|
+
match = _CU_TAG.search(tag) or _CUDA_TAG.search(tag)
|
|
44
|
+
if not match:
|
|
45
|
+
return None
|
|
46
|
+
return float(f"{int(match.group(1))}.{match.group(2)}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_cuda(value: Any) -> Optional[float]:
|
|
50
|
+
"""``"12.4"`` / ``12.4`` / ``None`` -> float or None (garbage is None)."""
|
|
51
|
+
if value is None or value == "":
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
return float(value)
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def max_cuda(*values: Any) -> Optional[float]:
|
|
60
|
+
known = [v for v in (parse_cuda(x) for x in values) if v is not None]
|
|
61
|
+
return max(known) if known else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ModelRuntime(ABC):
|
|
65
|
+
"""Describes how to boot a model server inside the provisioned container."""
|
|
66
|
+
|
|
67
|
+
engine: str = "abstract"
|
|
68
|
+
default_image: str = ""
|
|
69
|
+
|
|
70
|
+
def __init__(self, config: RuntimeConfig) -> None:
|
|
71
|
+
self.config = config
|
|
72
|
+
# Set by the engine from the provider's preferred_images.
|
|
73
|
+
self.provider_image: Optional[str] = None
|
|
74
|
+
|
|
75
|
+
# -- container shape ----------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def image(self) -> str:
|
|
78
|
+
return self.config.compute.image or self.provider_image or self.default_image
|
|
79
|
+
|
|
80
|
+
def ports(self) -> list[int]:
|
|
81
|
+
return [self.config.runtime.port]
|
|
82
|
+
|
|
83
|
+
def env(self) -> dict[str, str]:
|
|
84
|
+
return {}
|
|
85
|
+
|
|
86
|
+
# The environment variable through which the server takes its API key;
|
|
87
|
+
# None for a runtime that cannot require one.
|
|
88
|
+
api_key_env: Optional[str] = None
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def start_command(self) -> str:
|
|
92
|
+
"""Shell command that launches the model server in the background."""
|
|
93
|
+
|
|
94
|
+
# -- health -------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def health_path(self) -> str:
|
|
97
|
+
return self.config.health.path
|
|
98
|
+
|
|
99
|
+
def health_url(self, base_url: str) -> str:
|
|
100
|
+
return base_url.rstrip("/") + self.health_path()
|
|
101
|
+
|
|
102
|
+
def base_url(self, endpoint_url: str) -> str:
|
|
103
|
+
return endpoint_url.rstrip("/")
|
|
104
|
+
|
|
105
|
+
def activity_probe(self) -> Optional[dict]:
|
|
106
|
+
"""How the on-instance watchdog can tell the runtime is serving requests.
|
|
107
|
+
|
|
108
|
+
None means the runtime exposes no such signal; only workloads, startup
|
|
109
|
+
and locks then count as activity.
|
|
110
|
+
"""
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
def required_cuda(self) -> Optional[float]:
|
|
114
|
+
"""Minimum host CUDA (driver) version the image needs, None if unknown.
|
|
115
|
+
|
|
116
|
+
The offer filter is raised to this, so an image built for a newer CUDA
|
|
117
|
+
is never placed on a host whose driver cannot run it.
|
|
118
|
+
"""
|
|
119
|
+
return cuda_from_tag(image_tag(self.image()))
|
|
120
|
+
|
|
121
|
+
def required_compute_cap(self) -> Optional[float]:
|
|
122
|
+
"""Minimum GPU compute capability (architecture) the runtime needs.
|
|
123
|
+
|
|
124
|
+
None if unknown. The offer filter is raised to this, so an image whose
|
|
125
|
+
kernels are built for newer architectures is never placed on a GPU too
|
|
126
|
+
old to run them (which the host driver's CUDA alone does not catch: a
|
|
127
|
+
Volta card behind a CUDA-13 driver still fails at engine startup).
|
|
128
|
+
"""
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
# -- on-instance liveness -------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def runtime_pid_path(self) -> Optional[str]:
|
|
134
|
+
"""File holding the model server's pid, for the watchdog's liveness check."""
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def runtime_log_path(self) -> Optional[str]:
|
|
138
|
+
"""The model server's log on the instance, served by the watchdog's /logs."""
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def validate(self) -> ValidationResult:
|
|
142
|
+
return ValidationResult()
|
|
143
|
+
|
|
144
|
+
def describe(self) -> str:
|
|
145
|
+
return f"{self.engine} :{self.config.runtime.port}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class NoRuntime(ModelRuntime):
|
|
149
|
+
"""`runtime.engine: none` - provision bare compute, start nothing."""
|
|
150
|
+
|
|
151
|
+
engine = "none"
|
|
152
|
+
default_image = "pytorch/pytorch:2.4.0-cuda12.1-cudnn9-runtime"
|
|
153
|
+
|
|
154
|
+
def ports(self) -> list[int]:
|
|
155
|
+
return []
|
|
156
|
+
|
|
157
|
+
def start_command(self) -> str:
|
|
158
|
+
return "true"
|
|
159
|
+
|
|
160
|
+
def health_url(self, base_url: str) -> str: # pragma: no cover - unused
|
|
161
|
+
return base_url
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class RuntimeRegistry:
|
|
165
|
+
def __init__(self) -> None:
|
|
166
|
+
self._runtimes: dict[str, Type[ModelRuntime]] = {"none": NoRuntime}
|
|
167
|
+
|
|
168
|
+
def register(self, runtime_cls: Type[ModelRuntime]) -> Type[ModelRuntime]:
|
|
169
|
+
self._runtimes[runtime_cls.engine] = runtime_cls
|
|
170
|
+
return runtime_cls
|
|
171
|
+
|
|
172
|
+
def names(self) -> list[str]:
|
|
173
|
+
return sorted(self._runtimes)
|
|
174
|
+
|
|
175
|
+
def get(self, config: RuntimeConfig, engine: Optional[str] = None) -> ModelRuntime:
|
|
176
|
+
key = engine or config.runtime.engine
|
|
177
|
+
try:
|
|
178
|
+
return self._runtimes[key](config)
|
|
179
|
+
except KeyError:
|
|
180
|
+
known = ", ".join(self.names())
|
|
181
|
+
raise ConfigError(f"unknown runtime engine '{key}' (available: {known})") from None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
runtime_registry = RuntimeRegistry()
|