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
gpu4agents/core/sweep.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""Account-wide safety net: `gpu ls` and `gpu sweep`.
|
|
2
|
+
|
|
3
|
+
The watchdog guards one instance from the inside and `gpu guard` guards the
|
|
4
|
+
one instance a project tracks. The sweep covers every project on this machine
|
|
5
|
+
and whatever is left over:
|
|
6
|
+
|
|
7
|
+
- every project registered in ``$GPU_HOME/projects.json`` (by `gpu setup`)
|
|
8
|
+
gets its own ``Engine.guard_tick``, with its own config and state: its own
|
|
9
|
+
max_lifetime, lock, session and stopped_ttl. Those instances are *tracked*;
|
|
10
|
+
- an instance no project tracks is an *orphan*. Nobody knows its policy or
|
|
11
|
+
whether it is in use, so the rules are blunt but never destructive for a
|
|
12
|
+
running machine: a running orphan older than ``orphan_max_age`` is stopped
|
|
13
|
+
(never destroyed); an orphan first seen stopped more than ``stopped_ttl``
|
|
14
|
+
ago is destroyed.
|
|
15
|
+
|
|
16
|
+
Providers do not report when an instance stopped, so the first time the sweep
|
|
17
|
+
saw each orphan stopped is kept in ``$GPU_HOME/sweep.json``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import fcntl
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import tempfile
|
|
26
|
+
from contextlib import contextmanager
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Callable, Iterator, Optional
|
|
30
|
+
|
|
31
|
+
from . import events as ev
|
|
32
|
+
from . import policy
|
|
33
|
+
from .config import load_config
|
|
34
|
+
from .paths import gpu_home, project_state_dir
|
|
35
|
+
from .errors import JambuError, ProviderError
|
|
36
|
+
from .events import EventLog
|
|
37
|
+
from .models import Instance, InstanceState, is_jambu_label
|
|
38
|
+
from .state import RuntimeState, StateStore, from_iso, to_iso, utc_now
|
|
39
|
+
|
|
40
|
+
DEFAULT_ORPHAN_MAX_AGE_S = 24 * 3600.0
|
|
41
|
+
DEFAULT_STOPPED_TTL_S = 24 * 3600.0
|
|
42
|
+
|
|
43
|
+
REASON_ORPHAN_AGE = "orphan_max_age"
|
|
44
|
+
REASON_STOP_UNSUPPORTED = "stop_unsupported"
|
|
45
|
+
|
|
46
|
+
SWEEP_ACTION = "sweep_action"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def jambu_home() -> Path:
|
|
50
|
+
return gpu_home()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SweepRegistry:
|
|
54
|
+
"""First-seen-stopped timestamps, keyed by ``provider:instance_id``."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, path: Optional[Path] = None) -> None:
|
|
57
|
+
self.path = Path(path) if path else jambu_home() / "sweep.json"
|
|
58
|
+
self.stopped: dict[str, float] = {}
|
|
59
|
+
self._load()
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def key(provider: str, instance_id: str) -> str:
|
|
63
|
+
return f"{provider}:{instance_id}"
|
|
64
|
+
|
|
65
|
+
def _load(self) -> None:
|
|
66
|
+
if not self.path.is_file():
|
|
67
|
+
return
|
|
68
|
+
try:
|
|
69
|
+
data = json.loads(self.path.read_text() or "{}")
|
|
70
|
+
except (OSError, json.JSONDecodeError):
|
|
71
|
+
# A corrupt registry only delays stopped_ttl; never block the sweep.
|
|
72
|
+
return
|
|
73
|
+
for key, value in (data.get("stopped") or {}).items():
|
|
74
|
+
try:
|
|
75
|
+
ts = from_iso(value)
|
|
76
|
+
except Exception: # noqa: BLE001 - one bad entry must not lose the rest
|
|
77
|
+
continue
|
|
78
|
+
if ts is not None:
|
|
79
|
+
self.stopped[key] = ts
|
|
80
|
+
|
|
81
|
+
def save(self) -> None:
|
|
82
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
payload = {
|
|
84
|
+
"version": 1,
|
|
85
|
+
"stopped": {k: to_iso(v) for k, v in sorted(self.stopped.items())},
|
|
86
|
+
}
|
|
87
|
+
fd, tmp = tempfile.mkstemp(dir=str(self.path.parent), prefix=".sweep-", suffix=".json")
|
|
88
|
+
try:
|
|
89
|
+
with os.fdopen(fd, "w") as handle:
|
|
90
|
+
handle.write(json.dumps(payload, indent=2) + "\n")
|
|
91
|
+
os.replace(tmp, self.path)
|
|
92
|
+
finally:
|
|
93
|
+
if os.path.exists(tmp):
|
|
94
|
+
os.unlink(tmp)
|
|
95
|
+
|
|
96
|
+
def first_seen_stopped(self, key: str, now: float) -> float:
|
|
97
|
+
return self.stopped.setdefault(key, now)
|
|
98
|
+
|
|
99
|
+
def forget(self, key: str) -> None:
|
|
100
|
+
self.stopped.pop(key, None)
|
|
101
|
+
|
|
102
|
+
def retain(self, provider: str, keys: set[str]) -> None:
|
|
103
|
+
"""Drop entries of ``provider`` that are no longer in the account."""
|
|
104
|
+
prefix = f"{provider}:"
|
|
105
|
+
for key in [k for k in self.stopped if k.startswith(prefix) and k not in keys]:
|
|
106
|
+
del self.stopped[key]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _atomic_json(path: Path, payload: dict, prefix: str) -> None:
|
|
111
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=prefix, suffix=".json")
|
|
113
|
+
try:
|
|
114
|
+
with os.fdopen(fd, "w") as handle:
|
|
115
|
+
handle.write(json.dumps(payload, indent=2) + "\n")
|
|
116
|
+
os.replace(tmp, path)
|
|
117
|
+
finally:
|
|
118
|
+
if os.path.exists(tmp):
|
|
119
|
+
os.unlink(tmp)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ProjectRegistry:
|
|
123
|
+
"""Absolute config paths of every project that provisioned from this machine.
|
|
124
|
+
|
|
125
|
+
The sweep guards each with its own policy, so one project's thresholds are
|
|
126
|
+
never applied to another project's instance.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, path: Optional[Path] = None) -> None:
|
|
130
|
+
self.path = Path(path) if path else jambu_home() / "projects.json"
|
|
131
|
+
|
|
132
|
+
def _read(self) -> dict[str, dict]:
|
|
133
|
+
if not self.path.is_file():
|
|
134
|
+
return {}
|
|
135
|
+
try:
|
|
136
|
+
data = json.loads(self.path.read_text() or "{}")
|
|
137
|
+
except (OSError, json.JSONDecodeError):
|
|
138
|
+
# A corrupt registry must not block the sweep; setup rewrites it.
|
|
139
|
+
return {}
|
|
140
|
+
projects = data.get("projects") if isinstance(data, dict) else None
|
|
141
|
+
if not isinstance(projects, dict):
|
|
142
|
+
return {}
|
|
143
|
+
return {str(k): dict(v or {}) for k, v in projects.items()}
|
|
144
|
+
|
|
145
|
+
@contextmanager
|
|
146
|
+
def _edit(self) -> Iterator[dict[str, dict]]:
|
|
147
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
with open(self.path.with_suffix(".lock"), "a+") as lock:
|
|
149
|
+
# Two projects running `gpu setup` at once must not drop each other.
|
|
150
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
151
|
+
projects = self._read()
|
|
152
|
+
yield projects
|
|
153
|
+
_atomic_json(self.path, {"version": 1, "projects": projects}, ".projects-")
|
|
154
|
+
|
|
155
|
+
def paths(self) -> list[Path]:
|
|
156
|
+
return [Path(p) for p in sorted(self._read())]
|
|
157
|
+
|
|
158
|
+
def register(self, config_path: Path) -> Path:
|
|
159
|
+
resolved = Path(config_path).expanduser().resolve()
|
|
160
|
+
with self._edit() as projects:
|
|
161
|
+
projects.setdefault(str(resolved), {"registered_at": to_iso(utc_now())})
|
|
162
|
+
return resolved
|
|
163
|
+
|
|
164
|
+
def remove(self, paths: list[Path]) -> None:
|
|
165
|
+
if not paths:
|
|
166
|
+
return
|
|
167
|
+
with self._edit() as projects:
|
|
168
|
+
for path in paths:
|
|
169
|
+
projects.pop(str(path), None)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def register_project(config) -> Optional[Path]:
|
|
173
|
+
"""Register a loaded RuntimeConfig; None when it has no file behind it."""
|
|
174
|
+
if getattr(config, "source_path", None) is None:
|
|
175
|
+
return None
|
|
176
|
+
return ProjectRegistry().register(config.source_path)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class ProjectGuard:
|
|
181
|
+
"""One project's own guard tick, run by the sweep."""
|
|
182
|
+
|
|
183
|
+
config_path: Path
|
|
184
|
+
provider: Optional[str] = None
|
|
185
|
+
instance_id: Optional[str] = None
|
|
186
|
+
decision: Optional[dict] = None
|
|
187
|
+
error: Optional[str] = None
|
|
188
|
+
|
|
189
|
+
def to_dict(self) -> dict[str, Any]:
|
|
190
|
+
return {
|
|
191
|
+
"config": str(self.config_path),
|
|
192
|
+
"provider": self.provider,
|
|
193
|
+
"instance_id": self.instance_id,
|
|
194
|
+
"decision": self.decision,
|
|
195
|
+
"error": self.error,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _state_beside(config_path: Path) -> Optional[RuntimeState]:
|
|
200
|
+
"""The project's state read straight from disk (no API call)."""
|
|
201
|
+
store = StateStore(project_state_dir(Path(config_path).parent))
|
|
202
|
+
try:
|
|
203
|
+
return store.load() if store.exists() else None
|
|
204
|
+
except JambuError:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def guard_projects(
|
|
209
|
+
paths: list[Path],
|
|
210
|
+
make_engine: Callable[[Any], Any],
|
|
211
|
+
*,
|
|
212
|
+
apply: bool,
|
|
213
|
+
echo=None,
|
|
214
|
+
) -> tuple[list[ProjectGuard], set[tuple[str, str]]]:
|
|
215
|
+
"""Run each project's own guard. Returns the results and the tracked ids.
|
|
216
|
+
|
|
217
|
+
An instance whose project exists but could not be guarded (bad config, a
|
|
218
|
+
busy lock, an API blip) is still tracked: not knowing its policy is no
|
|
219
|
+
reason to treat it as an orphan.
|
|
220
|
+
"""
|
|
221
|
+
say = echo or (lambda _m: None)
|
|
222
|
+
results: list[ProjectGuard] = []
|
|
223
|
+
tracked: set[tuple[str, str]] = set()
|
|
224
|
+
for path in paths:
|
|
225
|
+
guard = ProjectGuard(config_path=path)
|
|
226
|
+
results.append(guard)
|
|
227
|
+
try:
|
|
228
|
+
config = load_config(path)
|
|
229
|
+
except JambuError as exc:
|
|
230
|
+
guard.error = f"config does not load: {exc}"
|
|
231
|
+
state = _state_beside(path)
|
|
232
|
+
if state is not None and state.instance_id:
|
|
233
|
+
guard.provider, guard.instance_id = state.provider, state.instance_id
|
|
234
|
+
tracked.add((state.provider, state.instance_id))
|
|
235
|
+
say(f"! {path}: {guard.error}")
|
|
236
|
+
continue
|
|
237
|
+
try:
|
|
238
|
+
engine = make_engine(config)
|
|
239
|
+
guard.provider = engine.provider.name
|
|
240
|
+
before = engine.store.load()
|
|
241
|
+
if before.instance_id:
|
|
242
|
+
guard.instance_id = before.instance_id
|
|
243
|
+
tracked.add((guard.provider, before.instance_id))
|
|
244
|
+
guard.decision = engine.guard_tick(apply=apply)
|
|
245
|
+
after = engine.store.load()
|
|
246
|
+
if after.instance_id:
|
|
247
|
+
guard.instance_id = after.instance_id
|
|
248
|
+
tracked.add((guard.provider, after.instance_id))
|
|
249
|
+
except JambuError as exc:
|
|
250
|
+
guard.error = str(exc)
|
|
251
|
+
say(f"! {path}: guard failed: {exc}")
|
|
252
|
+
continue
|
|
253
|
+
_describe_guard(guard, apply, say)
|
|
254
|
+
return results, tracked
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _describe_guard(guard: ProjectGuard, apply: bool, say) -> None:
|
|
258
|
+
decision = guard.decision or {}
|
|
259
|
+
action, reason = decision.get("action", policy.ACTION_NONE), decision.get("reason")
|
|
260
|
+
if reason == "no_instance":
|
|
261
|
+
say(f"= {guard.config_path}: no instance")
|
|
262
|
+
elif action == policy.ACTION_NONE:
|
|
263
|
+
say(f"= {guard.config_path}: {guard.instance_id} ok ({reason})")
|
|
264
|
+
else:
|
|
265
|
+
verb = action if apply else f"would {action}"
|
|
266
|
+
say(f"- {guard.config_path}: {verb} {guard.instance_id} ({reason})")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass
|
|
270
|
+
class SweepAction:
|
|
271
|
+
instance: Instance
|
|
272
|
+
action: str # stop | destroy | none
|
|
273
|
+
reason: str
|
|
274
|
+
age_seconds: Optional[float] = None
|
|
275
|
+
stopped_seconds: Optional[float] = None
|
|
276
|
+
applied: bool = False
|
|
277
|
+
error: Optional[str] = None
|
|
278
|
+
|
|
279
|
+
def to_dict(self) -> dict[str, Any]:
|
|
280
|
+
return {
|
|
281
|
+
"instance_id": self.instance.id,
|
|
282
|
+
"label": self.instance.label,
|
|
283
|
+
"state": self.instance.state.value,
|
|
284
|
+
"action": self.action,
|
|
285
|
+
"reason": self.reason,
|
|
286
|
+
"age_seconds": None if self.age_seconds is None else round(self.age_seconds, 1),
|
|
287
|
+
"stopped_seconds": (
|
|
288
|
+
None if self.stopped_seconds is None else round(self.stopped_seconds, 1)
|
|
289
|
+
),
|
|
290
|
+
"applied": self.applied,
|
|
291
|
+
"error": self.error,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@dataclass
|
|
296
|
+
class SweepResult:
|
|
297
|
+
provider: str
|
|
298
|
+
apply: bool
|
|
299
|
+
orphan_max_age_s: Optional[float]
|
|
300
|
+
stopped_ttl_s: Optional[float]
|
|
301
|
+
seen: int = 0
|
|
302
|
+
tracked: int = 0
|
|
303
|
+
actions: list[SweepAction] = field(default_factory=list)
|
|
304
|
+
|
|
305
|
+
def to_dict(self) -> dict[str, Any]:
|
|
306
|
+
return {
|
|
307
|
+
"provider": self.provider,
|
|
308
|
+
"applied": self.apply,
|
|
309
|
+
"orphan_max_age_s": self.orphan_max_age_s,
|
|
310
|
+
"stopped_ttl_s": self.stopped_ttl_s,
|
|
311
|
+
"instances_seen": self.seen,
|
|
312
|
+
"tracked": self.tracked,
|
|
313
|
+
"actions": [a.to_dict() for a in self.actions],
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def list_jambu_instances(provider) -> list[Instance]:
|
|
318
|
+
provider.require_capability("list_instances")
|
|
319
|
+
# The adapter already filters by label; filter again so a sloppy adapter
|
|
320
|
+
# can never put someone else's machine in front of the sweep.
|
|
321
|
+
return [i for i in provider.list_instances() if is_jambu_label(i.label)]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def sweep(
|
|
325
|
+
provider,
|
|
326
|
+
*,
|
|
327
|
+
orphan_max_age_s: Optional[float],
|
|
328
|
+
stopped_ttl_s: Optional[float],
|
|
329
|
+
apply: bool = False,
|
|
330
|
+
registry: Optional[SweepRegistry] = None,
|
|
331
|
+
tracked: Optional[set[str]] = None,
|
|
332
|
+
events: Optional[list[EventLog]] = None,
|
|
333
|
+
echo=None,
|
|
334
|
+
now: Optional[float] = None,
|
|
335
|
+
) -> SweepResult:
|
|
336
|
+
"""Plan (and with ``apply`` perform) the cleanup of untracked instances.
|
|
337
|
+
|
|
338
|
+
``tracked`` are the ids a project owns; they are left entirely to that
|
|
339
|
+
project's own guard.
|
|
340
|
+
"""
|
|
341
|
+
now = now if now is not None else utc_now()
|
|
342
|
+
registry = registry or SweepRegistry()
|
|
343
|
+
sinks = list(events or [])
|
|
344
|
+
say = echo or (lambda _m: None)
|
|
345
|
+
tracked = set(tracked or ())
|
|
346
|
+
|
|
347
|
+
instances = list_jambu_instances(provider)
|
|
348
|
+
result = SweepResult(
|
|
349
|
+
provider=provider.name,
|
|
350
|
+
apply=apply,
|
|
351
|
+
orphan_max_age_s=orphan_max_age_s,
|
|
352
|
+
stopped_ttl_s=stopped_ttl_s,
|
|
353
|
+
seen=len(instances),
|
|
354
|
+
)
|
|
355
|
+
present: set[str] = set()
|
|
356
|
+
|
|
357
|
+
for instance in instances:
|
|
358
|
+
key = registry.key(provider.name, instance.id)
|
|
359
|
+
if instance.id in tracked:
|
|
360
|
+
# Its project measures stopped_ttl itself. Dropping our sighting
|
|
361
|
+
# means the clock starts fresh should it ever become an orphan.
|
|
362
|
+
result.tracked += 1
|
|
363
|
+
continue
|
|
364
|
+
present.add(key)
|
|
365
|
+
|
|
366
|
+
if instance.state is InstanceState.STOPPED:
|
|
367
|
+
stopped_for = now - registry.first_seen_stopped(key, now)
|
|
368
|
+
if stopped_ttl_s is not None and stopped_for > stopped_ttl_s:
|
|
369
|
+
result.actions.append(
|
|
370
|
+
SweepAction(
|
|
371
|
+
instance,
|
|
372
|
+
policy.ACTION_DESTROY,
|
|
373
|
+
policy.REASON_STOPPED_TTL,
|
|
374
|
+
stopped_seconds=stopped_for,
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
continue
|
|
378
|
+
|
|
379
|
+
registry.forget(key) # running again (or never stopped)
|
|
380
|
+
if not instance.state.is_billable_compute or orphan_max_age_s is None:
|
|
381
|
+
continue
|
|
382
|
+
if not instance.created_at:
|
|
383
|
+
continue # no age to judge by; never guess on a running machine
|
|
384
|
+
age = now - instance.created_at
|
|
385
|
+
if age <= orphan_max_age_s:
|
|
386
|
+
continue
|
|
387
|
+
# Nobody knows whether a running orphan is in use, so it is only ever
|
|
388
|
+
# stopped: the disk survives and stopped_ttl removes it later.
|
|
389
|
+
if provider.capabilities.stop:
|
|
390
|
+
action, reason = policy.ACTION_STOP, REASON_ORPHAN_AGE
|
|
391
|
+
else:
|
|
392
|
+
action, reason = policy.ACTION_NONE, REASON_STOP_UNSUPPORTED
|
|
393
|
+
result.actions.append(SweepAction(instance, action, reason, age_seconds=age))
|
|
394
|
+
|
|
395
|
+
registry.retain(provider.name, present)
|
|
396
|
+
|
|
397
|
+
for planned in result.actions:
|
|
398
|
+
_describe(planned, apply, say)
|
|
399
|
+
if not apply or planned.action == policy.ACTION_NONE:
|
|
400
|
+
continue
|
|
401
|
+
try:
|
|
402
|
+
if planned.action == policy.ACTION_DESTROY:
|
|
403
|
+
provider.destroy(planned.instance)
|
|
404
|
+
registry.forget(registry.key(provider.name, planned.instance.id))
|
|
405
|
+
else:
|
|
406
|
+
provider.stop(planned.instance)
|
|
407
|
+
planned.applied = True
|
|
408
|
+
except ProviderError as exc:
|
|
409
|
+
planned.error = str(exc)
|
|
410
|
+
say(f"! {planned.action} {planned.instance.id} failed: {exc}")
|
|
411
|
+
for sink in sinks:
|
|
412
|
+
_emit(sink, planned)
|
|
413
|
+
|
|
414
|
+
registry.save()
|
|
415
|
+
return result
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _describe(planned: SweepAction, apply: bool, say) -> None:
|
|
419
|
+
from .durations import format_duration
|
|
420
|
+
|
|
421
|
+
if planned.action == policy.ACTION_NONE:
|
|
422
|
+
say(
|
|
423
|
+
f"! orphan {planned.instance.id} ({planned.instance.label}) has run for "
|
|
424
|
+
f"{format_duration(planned.age_seconds)}, but the provider cannot stop it"
|
|
425
|
+
)
|
|
426
|
+
return
|
|
427
|
+
if planned.stopped_seconds is not None:
|
|
428
|
+
why = f"stopped for {format_duration(planned.stopped_seconds)}"
|
|
429
|
+
else:
|
|
430
|
+
why = f"running for {format_duration(planned.age_seconds)}"
|
|
431
|
+
verb = planned.action if apply else f"would {planned.action}"
|
|
432
|
+
say(
|
|
433
|
+
f"- {verb} orphan {planned.instance.id} ({planned.instance.label}): "
|
|
434
|
+
f"{planned.reason}, {why}"
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _emit(sink: EventLog, planned: SweepAction) -> None:
|
|
439
|
+
detail = planned.to_dict()
|
|
440
|
+
detail.pop("instance_id")
|
|
441
|
+
sink.emit(SWEEP_ACTION, instance_id=planned.instance.id, source="sweep", **detail)
|
|
442
|
+
if not planned.applied:
|
|
443
|
+
return
|
|
444
|
+
name = ev.INSTANCE_DESTROYED if planned.action == policy.ACTION_DESTROY else ev.INSTANCE_STOPPED
|
|
445
|
+
sink.emit(name, instance_id=planned.instance.id, reason=planned.reason, source="sweep")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def tracked_by_projects(paths: list[Path]) -> dict[tuple[str, str], Path]:
|
|
449
|
+
"""(provider, instance id) -> config path, from state files only (no API calls)."""
|
|
450
|
+
out: dict[tuple[str, str], Path] = {}
|
|
451
|
+
for path in paths:
|
|
452
|
+
state = _state_beside(path)
|
|
453
|
+
if state is not None and state.instance_id and state.provider:
|
|
454
|
+
out[(state.provider, state.instance_id)] = Path(path)
|
|
455
|
+
return out
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def account_rows(
|
|
459
|
+
instances: list[Instance],
|
|
460
|
+
tracked: dict[str, Path],
|
|
461
|
+
now: Optional[float] = None,
|
|
462
|
+
) -> list[dict[str, Any]]:
|
|
463
|
+
"""``tracked`` maps instance id -> the config path of the project owning it."""
|
|
464
|
+
now = now if now is not None else utc_now()
|
|
465
|
+
return [
|
|
466
|
+
{
|
|
467
|
+
"instance_id": i.id,
|
|
468
|
+
"label": i.label,
|
|
469
|
+
"state": i.state.value,
|
|
470
|
+
"gpu": f"{i.gpu_count}x {i.gpu_name}".strip() if i.gpu_name else "",
|
|
471
|
+
"hourly_cost_usd": i.hourly_cost_usd,
|
|
472
|
+
"created_at": i.created_at,
|
|
473
|
+
"age_seconds": round(max(0.0, now - i.created_at), 1) if i.created_at else None,
|
|
474
|
+
"tracked": i.id in tracked,
|
|
475
|
+
"project": str(tracked[i.id]) if i.id in tracked else None,
|
|
476
|
+
}
|
|
477
|
+
for i in instances
|
|
478
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""model.id -> vLLM ``--tool-call-parser`` heuristics.
|
|
2
|
+
|
|
3
|
+
Lives in ``core`` (not ``runtime``) because both the vLLM runtime and
|
|
4
|
+
``core/model_inspect.py`` need it, and ``core`` must not depend on
|
|
5
|
+
``runtime`` (Provider != Runtime != Core stays a one-way dependency).
|
|
6
|
+
|
|
7
|
+
Source: https://docs.vllm.ai/en/latest/features/tool_calling/ - keep this in
|
|
8
|
+
sync when vLLM adds parsers; override with runtime.tool_call_parser for
|
|
9
|
+
anything not recognized here (fine-tunes, quantized repos, etc.).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
_TOOL_PARSER_HINTS: tuple[tuple[str, str], ...] = (
|
|
15
|
+
("llama-4", "llama4_pythonic"),
|
|
16
|
+
("llama4", "llama4_pythonic"),
|
|
17
|
+
("llama-3", "llama3_json"),
|
|
18
|
+
("llama3", "llama3_json"),
|
|
19
|
+
("hermes", "hermes"),
|
|
20
|
+
("qwen2.5", "hermes"),
|
|
21
|
+
("qwen-2.5", "hermes"),
|
|
22
|
+
("qwen3-coder", "qwen3_xml"),
|
|
23
|
+
("qwen3_coder", "qwen3_xml"),
|
|
24
|
+
# Qwen3.5/3.6/3.8 (hybrid Gated-DeltaNet architecture, vLLM >=0.17) emit
|
|
25
|
+
# tool calls in the SAME XML function/parameter format as Qwen3-Coder, not
|
|
26
|
+
# Hermes' JSON-in-XML format - confirmed empirically on
|
|
27
|
+
# empero-ai/Qwythos-9B-v2 (Qwen3.5-based): with --tool-call-parser hermes
|
|
28
|
+
# the model's <tool_call><function=x><parameter=y>...</tool_call> output
|
|
29
|
+
# landed as inert text in `content`; switching to qwen3_xml parses it into
|
|
30
|
+
# a real `tool_calls` array. Applies to this whole "point release" Qwen3.x
|
|
31
|
+
# family, unlike the original dash-versioned Qwen3 (e.g. Qwen3-8B), which
|
|
32
|
+
# does use hermes per Qwen's own docs.
|
|
33
|
+
("qwen3.8", "qwen3_xml"),
|
|
34
|
+
("qwen3_8", "qwen3_xml"),
|
|
35
|
+
("qwen3.6", "qwen3_xml"),
|
|
36
|
+
("qwen3_6", "qwen3_xml"),
|
|
37
|
+
("qwen3.5", "qwen3_xml"),
|
|
38
|
+
("qwen3_5", "qwen3_xml"),
|
|
39
|
+
("qwen3", "hermes"), # original dash-versioned Qwen3 (e.g. Qwen3-8B): hermes
|
|
40
|
+
("mistral", "mistral"),
|
|
41
|
+
("mixtral", "mistral"),
|
|
42
|
+
("granite-4", "granite4"),
|
|
43
|
+
("granite4", "granite4"),
|
|
44
|
+
("granite", "granite"),
|
|
45
|
+
("internlm", "internlm"),
|
|
46
|
+
("jamba", "jamba"),
|
|
47
|
+
("xlam", "xlam"),
|
|
48
|
+
("toolace", "pythonic"),
|
|
49
|
+
("deepseek-v3.1", "deepseek_v31"),
|
|
50
|
+
("deepseek-v3", "deepseek_v3"),
|
|
51
|
+
("glm-4.5", "glm45"),
|
|
52
|
+
("glm-4.7", "glm47"),
|
|
53
|
+
("functiongemma", "functiongemma"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def guess_tool_call_parser(model_id: str) -> "str | None":
|
|
58
|
+
"""Best-effort ``--tool-call-parser`` guess from a HuggingFace repo id."""
|
|
59
|
+
lowered = model_id.lower()
|
|
60
|
+
for needle, parser in _TOOL_PARSER_HINTS:
|
|
61
|
+
if needle in lowered:
|
|
62
|
+
return parser
|
|
63
|
+
return None
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""The provider contract (spec section 6).
|
|
2
|
+
|
|
3
|
+
Every provider implements exactly this interface. Provider-specific APIs,
|
|
4
|
+
SDKs, identifiers, pricing structures and authentication MUST NOT leak
|
|
5
|
+
through it. The core branches on ``capabilities``, never on ``name``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from ..core.config import RuntimeConfig
|
|
15
|
+
from ..core.credentials import CredentialResolver
|
|
16
|
+
from ..core.errors import UnsupportedCapabilityError
|
|
17
|
+
from ..core.models import (
|
|
18
|
+
ComputeSpec,
|
|
19
|
+
Execution,
|
|
20
|
+
Instance,
|
|
21
|
+
InstanceStatus,
|
|
22
|
+
LogStream,
|
|
23
|
+
Offer,
|
|
24
|
+
ProviderCapabilities,
|
|
25
|
+
ValidationResult,
|
|
26
|
+
Workload,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class WatchdogSpec:
|
|
32
|
+
"""What the adapter contributes to the remote lifecycle watchdog.
|
|
33
|
+
|
|
34
|
+
The *policy* lives in the core; the adapter only supplies the provider
|
|
35
|
+
calls the watchdog needs to shut its own machine down (spec section 14).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# Python source executed inside the watchdog. Must define
|
|
39
|
+
# def provider_stop(ctx) -> None
|
|
40
|
+
# def provider_destroy(ctx) -> None
|
|
41
|
+
# using the stdlib only.
|
|
42
|
+
actions_source: str
|
|
43
|
+
# Environment variables the instance needs for those calls (credentials).
|
|
44
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
45
|
+
# Static values available to the actions through ctx["provider"].
|
|
46
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GPUProvider(ABC):
|
|
50
|
+
"""Stable internal contract implemented by every adapter."""
|
|
51
|
+
|
|
52
|
+
name: str = "abstract"
|
|
53
|
+
display_name: str = "Abstract"
|
|
54
|
+
required_credentials: list[str] = []
|
|
55
|
+
capabilities: ProviderCapabilities = ProviderCapabilities()
|
|
56
|
+
# Runtime engine -> the image to use when compute.image is unset. A
|
|
57
|
+
# provider names the copy its hosts already cache, so a first boot does
|
|
58
|
+
# not wait on a multi-GB registry pull.
|
|
59
|
+
preferred_images: dict[str, str] = {}
|
|
60
|
+
|
|
61
|
+
def __init__(self, config: RuntimeConfig, credentials: CredentialResolver) -> None:
|
|
62
|
+
self.config = config
|
|
63
|
+
self.credentials = credentials
|
|
64
|
+
|
|
65
|
+
# -- contract -----------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def validate(self, config: RuntimeConfig) -> ValidationResult:
|
|
69
|
+
"""Check credentials, capabilities and the requested spec. No side effects."""
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def setup(self, spec: ComputeSpec) -> Instance:
|
|
73
|
+
"""Provision compute matching ``spec`` and return a normalized handle."""
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def start(self, instance: Instance) -> Instance:
|
|
77
|
+
"""Bring a stopped instance back to running."""
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def run(self, instance: Instance, workload: Workload) -> Execution:
|
|
81
|
+
"""Execute a workload on the instance (requires ``capabilities.remote_exec``)."""
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def status(self, instance: Instance) -> InstanceStatus:
|
|
85
|
+
"""Observed provider-side state, normalized."""
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
def stop(self, instance: Instance) -> None:
|
|
89
|
+
"""Stop compute. MUST be idempotent (spec section 23)."""
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def destroy(self, instance: Instance) -> None:
|
|
93
|
+
"""Destroy the remote resource. MUST be idempotent."""
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
def logs(self, instance: Instance) -> LogStream:
|
|
97
|
+
"""Provider-side logs for the instance."""
|
|
98
|
+
|
|
99
|
+
# -- optional, capability-gated ----------------------------------------
|
|
100
|
+
|
|
101
|
+
def find_offers(self, spec: ComputeSpec, limit: int = 10) -> list[Offer]:
|
|
102
|
+
"""Discover purchasable capacity (requires ``capabilities.pricing_query``)."""
|
|
103
|
+
raise UnsupportedCapabilityError(f"{self.name} cannot query offers")
|
|
104
|
+
|
|
105
|
+
def list_instances(self) -> list[Instance]:
|
|
106
|
+
"""Every instance of this tool in the account (requires ``capabilities.list_instances``).
|
|
107
|
+
|
|
108
|
+
Returns only instances whose label starts with ``JAMBU_LABEL_PREFIX``;
|
|
109
|
+
anything else in the account is not ours to report or touch.
|
|
110
|
+
"""
|
|
111
|
+
raise UnsupportedCapabilityError(f"{self.name} cannot list instances")
|
|
112
|
+
|
|
113
|
+
def watchdog_spec(self, spec_or_instance: Any) -> Optional[WatchdogSpec]:
|
|
114
|
+
"""Provider calls the remote watchdog uses to stop/destroy itself."""
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
def refresh(self, instance: Instance) -> Instance:
|
|
118
|
+
"""Re-read provider data into the normalized handle (ports, ip, state)."""
|
|
119
|
+
status = self.status(instance)
|
|
120
|
+
return status.instance or instance
|
|
121
|
+
|
|
122
|
+
# -- helpers ------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def require_capability(self, name: str) -> None:
|
|
125
|
+
if not getattr(self.capabilities, name, False):
|
|
126
|
+
raise UnsupportedCapabilityError(
|
|
127
|
+
f"provider '{self.name}' does not support capability '{name}'"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def credential_status(self) -> dict[str, bool]:
|
|
131
|
+
return {name: bool(self.credentials.get(name)) for name in self.required_credentials}
|
|
132
|
+
|
|
133
|
+
def is_configured(self) -> bool:
|
|
134
|
+
return all(self.credential_status().values()) if self.required_credentials else True
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str: # pragma: no cover - debug helper
|
|
137
|
+
return f"<{type(self).__name__} name={self.name!r}>"
|