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.
Files changed (59) hide show
  1. gpu4agents/__init__.py +5 -0
  2. gpu4agents/__main__.py +4 -0
  3. gpu4agents/agent/__init__.py +1 -0
  4. gpu4agents/agent/install.py +173 -0
  5. gpu4agents/agent/jobs.py +193 -0
  6. gpu4agents/agent/mcp.py +646 -0
  7. gpu4agents/agent/skill/SKILL.md +68 -0
  8. gpu4agents/cli/__init__.py +0 -0
  9. gpu4agents/cli/_common.py +131 -0
  10. gpu4agents/cli/account.py +330 -0
  11. gpu4agents/cli/agent.py +74 -0
  12. gpu4agents/cli/inspect.py +207 -0
  13. gpu4agents/cli/lifecycle.py +127 -0
  14. gpu4agents/cli/main.py +163 -0
  15. gpu4agents/cli/providers.py +96 -0
  16. gpu4agents/cli/run.py +38 -0
  17. gpu4agents/cli/setup.py +164 -0
  18. gpu4agents/cli/status.py +224 -0
  19. gpu4agents/cli/timer.py +143 -0
  20. gpu4agents/cli/validate.py +57 -0
  21. gpu4agents/core/__init__.py +0 -0
  22. gpu4agents/core/beacon.py +133 -0
  23. gpu4agents/core/config.py +647 -0
  24. gpu4agents/core/credentials.py +95 -0
  25. gpu4agents/core/durations.py +47 -0
  26. gpu4agents/core/engine.py +1335 -0
  27. gpu4agents/core/errors.py +94 -0
  28. gpu4agents/core/events.py +117 -0
  29. gpu4agents/core/execution.py +169 -0
  30. gpu4agents/core/health.py +62 -0
  31. gpu4agents/core/hosts.py +58 -0
  32. gpu4agents/core/model_inspect.py +368 -0
  33. gpu4agents/core/models.py +313 -0
  34. gpu4agents/core/paths.py +42 -0
  35. gpu4agents/core/policy.py +264 -0
  36. gpu4agents/core/state.py +406 -0
  37. gpu4agents/core/sweep.py +478 -0
  38. gpu4agents/core/tool_parsers.py +63 -0
  39. gpu4agents/providers/__init__.py +4 -0
  40. gpu4agents/providers/base.py +137 -0
  41. gpu4agents/providers/registry.py +63 -0
  42. gpu4agents/providers/vast/__init__.py +3 -0
  43. gpu4agents/providers/vast/client.py +255 -0
  44. gpu4agents/providers/vast/config.py +56 -0
  45. gpu4agents/providers/vast/mapper.py +292 -0
  46. gpu4agents/providers/vast/provider.py +438 -0
  47. gpu4agents/runtime/__init__.py +6 -0
  48. gpu4agents/runtime/base.py +184 -0
  49. gpu4agents/runtime/vllm.py +295 -0
  50. gpu4agents/watchdog/__init__.py +3 -0
  51. gpu4agents/watchdog/agent_body.py +959 -0
  52. gpu4agents/watchdog/builder.py +248 -0
  53. gpu4agents-0.5.0.dist-info/METADATA +157 -0
  54. gpu4agents-0.5.0.dist-info/RECORD +59 -0
  55. gpu4agents-0.5.0.dist-info/WHEEL +4 -0
  56. gpu4agents-0.5.0.dist-info/entry_points.txt +2 -0
  57. gpu4agents-0.5.0.dist-info/licenses/LICENSE +21 -0
  58. jambu_gpu/__init__.py +9 -0
  59. 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,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"]
@@ -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()