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,94 @@
1
+ """Normalized error model (spec section 22).
2
+
3
+ Provider adapters MUST translate their native failures into these types so the
4
+ CLI behaves identically regardless of which provider is configured.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ class JambuError(Exception):
13
+ """Base class for every error raised by the CLI."""
14
+
15
+ exit_code = 1
16
+
17
+
18
+ class ConfigError(JambuError):
19
+ """jambu.yaml (or legacy config.yml) is missing, malformed or semantically invalid."""
20
+
21
+ exit_code = 2
22
+
23
+
24
+ class StateError(JambuError):
25
+ """Local runtime state is missing or inconsistent."""
26
+
27
+ exit_code = 3
28
+
29
+
30
+ class LockBusy(StateError):
31
+ """Another gpu command (usually a setup, for minutes) holds this project's lock."""
32
+
33
+ exit_code = 12
34
+
35
+
36
+ class ProviderError(JambuError):
37
+ """Base class for every provider-originated failure."""
38
+
39
+ exit_code = 4
40
+
41
+
42
+ class AuthenticationError(ProviderError):
43
+ exit_code = 5
44
+
45
+
46
+ class CapacityUnavailableError(ProviderError):
47
+ exit_code = 6
48
+
49
+
50
+ class ProvisioningError(ProviderError):
51
+ exit_code = 7
52
+
53
+
54
+ class ProviderTimeoutError(ProviderError):
55
+ exit_code = 8
56
+
57
+
58
+ class UnsupportedCapabilityError(ProviderError):
59
+ exit_code = 9
60
+
61
+
62
+ class BudgetExceededError(JambuError):
63
+ exit_code = 10
64
+
65
+
66
+ class RuntimeStartupError(JambuError):
67
+ exit_code = 11
68
+
69
+
70
+ class HostFailure(RuntimeStartupError):
71
+ """The machine failed, not the config: another host may well succeed.
72
+
73
+ Raised while an instance boots, before its container runs: the provider
74
+ reports it failed (an image pull that never finished, a registry the host
75
+ cannot reach), it vanished, or it never reached running within
76
+ ``health.boot_timeout``. A model server that dies once the container runs
77
+ is a plain RuntimeStartupError - the same config would fail anywhere.
78
+ """
79
+
80
+ def __init__(self, message: str, reason: str = "", instance: Any = None) -> None:
81
+ super().__init__(message)
82
+ # Short cause for one-line output, e.g. "image pull timed out after 15m".
83
+ self.reason = reason or message
84
+ # The models.Instance that failed, so its machine can be excluded.
85
+ self.instance = instance
86
+
87
+
88
+ class WorkloadFailed(JambuError):
89
+ """The user workload exited non-zero. Carries the original exit code."""
90
+
91
+ def __init__(self, message: str, returncode: int) -> None:
92
+ super().__init__(message)
93
+ self.returncode = returncode
94
+ self.exit_code = returncode or 1
@@ -0,0 +1,117 @@
1
+ """Structured lifecycle events (spec section 24).
2
+
3
+ Every lifecycle action appends one JSON object to ``.gpu/logs/events.jsonl``
4
+ so unexpected GPU cost can always be traced back to a decision and a reason.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ import threading
13
+ from pathlib import Path
14
+ from typing import Any, Iterable, Optional
15
+
16
+ from .state import to_iso, utc_now
17
+
18
+ # Minimum event vocabulary required by the spec.
19
+ PROVISION_REQUESTED = "provision_requested"
20
+ INSTANCE_CREATED = "instance_created"
21
+ RUNTIME_STARTING = "runtime_starting"
22
+ RUNTIME_READY = "runtime_ready"
23
+ WORKLOAD_STARTED = "workload_started"
24
+ WORKLOAD_FINISHED = "workload_finished"
25
+ INSTANCE_IDLE = "instance_idle"
26
+ INSTANCE_LOCKED = "instance_locked"
27
+ INSTANCE_UNLOCKED = "instance_unlocked"
28
+ INSTANCE_STOPPED = "instance_stopped"
29
+ INSTANCE_STARTED = "instance_started"
30
+ INSTANCE_DESTROYED = "instance_destroyed"
31
+ LIFECYCLE_GUARD_TRIGGERED = "lifecycle_guard_triggered"
32
+ PROVIDER_ERROR = "provider_error"
33
+ STATE_RECONCILED = "state_reconciled"
34
+ SETUP_FAILED = "setup_failed"
35
+ CLEANUP_PERFORMED = "cleanup_performed"
36
+ BUDGET_REJECTED = "budget_rejected"
37
+ # A machine failed to boot; setup rents another one.
38
+ HOST_FAILED = "host_failed"
39
+
40
+
41
+ class EventLog:
42
+ """Append-only JSONL sink. Never raises into the caller's path."""
43
+
44
+ def __init__(self, logs_dir: Path, echo: bool = False) -> None:
45
+ self.dir = Path(logs_dir)
46
+ self.path = self.dir / "events.jsonl"
47
+ self.echo = echo
48
+ self._lock = threading.Lock()
49
+ self._context: dict[str, Any] = {}
50
+
51
+ def bind(self, **context: Any) -> "EventLog":
52
+ self._context.update({k: v for k, v in context.items() if v is not None})
53
+ return self
54
+
55
+ def emit(self, event: str, **fields: Any) -> dict[str, Any]:
56
+ record: dict[str, Any] = {
57
+ "ts": to_iso(utc_now()),
58
+ "event": event,
59
+ "pid": os.getpid(),
60
+ }
61
+ record.update(self._context)
62
+ record.update({k: v for k, v in fields.items() if v is not None})
63
+ line = json.dumps(record, default=str)
64
+ with self._lock:
65
+ try:
66
+ self.dir.mkdir(parents=True, exist_ok=True)
67
+ with open(self.path, "a") as handle:
68
+ handle.write(line + "\n")
69
+ except OSError:
70
+ pass
71
+ if self.echo:
72
+ print(line, file=sys.stderr)
73
+ return record
74
+
75
+ def tail(self, limit: int = 50, event: Optional[str] = None) -> list[dict[str, Any]]:
76
+ if not self.path.is_file():
77
+ return []
78
+ out: list[dict[str, Any]] = []
79
+ for line in self.path.read_text().splitlines():
80
+ line = line.strip()
81
+ if not line:
82
+ continue
83
+ try:
84
+ record = json.loads(line)
85
+ except json.JSONDecodeError:
86
+ continue
87
+ if event and record.get("event") != event:
88
+ continue
89
+ out.append(record)
90
+ return out[-limit:]
91
+
92
+ def extend(self, records: Iterable[dict[str, Any]]) -> int:
93
+ """Merge records produced elsewhere (e.g. pulled from the remote watchdog)."""
94
+ count = 0
95
+ seen = {(r.get("ts"), r.get("event")) for r in self.tail(limit=2000)}
96
+ with self._lock:
97
+ try:
98
+ self.dir.mkdir(parents=True, exist_ok=True)
99
+ with open(self.path, "a") as handle:
100
+ for record in records:
101
+ key = (record.get("ts"), record.get("event"))
102
+ if key in seen:
103
+ continue
104
+ seen.add(key)
105
+ handle.write(json.dumps(record, default=str) + "\n")
106
+ count += 1
107
+ except OSError:
108
+ pass
109
+ return count
110
+
111
+
112
+ class NullEventLog(EventLog):
113
+ def __init__(self) -> None: # noqa: D107
114
+ super().__init__(Path(os.devnull).parent, echo=False)
115
+
116
+ def emit(self, event: str, **fields: Any) -> dict[str, Any]: # noqa: D102
117
+ return {"event": event, **fields}
@@ -0,0 +1,169 @@
1
+ """Workload execution and heartbeat (spec sections 12, 13, 16).
2
+
3
+ The CLI registers the workload with the remote watchdog, keeps a heartbeat
4
+ alive while it runs, and records completion. If the heartbeat disappears
5
+ (CLI crash, SSH drop, laptop sleep), the watchdog lets the grace period
6
+ expire and the instance falls back to IDLE and the normal shutdown rules.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import signal
13
+ import subprocess
14
+ import threading
15
+ import time
16
+ from typing import Optional, Sequence
17
+
18
+ from .beacon import WatchdogClient
19
+ from .events import WORKLOAD_FINISHED, WORKLOAD_STARTED, EventLog
20
+ from .models import Execution, Workload
21
+ from .state import StateStore, WorkloadRecord, new_workload_id, utc_now
22
+
23
+
24
+ class Heartbeat:
25
+ """Background thread that keeps the remote watchdog informed."""
26
+
27
+ def __init__(
28
+ self,
29
+ workload_id: str,
30
+ interval: float,
31
+ store: StateStore,
32
+ beacon: Optional[WatchdogClient] = None,
33
+ ) -> None:
34
+ self.workload_id = workload_id
35
+ self.interval = max(5.0, float(interval))
36
+ self.store = store
37
+ self.beacon = beacon
38
+ self._stop = threading.Event()
39
+ self._thread: Optional[threading.Thread] = None
40
+ self.failures = 0
41
+
42
+ def _beat(self) -> None:
43
+ if self.beacon is not None:
44
+ if self.beacon.workload_heartbeat(self.workload_id) is None:
45
+ self.failures += 1
46
+ else:
47
+ self.failures = 0
48
+ try:
49
+ with self.store.transaction(timeout=10) as state:
50
+ record = state.workloads.get(self.workload_id)
51
+ if record is not None:
52
+ record.last_heartbeat = utc_now()
53
+ state.touch()
54
+ except Exception: # noqa: BLE001 - a heartbeat must never kill a workload
55
+ pass
56
+
57
+ def start(self) -> "Heartbeat":
58
+ self._beat()
59
+
60
+ def _loop() -> None:
61
+ while not self._stop.wait(self.interval):
62
+ self._beat()
63
+
64
+ self._thread = threading.Thread(target=_loop, name="jambu-heartbeat", daemon=True)
65
+ self._thread.start()
66
+ return self
67
+
68
+ def stop(self) -> None:
69
+ self._stop.set()
70
+ if self._thread is not None:
71
+ self._thread.join(timeout=2.0)
72
+
73
+ def __enter__(self) -> "Heartbeat":
74
+ return self.start()
75
+
76
+ def __exit__(self, *exc_info) -> None:
77
+ self.stop()
78
+
79
+
80
+ class ExecutionManager:
81
+ """Registers, runs and closes out workloads."""
82
+
83
+ def __init__(
84
+ self,
85
+ store: StateStore,
86
+ events: EventLog,
87
+ beacon: Optional[WatchdogClient] = None,
88
+ heartbeat_interval: float = 30.0,
89
+ ) -> None:
90
+ self.store = store
91
+ self.events = events
92
+ self.beacon = beacon
93
+ self.heartbeat_interval = heartbeat_interval
94
+
95
+ def register(self, command: Sequence[str], label: str = "") -> WorkloadRecord:
96
+ record = WorkloadRecord(
97
+ id=new_workload_id(),
98
+ label=label or " ".join(command)[:80],
99
+ command=list(command),
100
+ state="running",
101
+ pid=os.getpid(),
102
+ )
103
+ with self.store.transaction() as state:
104
+ state.workloads[record.id] = record
105
+ state.touch()
106
+ state.prune_workloads()
107
+ if self.beacon is not None:
108
+ self.beacon.workload_start(record.id, record.label)
109
+ self.events.emit(WORKLOAD_STARTED, workload_id=record.id, label=record.label)
110
+ return record
111
+
112
+ def finish(self, record: WorkloadRecord, returncode: Optional[int]) -> None:
113
+ state_name = "finished" if returncode == 0 else "failed"
114
+ with self.store.transaction() as state:
115
+ stored = state.workloads.get(record.id)
116
+ if stored is not None:
117
+ stored.state = state_name
118
+ stored.finished_at = utc_now()
119
+ stored.returncode = returncode
120
+ state.touch()
121
+ if self.beacon is not None:
122
+ self.beacon.workload_end(record.id, returncode, state_name)
123
+ self.events.emit(
124
+ WORKLOAD_FINISHED,
125
+ workload_id=record.id,
126
+ returncode=returncode,
127
+ state=state_name,
128
+ duration_seconds=round(utc_now() - record.started_at, 1),
129
+ )
130
+
131
+ def run_local(self, workload: Workload, record: WorkloadRecord) -> Execution:
132
+ """Run the command on the developer machine against the remote runtime."""
133
+ execution = Execution(
134
+ id=record.id, workload_id=record.id, started_at=time.time()
135
+ )
136
+ env = os.environ.copy()
137
+ env.update({k: str(v) for k, v in workload.env.items()})
138
+
139
+ process = subprocess.Popen( # noqa: S603 - the user's own command, by design
140
+ list(workload.command),
141
+ cwd=workload.cwd or None,
142
+ env=env,
143
+ )
144
+
145
+ def _forward(signum, _frame): # pragma: no cover - interactive path
146
+ try:
147
+ process.send_signal(signum)
148
+ except ProcessLookupError:
149
+ pass
150
+
151
+ previous = {}
152
+ for sig in (signal.SIGINT, signal.SIGTERM):
153
+ try:
154
+ previous[sig] = signal.signal(sig, _forward)
155
+ except ValueError:
156
+ pass
157
+ try:
158
+ returncode = process.wait()
159
+ finally:
160
+ for sig, handler in previous.items():
161
+ try:
162
+ signal.signal(sig, handler)
163
+ except ValueError:
164
+ pass
165
+
166
+ execution.finished_at = time.time()
167
+ execution.returncode = returncode
168
+ execution.state = "finished" if returncode == 0 else "failed"
169
+ return execution
@@ -0,0 +1,62 @@
1
+ """Model server readiness checks.
2
+
3
+ A passing health check means the runtime is *available*; per spec section 12
4
+ it is explicitly NOT workload activity and never refreshes the idle timer.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Callable, Optional
11
+
12
+ import httpx
13
+
14
+ from .errors import RuntimeStartupError
15
+
16
+
17
+ def probe(url: str, timeout: float = 5.0) -> tuple[bool, str]:
18
+ try:
19
+ response = httpx.get(url, timeout=timeout)
20
+ except httpx.HTTPError as exc:
21
+ return False, type(exc).__name__
22
+ if response.status_code < 400:
23
+ return True, f"{response.status_code}"
24
+ return False, f"HTTP {response.status_code}"
25
+
26
+
27
+ def wait_for_healthy(
28
+ url: str,
29
+ *,
30
+ timeout: float,
31
+ interval: float = 10.0,
32
+ on_attempt: Optional[Callable[[int, float, str], None]] = None,
33
+ should_abort: Optional[Callable[[], Optional[str]]] = None,
34
+ ) -> None:
35
+ """Poll ``url`` until it answers or ``timeout`` elapses.
36
+
37
+ ``should_abort`` lets the caller fail fast when the instance itself died
38
+ instead of waiting out the full startup timeout.
39
+ """
40
+ deadline = time.time() + timeout
41
+ attempt = 0
42
+ last_detail = "not started"
43
+ interval = max(2.0, min(interval, 30.0))
44
+
45
+ while time.time() < deadline:
46
+ attempt += 1
47
+ if should_abort is not None:
48
+ reason = should_abort()
49
+ if reason:
50
+ raise RuntimeStartupError(f"model server did not start: {reason}")
51
+ ok, last_detail = probe(url)
52
+ if ok:
53
+ return
54
+ if on_attempt is not None:
55
+ on_attempt(attempt, max(0.0, deadline - time.time()), last_detail)
56
+ time.sleep(interval)
57
+
58
+ raise RuntimeStartupError(
59
+ f"model server did not become healthy within {int(timeout)}s "
60
+ f"({url}, last probe: {last_detail}). "
61
+ "Check `gpu logs` - model downloads can be slow on first boot."
62
+ )
@@ -0,0 +1,58 @@
1
+ """Machines that recently failed us, remembered across setups.
2
+
3
+ A host that could not pull the image or reach the network fails the next
4
+ setup the same way, and the cheapest-offer search keeps picking it. Each
5
+ failure is kept for ``BAD_HOST_TTL_S`` in ``$GPU_HOME/bad_hosts.json``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from .sweep import jambu_home
16
+
17
+ BAD_HOST_TTL_S = 24 * 3600.0
18
+
19
+
20
+ class BadHosts:
21
+ def __init__(self, path: Optional[Path] = None, ttl_s: float = BAD_HOST_TTL_S) -> None:
22
+ self.path = Path(path) if path else jambu_home() / "bad_hosts.json"
23
+ self.ttl_s = ttl_s
24
+
25
+ def _load(self) -> dict:
26
+ try:
27
+ data = json.loads(self.path.read_text() or "{}")
28
+ except (OSError, ValueError):
29
+ # Unreadable memory only costs a retry; never block a setup on it.
30
+ return {}
31
+ return data if isinstance(data, dict) else {}
32
+
33
+ def recent(self, provider: str, now: Optional[float] = None) -> list[str]:
34
+ now = now if now is not None else time.time()
35
+ hosts = self._load().get(provider) or {}
36
+ return sorted(
37
+ host
38
+ for host, entry in hosts.items()
39
+ if isinstance(entry, dict) and now - float(entry.get("at") or 0) < self.ttl_s
40
+ )
41
+
42
+ def mark(self, provider: str, host_id: str, reason: str, now: Optional[float] = None) -> None:
43
+ now = now if now is not None else time.time()
44
+ data = self._load()
45
+ hosts = data.setdefault(provider, {})
46
+ hosts[str(host_id)] = {"at": now, "reason": reason[:200]}
47
+ # Drop expired entries so the file stays small.
48
+ data[provider] = {
49
+ h: e for h, e in hosts.items()
50
+ if isinstance(e, dict) and now - float(e.get("at") or 0) < self.ttl_s
51
+ }
52
+ try:
53
+ self.path.parent.mkdir(parents=True, exist_ok=True)
54
+ tmp = self.path.with_suffix(".tmp")
55
+ tmp.write_text(json.dumps(data, indent=2))
56
+ tmp.replace(self.path)
57
+ except OSError:
58
+ pass