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,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,4 @@
1
+ from .base import GPUProvider, WatchdogSpec
2
+ from .registry import ProviderRegistry, registry
3
+
4
+ __all__ = ["GPUProvider", "WatchdogSpec", "ProviderRegistry", "registry"]
@@ -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}>"