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
gpu4agents/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """GPU for Agents: GPUs your agent rents on its own, billed only while they work."""
2
+
3
+ __version__ = "0.5.0"
4
+
5
+ __all__ = ["__version__"]
gpu4agents/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1 @@
1
+ """Agent integrations: the local MCP server, the bundled skill and their installer."""
@@ -0,0 +1,173 @@
1
+ """`gpu agent install`: put the skill and the MCP server where agents look for them.
2
+
3
+ One skill copy, in ``.claude/skills/gpu/`` (or ``~/.claude/skills/gpu/``),
4
+ serves both Claude Code and Cursor, which also reads Claude's skill
5
+ directories. The MCP server is registered per client:
6
+
7
+ - claude: ``.mcp.json`` in the project; with --global, ``claude mcp add -s user``
8
+ - cursor: ``.cursor/mcp.json`` in the project, or ``~/.cursor/mcp.json``
9
+ - desktop: Claude Desktop's ``claude_desktop_config.json``, pinned to this
10
+ project, plus a zip of the skill to upload in Settings
11
+
12
+ Existing config files are merged, never replaced: only the ``gpu`` server
13
+ entry is written.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import platform
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ import zipfile
25
+ from pathlib import Path
26
+ from typing import Any, Optional
27
+
28
+ from ..core.errors import ConfigError
29
+ from ..core.paths import gpu_home
30
+
31
+ SERVER_NAME = "gpu"
32
+ SKILL_NAME = "gpu"
33
+ TARGETS = ("claude", "cursor", "desktop")
34
+
35
+
36
+ def skill_source() -> Path:
37
+ return Path(__file__).parent / "skill" / "SKILL.md"
38
+
39
+
40
+ def skill_text() -> str:
41
+ return skill_source().read_text()
42
+
43
+
44
+ def gpu_command(portable: bool) -> list[str]:
45
+ """How a client starts `gpu`.
46
+
47
+ A project file that may be committed says plain ``gpu`` when it is on
48
+ PATH; anything machine-local uses an absolute path, because GUI apps
49
+ (Claude Desktop, Cursor) do not get the shell's PATH.
50
+ """
51
+ found = shutil.which("gpu")
52
+ if found and portable:
53
+ return ["gpu"]
54
+ if found:
55
+ return [str(Path(found).resolve())]
56
+ return [sys.executable, "-m", "gpu4agents"]
57
+
58
+
59
+ def desktop_config_path() -> Path:
60
+ system = platform.system()
61
+ if system == "Darwin":
62
+ return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
63
+ if system == "Windows":
64
+ return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / "Claude" / "claude_desktop_config.json"
65
+ base = os.environ.get("XDG_CONFIG_HOME")
66
+ return (Path(base) if base else Path.home() / ".config") / "Claude" / "claude_desktop_config.json"
67
+
68
+
69
+ def merge_server(path: Path, entry: dict[str, Any]) -> str:
70
+ """Write ``mcpServers.gpu`` into a JSON config. Returns created/updated/unchanged."""
71
+ data: dict[str, Any] = {}
72
+ if path.is_file():
73
+ text = path.read_text().strip()
74
+ if text:
75
+ try:
76
+ data = json.loads(text)
77
+ except ValueError as exc:
78
+ raise ConfigError(f"{path} is not valid JSON ({exc}); fix it or add the gpu server by hand") from exc
79
+ if not isinstance(data, dict):
80
+ raise ConfigError(f"{path} does not hold a JSON object")
81
+ servers = data.setdefault("mcpServers", {})
82
+ if not isinstance(servers, dict):
83
+ raise ConfigError(f"{path}: mcpServers is not an object")
84
+ if servers.get(SERVER_NAME) == entry:
85
+ return "unchanged"
86
+ action = "updated" if SERVER_NAME in servers else "created"
87
+ servers[SERVER_NAME] = entry
88
+ path.parent.mkdir(parents=True, exist_ok=True)
89
+ path.write_text(json.dumps(data, indent=2) + "\n")
90
+ return action
91
+
92
+
93
+ def write_skill(directory: Path) -> str:
94
+ target = directory / SKILL_NAME / "SKILL.md"
95
+ text = skill_text()
96
+ if target.is_file() and target.read_text() == text:
97
+ return "unchanged"
98
+ action = "updated" if target.exists() else "created"
99
+ target.parent.mkdir(parents=True, exist_ok=True)
100
+ target.write_text(text)
101
+ return action
102
+
103
+
104
+ def write_skill_zip(path: Path) -> str:
105
+ """The skill as Claude Desktop / claude.ai expect an upload: a folder in a zip."""
106
+ path.parent.mkdir(parents=True, exist_ok=True)
107
+ with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
108
+ archive.writestr(f"{SKILL_NAME}/SKILL.md", skill_text())
109
+ return "created"
110
+
111
+
112
+ def install(
113
+ targets: list[str],
114
+ project_dir: Path,
115
+ global_scope: bool = False,
116
+ run=subprocess.run,
117
+ ) -> dict[str, Any]:
118
+ """Install for each target. ``run`` is a seam for tests (``claude mcp add``)."""
119
+ unknown = [t for t in targets if t not in TARGETS]
120
+ if unknown:
121
+ raise ConfigError(f"unknown target {', '.join(unknown)} (choose from {', '.join(TARGETS)})")
122
+ project_dir = project_dir.resolve()
123
+ done: list[dict[str, Any]] = []
124
+ notes: list[str] = []
125
+
126
+ if "claude" in targets or "cursor" in targets:
127
+ skills = (Path.home() if global_scope else project_dir) / ".claude" / "skills"
128
+ done.append({"what": "skill (Claude Code, Cursor)", "path": str(skills / SKILL_NAME / "SKILL.md"), "action": write_skill(skills)})
129
+
130
+ if "claude" in targets:
131
+ if global_scope:
132
+ command = gpu_command(portable=False)
133
+ argv = ["claude", "mcp", "add", "--scope", "user", SERVER_NAME, "--", *command, "mcp"]
134
+ if shutil.which("claude"):
135
+ # `add` refuses an existing name; replace it so a reinstall picks up a new path.
136
+ run(["claude", "mcp", "remove", "--scope", "user", SERVER_NAME], capture_output=True, text=True)
137
+ proc = run(argv, capture_output=True, text=True)
138
+ if proc.returncode != 0:
139
+ raise ConfigError(f"`{' '.join(argv)}` failed: {(proc.stderr or proc.stdout).strip()}")
140
+ done.append({"what": "MCP server (Claude Code, user scope)", "path": "claude mcp", "action": "created"})
141
+ else:
142
+ notes.append(f"claude CLI not found; register the server with: {' '.join(argv)}")
143
+ else:
144
+ command = gpu_command(portable=True)
145
+ entry = {"command": command[0], "args": [*command[1:], "mcp"]}
146
+ path = project_dir / ".mcp.json"
147
+ done.append({"what": "MCP server (Claude Code)", "path": str(path), "action": merge_server(path, entry)})
148
+ notes.append("Claude Code asks once to approve the project's .mcp.json servers")
149
+
150
+ if "cursor" in targets:
151
+ command = gpu_command(portable=not global_scope)
152
+ entry = {
153
+ "type": "stdio",
154
+ "command": command[0],
155
+ "args": [*command[1:], "mcp", "--project", "${workspaceFolder}"],
156
+ }
157
+ path = (Path.home() if global_scope else project_dir) / ".cursor" / "mcp.json"
158
+ done.append({"what": "MCP server (Cursor)", "path": str(path), "action": merge_server(path, entry)})
159
+
160
+ if "desktop" in targets:
161
+ command = gpu_command(portable=False)
162
+ entry = {"command": command[0], "args": [*command[1:], "mcp", "--project", str(project_dir)]}
163
+ path = desktop_config_path()
164
+ done.append({"what": "MCP server (Claude Desktop)", "path": str(path), "action": merge_server(path, entry)})
165
+ archive = gpu_home() / "gpu-skill.zip"
166
+ done.append({"what": "skill zip (Claude Desktop)", "path": str(archive), "action": write_skill_zip(archive)})
167
+ notes.append(f"Claude Desktop: restart it, then upload {archive} under Settings > Capabilities > Skills")
168
+ notes.append(
169
+ "Claude Desktop does not get your shell's environment: keep VAST_API_KEY in the "
170
+ "project .env or ~/.gpu/credentials"
171
+ )
172
+
173
+ return {"installed": done, "notes": notes}
@@ -0,0 +1,193 @@
1
+ """`gpu setup` in the background, for callers that cannot block for 15 minutes.
2
+
3
+ An MCP tool call times out long before a first setup finishes, so the MCP
4
+ server starts setup as a detached job and reports on it when asked again.
5
+ The job outlives the server: a client that disconnects mid-setup leaves a
6
+ machine that either finishes booting (and is then stopped by its watchdog
7
+ when nobody uses it) or is destroyed by setup's own failure cleanup.
8
+
9
+ Files, under the project's ``.gpu/jobs/``:
10
+
11
+ - ``setup.json`` pid, argv and start time
12
+ - ``setup.out`` the command's stdout (one JSON document with --json)
13
+ - ``setup.err`` its stderr (progress is suppressed; errors land here)
14
+ - ``setup.rc`` the exit code, written last, when the command ends
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import signal
22
+ import subprocess
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Any, Optional, Sequence
27
+
28
+ from ..core.paths import project_state_dir
29
+
30
+ JOB_DIRNAME = "jobs"
31
+ ERROR_TAIL_LINES = 40
32
+
33
+
34
+ def _alive(pid: int) -> bool:
35
+ if os.name == "nt": # signal 0 is CTRL_C_EVENT on Windows; trust the rc file
36
+ return True
37
+ try:
38
+ os.kill(pid, 0)
39
+ except ProcessLookupError:
40
+ return False
41
+ except PermissionError:
42
+ return True
43
+ return True
44
+
45
+
46
+ def _tail(path: Path, lines: int) -> list[str]:
47
+ try:
48
+ text = path.read_text(errors="replace")
49
+ except OSError:
50
+ return []
51
+ return [line for line in text.splitlines() if line.strip()][-lines:]
52
+
53
+
54
+ class Job:
55
+ """One named background command per project (only ``setup`` today)."""
56
+
57
+ def __init__(self, project_dir: Path, name: str = "setup") -> None:
58
+ self.dir = project_state_dir(Path(project_dir)) / JOB_DIRNAME
59
+ self.name = name
60
+ self.meta_path = self.dir / f"{name}.json"
61
+ self.out_path = self.dir / f"{name}.out"
62
+ self.err_path = self.dir / f"{name}.err"
63
+ self.rc_path = self.dir / f"{name}.rc"
64
+ self._proc: Optional[subprocess.Popen] = None
65
+
66
+ def meta(self) -> Optional[dict[str, Any]]:
67
+ try:
68
+ return json.loads(self.meta_path.read_text())
69
+ except (OSError, ValueError):
70
+ return None
71
+
72
+ def exit_code(self) -> Optional[int]:
73
+ try:
74
+ return int(self.rc_path.read_text().strip())
75
+ except (OSError, ValueError):
76
+ return None
77
+
78
+ def running(self) -> bool:
79
+ meta = self.meta()
80
+ if meta is None or self.exit_code() is not None:
81
+ return False
82
+ if self._proc is not None and self._proc.poll() is not None:
83
+ return False # reaped here: our own child never lingers as a zombie
84
+ return _alive(int(meta["pid"]))
85
+
86
+ def start(self, argv: Sequence[str], cwd: Path) -> dict[str, Any]:
87
+ """Start ``argv`` detached. The caller checks ``running()`` first."""
88
+ self.dir.mkdir(parents=True, exist_ok=True)
89
+ for path in (self.out_path, self.err_path, self.rc_path):
90
+ path.unlink(missing_ok=True)
91
+ runner = [sys.executable, "-m", "gpu4agents.agent.jobs", str(self.dir), self.name, "--", *argv]
92
+ kwargs: dict[str, Any] = {}
93
+ if os.name == "nt":
94
+ kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
95
+ subprocess, "CREATE_NEW_PROCESS_GROUP", 0
96
+ )
97
+ else:
98
+ kwargs["start_new_session"] = True
99
+ self._proc = subprocess.Popen(
100
+ runner,
101
+ cwd=str(cwd),
102
+ stdin=subprocess.DEVNULL,
103
+ stdout=subprocess.DEVNULL,
104
+ stderr=subprocess.DEVNULL,
105
+ **kwargs,
106
+ )
107
+ meta = {"pid": self._proc.pid, "argv": list(argv), "started_at": time.time()}
108
+ self.meta_path.write_text(json.dumps(meta))
109
+ return meta
110
+
111
+ def wait(self, seconds: float, poll: float = 2.0) -> bool:
112
+ """Wait up to ``seconds`` for the job to end. True when it has."""
113
+ deadline = time.monotonic() + max(0.0, seconds)
114
+ while self.running():
115
+ if time.monotonic() >= deadline:
116
+ return False
117
+ time.sleep(min(poll, max(0.05, deadline - time.monotonic())))
118
+ return True
119
+
120
+ def cancel(self, timeout: float = 120.0) -> bool:
121
+ """Interrupt a running job like Ctrl-C and wait for it to clean up.
122
+
123
+ ``gpu setup`` destroys the machine it was booting when interrupted, so
124
+ a cancelled setup leaves nothing billing. True when the job has ended.
125
+ """
126
+ meta = self.meta()
127
+ if meta is None or not self.running():
128
+ return True
129
+ pid = int(meta["pid"])
130
+ try:
131
+ if os.name == "nt":
132
+ os.kill(pid, signal.CTRL_BREAK_EVENT)
133
+ else:
134
+ os.killpg(pid, signal.SIGINT) # the runner leads the group (start_new_session)
135
+ except (ProcessLookupError, PermissionError, OSError):
136
+ return not self.running()
137
+ return self.wait(timeout, poll=1.0)
138
+
139
+ def report(self) -> dict[str, Any]:
140
+ """``status`` is none, running, succeeded, failed or lost (died without a code)."""
141
+ meta = self.meta()
142
+ if meta is None:
143
+ return {"status": "none"}
144
+ started = float(meta.get("started_at") or 0)
145
+ report: dict[str, Any] = {
146
+ "pid": meta.get("pid"),
147
+ "started_at": started,
148
+ "elapsed_s": round(time.time() - started, 1) if started else None,
149
+ }
150
+ if self.running():
151
+ report["status"] = "running"
152
+ return report
153
+ code = self.exit_code()
154
+ if code is None:
155
+ report["status"] = "lost"
156
+ else:
157
+ report["status"] = "succeeded" if code == 0 else "failed"
158
+ report["exit_code"] = code
159
+ report["finished_at"] = self.rc_path.stat().st_mtime
160
+ report["elapsed_s"] = round(report["finished_at"] - started, 1) if started else None
161
+ try:
162
+ report["result"] = json.loads(self.out_path.read_text())
163
+ except (OSError, ValueError):
164
+ pass
165
+ if report["status"] != "succeeded":
166
+ report["stderr_tail"] = _tail(self.err_path, ERROR_TAIL_LINES)
167
+ return report
168
+
169
+
170
+ def _main(argv: list[str]) -> int:
171
+ """``python -m gpu4agents.agent.jobs DIR NAME -- CMD...``: run CMD, record its exit code."""
172
+ directory, name = Path(argv[0]), argv[1]
173
+ command = argv[3:] if len(argv) > 2 and argv[2] == "--" else argv[2:]
174
+ # A cancel sends SIGINT to the whole group. The runner must outlive it to
175
+ # record the exit code, and must not kill the command while it cleans up.
176
+ # A Python handler (not SIG_IGN) resets to the default in the child on exec.
177
+ signal.signal(signal.SIGINT, lambda signum, frame: None)
178
+ with open(directory / f"{name}.out", "wb") as out, open(directory / f"{name}.err", "wb") as err:
179
+ try:
180
+ proc = subprocess.Popen(command, stdout=out, stderr=err, stdin=subprocess.DEVNULL)
181
+ except OSError as exc:
182
+ err.write(f"error: could not start {command[0]}: {exc}\n".encode())
183
+ code = 127
184
+ else:
185
+ code = proc.wait()
186
+ tmp = directory / f"{name}.rc.tmp"
187
+ tmp.write_text(str(code))
188
+ tmp.replace(directory / f"{name}.rc")
189
+ return code
190
+
191
+
192
+ if __name__ == "__main__":
193
+ sys.exit(_main(sys.argv[1:]))