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,42 @@
1
+ """Where gpu keeps its files: ``.gpu/`` in a project, ``~/.gpu`` per user.
2
+
3
+ Both were named ``.jambu`` before 0.3.0. A legacy directory is renamed in
4
+ place the first time it is needed, so an instance a project already tracks
5
+ is never forgotten (and never rented twice) after an upgrade.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ STATE_DIRNAME = ".gpu"
14
+ LEGACY_STATE_DIRNAME = ".jambu"
15
+
16
+
17
+ def adopt_legacy(path: Path, legacy: Path) -> Path:
18
+ """``path``, after moving ``legacy`` there when only the legacy one exists."""
19
+ if not path.exists() and legacy.is_dir():
20
+ try:
21
+ legacy.rename(path)
22
+ except OSError:
23
+ # Read-only or racing another gpu process: keep using the old one.
24
+ return path if path.exists() else legacy
25
+ return path
26
+
27
+
28
+ def project_state_dir(project_dir: Path) -> Path:
29
+ project_dir = Path(project_dir)
30
+ return adopt_legacy(project_dir / STATE_DIRNAME, project_dir / LEGACY_STATE_DIRNAME)
31
+
32
+
33
+ def gpu_home() -> Path:
34
+ """Per-user files: the project registry, sweep sightings, bad hosts, credentials.
35
+
36
+ ``$GPU_HOME`` overrides it (``$JAMBU_HOME`` is still honored).
37
+ """
38
+ override = os.environ.get("GPU_HOME") or os.environ.get("JAMBU_HOME")
39
+ if override:
40
+ return Path(override).expanduser()
41
+ home = Path.home()
42
+ return adopt_legacy(home / STATE_DIRNAME, home / LEGACY_STATE_DIRNAME)
@@ -0,0 +1,264 @@
1
+ """Lifecycle policy (spec sections 10-13, 25).
2
+
3
+ This module is the SINGLE SOURCE OF TRUTH for "may this instance stay
4
+ billable?". It is deliberately stdlib-only and dependency-free because its
5
+ source is inlined verbatim into the remote watchdog agent that runs on the
6
+ rented instance (see gpu4agents/watchdog/builder.py). Do not import anything
7
+ from the rest of the package here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Optional
14
+
15
+ # --- normalized runtime state (spec section 8) -------------------------------
16
+
17
+ PROVISIONING = "provisioning"
18
+ STARTING = "starting"
19
+ READY = "ready"
20
+ BUSY = "busy"
21
+ IDLE = "idle"
22
+ STOPPED = "stopped"
23
+ FAILED = "failed"
24
+ DESTROYED = "destroyed"
25
+ UNKNOWN = "unknown"
26
+
27
+ # --- actions -----------------------------------------------------------------
28
+
29
+ ACTION_NONE = "none"
30
+ ACTION_STOP = "stop"
31
+ ACTION_DESTROY = "destroy"
32
+
33
+ # --- reasons (recorded on every automatic shutdown, spec criterion 12) --------
34
+
35
+ REASON_IDLE = "idle_timeout"
36
+ REASON_MAX_LIFETIME = "max_lifetime"
37
+ REASON_BUDGET = "budget_exceeded"
38
+ REASON_STOPPED_TTL = "stopped_ttl"
39
+
40
+
41
+ @dataclass
42
+ class Workload:
43
+ """A registered unit of useful work (spec section 12)."""
44
+
45
+ id: str
46
+ started_at: float
47
+ last_heartbeat: float
48
+ label: str = ""
49
+ state: str = "running" # running | queued | finished | failed
50
+
51
+ def is_pending(self) -> bool:
52
+ return self.state in ("running", "queued")
53
+
54
+ def is_stale(self, now: float, grace: float) -> bool:
55
+ """Heartbeat vanished for longer than the grace period (spec section 13)."""
56
+ if not self.is_pending():
57
+ return False
58
+ if self.state == "queued":
59
+ return False
60
+ return (now - self.last_heartbeat) > grace
61
+
62
+
63
+ @dataclass
64
+ class PolicyConfig:
65
+ idle_timeout_s: Optional[float] = None
66
+ max_lifetime_s: Optional[float] = None
67
+ heartbeat_grace_s: float = 300.0
68
+ auto_stop: bool = True
69
+ auto_destroy: bool = False
70
+ allow_indefinite_lock: bool = False
71
+ stop_supported: bool = True
72
+ destroy_supported: bool = True
73
+ # False when a stopped instance keeps costing real money (spec section 25).
74
+ stop_eliminates_billing: bool = True
75
+ # Cost guardrails (spec section 17).
76
+ hourly_cost_usd: Optional[float] = None
77
+ max_session_cost_usd: Optional[float] = None
78
+ # A stopped instance still bills storage; destroy it after this long.
79
+ stopped_ttl_s: Optional[float] = None
80
+
81
+ @classmethod
82
+ def from_dict(cls, data: dict) -> "PolicyConfig":
83
+ known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
84
+ return cls(**{k: v for k, v in data.items() if k in known})
85
+
86
+ def to_dict(self) -> dict:
87
+ return {f: getattr(self, f) for f in self.__dataclass_fields__} # type: ignore[attr-defined]
88
+
89
+
90
+ @dataclass
91
+ class Snapshot:
92
+ """Everything the policy needs to decide. Times are unix seconds."""
93
+
94
+ now: float
95
+ created_at: float
96
+ last_activity: float
97
+ workloads: list = field(default_factory=list) # list[Workload]
98
+ starting: bool = False
99
+ locked: bool = False
100
+ locked_until: Optional[float] = None
101
+ provider_state: str = READY
102
+ # Start of the current container session. A restarted instance gets a new
103
+ # one, so max_lifetime and the budget do not kill it the moment it boots.
104
+ session_started_at: Optional[float] = None
105
+ # When the provider last reported the instance stopped (CLI side only).
106
+ stopped_since: Optional[float] = None
107
+
108
+ # -- derived helpers ----------------------------------------------------
109
+
110
+ def lock_active(self, cfg: PolicyConfig) -> bool:
111
+ if not self.locked:
112
+ return False
113
+ if self.locked_until is None:
114
+ # An indefinite lock only holds when the project explicitly allows it.
115
+ return bool(cfg.allow_indefinite_lock)
116
+ return self.locked_until > self.now
117
+
118
+ def lock_expired(self, cfg: PolicyConfig) -> bool:
119
+ if not self.locked:
120
+ return False
121
+ if self.locked_until is None:
122
+ return not cfg.allow_indefinite_lock
123
+ return self.locked_until <= self.now
124
+
125
+ def pending(self, cfg: PolicyConfig) -> list:
126
+ """Workloads that still count as useful work."""
127
+ return [
128
+ w
129
+ for w in self.workloads
130
+ if w.is_pending() and not w.is_stale(self.now, cfg.heartbeat_grace_s)
131
+ ]
132
+
133
+ def stale(self, cfg: PolicyConfig) -> list:
134
+ return [w for w in self.workloads if w.is_stale(self.now, cfg.heartbeat_grace_s)]
135
+
136
+ def idle_seconds(self, cfg: PolicyConfig) -> float:
137
+ """Seconds since the last thing that counted as activity."""
138
+ if self.pending(cfg) or self.starting:
139
+ return 0.0
140
+ marks = [self.last_activity]
141
+ for w in self.workloads:
142
+ marks.append(w.last_heartbeat)
143
+ return max(0.0, self.now - max(marks))
144
+
145
+ def age_seconds(self) -> float:
146
+ return max(0.0, self.now - self.created_at)
147
+
148
+ def session_age_seconds(self) -> float:
149
+ start = self.session_started_at if self.session_started_at is not None else self.created_at
150
+ return max(0.0, self.now - start)
151
+
152
+ def stopped_seconds(self) -> Optional[float]:
153
+ if self.stopped_since is None:
154
+ return None
155
+ return max(0.0, self.now - self.stopped_since)
156
+
157
+
158
+ @dataclass
159
+ class Decision:
160
+ action: str = ACTION_NONE
161
+ reason: Optional[str] = None
162
+ detail: dict = field(default_factory=dict)
163
+
164
+ @property
165
+ def terminates(self) -> bool:
166
+ return self.action in (ACTION_STOP, ACTION_DESTROY)
167
+
168
+ def to_dict(self) -> dict:
169
+ return {"action": self.action, "reason": self.reason, "detail": dict(self.detail)}
170
+
171
+
172
+ def derive_state(snapshot: Snapshot, cfg: PolicyConfig) -> str:
173
+ """Normalized observed state of a live instance."""
174
+ if snapshot.provider_state in (STOPPED, DESTROYED, FAILED, PROVISIONING):
175
+ return snapshot.provider_state
176
+ if snapshot.starting:
177
+ return STARTING
178
+ if snapshot.pending(cfg):
179
+ return BUSY
180
+ if snapshot.provider_state == READY:
181
+ return IDLE if snapshot.idle_seconds(cfg) > 0 else READY
182
+ return snapshot.provider_state
183
+
184
+
185
+ def _terminal_action(cfg: PolicyConfig) -> str:
186
+ """Pick the cheapest supported action that actually stops the bleeding."""
187
+ if cfg.auto_destroy:
188
+ return ACTION_DESTROY if cfg.destroy_supported else ACTION_NONE
189
+ if not cfg.auto_stop:
190
+ return ACTION_NONE
191
+ if cfg.stop_supported and cfg.stop_eliminates_billing:
192
+ return ACTION_STOP
193
+ # Stopping does not (or cannot) end billing: escalate only when allowed to.
194
+ if not cfg.stop_supported:
195
+ return ACTION_DESTROY if cfg.destroy_supported else ACTION_NONE
196
+ return ACTION_STOP
197
+
198
+
199
+ def evaluate(snapshot: Snapshot, cfg: PolicyConfig) -> Decision:
200
+ """Core lifecycle rule (spec section 10). Pure function, no side effects."""
201
+ detail: dict[str, Any] = {
202
+ "state": derive_state(snapshot, cfg),
203
+ "age_seconds": round(snapshot.age_seconds(), 1),
204
+ "session_age_seconds": round(snapshot.session_age_seconds(), 1),
205
+ "idle_seconds": round(snapshot.idle_seconds(cfg), 1),
206
+ "pending_workloads": len(snapshot.pending(cfg)),
207
+ "stale_workloads": len(snapshot.stale(cfg)),
208
+ "locked": snapshot.lock_active(cfg),
209
+ }
210
+
211
+ locked = snapshot.lock_active(cfg)
212
+
213
+ if snapshot.provider_state == STOPPED:
214
+ stopped_for = snapshot.stopped_seconds()
215
+ if (
216
+ cfg.stopped_ttl_s is not None
217
+ and stopped_for is not None
218
+ and stopped_for > cfg.stopped_ttl_s
219
+ and cfg.destroy_supported
220
+ and not locked
221
+ ):
222
+ detail["stopped_seconds"] = round(stopped_for, 1)
223
+ return Decision(ACTION_DESTROY, REASON_STOPPED_TTL, detail)
224
+ return Decision(ACTION_NONE, "already_terminal", detail)
225
+ if snapshot.provider_state == DESTROYED:
226
+ return Decision(ACTION_NONE, "already_terminal", detail)
227
+
228
+ # max_lifetime wins over everything except an active lock. Measured per
229
+ # session: a restart is a deliberate new lease, not a forgotten instance.
230
+ if cfg.max_lifetime_s is not None and snapshot.session_age_seconds() > cfg.max_lifetime_s:
231
+ if locked:
232
+ return Decision(ACTION_NONE, "locked", detail)
233
+ action = _terminal_action(cfg)
234
+ if action == ACTION_NONE:
235
+ return Decision(ACTION_NONE, "max_lifetime_no_action", detail)
236
+ return Decision(action, REASON_MAX_LIFETIME, detail)
237
+
238
+ # Session budget ceiling.
239
+ if cfg.max_session_cost_usd is not None and cfg.hourly_cost_usd:
240
+ spent = cfg.hourly_cost_usd * snapshot.session_age_seconds() / 3600.0
241
+ detail["session_cost_usd"] = round(spent, 4)
242
+ if spent > cfg.max_session_cost_usd:
243
+ if locked:
244
+ return Decision(ACTION_NONE, "locked", detail)
245
+ action = _terminal_action(cfg)
246
+ if action != ACTION_NONE:
247
+ return Decision(action, REASON_BUDGET, detail)
248
+
249
+ if locked:
250
+ return Decision(ACTION_NONE, "locked", detail)
251
+
252
+ # Useful work in flight is never interrupted (spec criterion 5).
253
+ if snapshot.starting:
254
+ return Decision(ACTION_NONE, "startup_in_progress", detail)
255
+ if snapshot.pending(cfg):
256
+ return Decision(ACTION_NONE, "workload_active", detail)
257
+
258
+ if cfg.idle_timeout_s is not None and snapshot.idle_seconds(cfg) > cfg.idle_timeout_s:
259
+ action = _terminal_action(cfg)
260
+ if action == ACTION_NONE:
261
+ return Decision(ACTION_NONE, "idle_no_action", detail)
262
+ return Decision(action, REASON_IDLE, detail)
263
+
264
+ return Decision(ACTION_NONE, "within_policy", detail)