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/agent/mcp.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""`gpu mcp`: a local Model Context Protocol server over stdio.
|
|
2
|
+
|
|
3
|
+
The client (Claude Code, Claude Desktop, Cursor, ...) starts it as a child
|
|
4
|
+
process and talks newline-delimited JSON-RPC on stdin/stdout. Nothing is
|
|
5
|
+
hosted. Every tool runs the `gpu` CLI with ``--json`` in the project
|
|
6
|
+
directory, so an agent gets exactly the contract an application gets
|
|
7
|
+
(docs/INTEGRATION.md): same output, same exit codes, same file lock.
|
|
8
|
+
|
|
9
|
+
``gpu_setup`` is the one tool that would outlast a client's tool timeout. It
|
|
10
|
+
starts setup as a background job (see jobs.py) and waits up to
|
|
11
|
+
``wait_seconds``; calling it again re-attaches to the running job instead of
|
|
12
|
+
starting another one.
|
|
13
|
+
|
|
14
|
+
stdout carries the protocol and nothing else: everything a tool prints goes
|
|
15
|
+
through a subprocess pipe, and ``sys.stdout`` is pointed at stderr while the
|
|
16
|
+
server runs.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import signal
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Callable, Optional
|
|
30
|
+
|
|
31
|
+
from .. import __version__
|
|
32
|
+
from ..core.config import find_config
|
|
33
|
+
from .jobs import Job
|
|
34
|
+
|
|
35
|
+
PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
|
|
36
|
+
SERVER_NAME = "gpu"
|
|
37
|
+
|
|
38
|
+
DEFAULT_SETUP_WAIT_S = 45
|
|
39
|
+
MAX_SETUP_WAIT_S = 300
|
|
40
|
+
DEFAULT_RUN_TIMEOUT_S = 600
|
|
41
|
+
CLI_TIMEOUT_S = 900
|
|
42
|
+
OUTPUT_TAIL_CHARS = 12_000
|
|
43
|
+
|
|
44
|
+
EXIT_MEANINGS = {
|
|
45
|
+
1: ("unexpected error", "read the error; retry later"),
|
|
46
|
+
2: ("config error", "fix jambu.yaml; gpu_setup(recreate=true) replaces a machine built from an old config"),
|
|
47
|
+
3: ("no instance or no endpoint", "call gpu_setup"),
|
|
48
|
+
4: ("provider API error", "retry with backoff"),
|
|
49
|
+
5: ("provider authentication failed", "the user must fix VAST_API_KEY"),
|
|
50
|
+
6: ("no capacity matches the config", "retry later, or relax compute.gpu / the budget in jambu.yaml"),
|
|
51
|
+
7: ("provisioning failed", "retry; the failed machine was already destroyed"),
|
|
52
|
+
8: ("provider timed out", "retry with backoff"),
|
|
53
|
+
9: ("the provider cannot do this", "change the config"),
|
|
54
|
+
10: ("over budget.max_hourly_cost_usd", "ask the user before raising the budget in jambu.yaml"),
|
|
55
|
+
11: ("the model server did not start", "read stderr (the server's log tail); the machine was destroyed"),
|
|
56
|
+
12: ("another gpu command holds this project's lock", "usually a setup: follow it with gpu_setup, or cancel it with gpu_stop"),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
INSTRUCTIONS = """\
|
|
60
|
+
gpu rents a cloud GPU (Vast.ai), serves a model on it with vLLM behind an
|
|
61
|
+
OpenAI-compatible API, and stops it by itself when nobody uses it.
|
|
62
|
+
|
|
63
|
+
Rules:
|
|
64
|
+
- Renting costs the user money. Before the first gpu_setup in a conversation,
|
|
65
|
+
tell the user the model, the price cap (gpu_offers shows prices) and ask.
|
|
66
|
+
- gpu_setup takes 5-15 minutes the first time. It returns status "running"
|
|
67
|
+
while it works: call gpu_setup again to keep waiting. Never start a new
|
|
68
|
+
project or recreate because it is slow.
|
|
69
|
+
- A stopped machine restarts in about a minute with the same key, but
|
|
70
|
+
base_url may change: always take it from the latest gpu_setup/gpu_connection.
|
|
71
|
+
- When the work is done, offer gpu_stop (keeps the disk, cheap) or
|
|
72
|
+
gpu_destroy (nothing billed). gpu_destroy needs confirm=true and the user's
|
|
73
|
+
explicit yes. Finish by checking gpu_ls for anything still running.
|
|
74
|
+
- Never print or ask for VAST_API_KEY. The endpoint api_key is per machine.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ToolError(Exception):
|
|
79
|
+
"""A tool failed in a way the agent can act on (reported with isError)."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, payload: dict[str, Any]) -> None:
|
|
82
|
+
super().__init__(payload.get("error", "error"))
|
|
83
|
+
self.payload = payload
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class CliResult:
|
|
88
|
+
returncode: int
|
|
89
|
+
stdout: str
|
|
90
|
+
stderr: str
|
|
91
|
+
data: Any = None
|
|
92
|
+
|
|
93
|
+
def error(self) -> dict[str, Any]:
|
|
94
|
+
lines = [line for line in self.stderr.splitlines() if line.strip()]
|
|
95
|
+
message = next(
|
|
96
|
+
(line.split("error:", 1)[1].strip() for line in reversed(lines) if "error:" in line),
|
|
97
|
+
lines[-1] if lines else f"gpu exited with code {self.returncode}",
|
|
98
|
+
)
|
|
99
|
+
meaning, hint = EXIT_MEANINGS.get(self.returncode, ("error", "read the error"))
|
|
100
|
+
payload: dict[str, Any] = {
|
|
101
|
+
"error": message,
|
|
102
|
+
"exit_code": self.returncode,
|
|
103
|
+
"meaning": meaning,
|
|
104
|
+
"next": hint,
|
|
105
|
+
}
|
|
106
|
+
if len(lines) > 1:
|
|
107
|
+
payload["stderr_tail"] = lines[-30:]
|
|
108
|
+
if isinstance(self.data, dict):
|
|
109
|
+
payload["output"] = self.data
|
|
110
|
+
return payload
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _tail_text(text: str, limit: int = OUTPUT_TAIL_CHARS) -> str:
|
|
114
|
+
return text if len(text) <= limit else "...[truncated]...\n" + text[-limit:]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _schema(properties: dict[str, Any], required: Optional[list[str]] = None) -> dict[str, Any]:
|
|
118
|
+
props = {
|
|
119
|
+
"project_dir": {
|
|
120
|
+
"type": "string",
|
|
121
|
+
"description": "Directory holding jambu.yaml (or below it). Defaults to the server's project.",
|
|
122
|
+
},
|
|
123
|
+
**properties,
|
|
124
|
+
}
|
|
125
|
+
schema: dict[str, Any] = {"type": "object", "properties": props, "additionalProperties": False}
|
|
126
|
+
if required:
|
|
127
|
+
schema["required"] = required
|
|
128
|
+
return schema
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class Tool:
|
|
133
|
+
name: str
|
|
134
|
+
title: str
|
|
135
|
+
description: str
|
|
136
|
+
input_schema: dict[str, Any]
|
|
137
|
+
handler: Callable[[dict[str, Any]], Any]
|
|
138
|
+
annotations: dict[str, Any] = field(default_factory=dict)
|
|
139
|
+
|
|
140
|
+
def describe(self) -> dict[str, Any]:
|
|
141
|
+
return {
|
|
142
|
+
"name": self.name,
|
|
143
|
+
"title": self.title,
|
|
144
|
+
"description": self.description,
|
|
145
|
+
"inputSchema": self.input_schema,
|
|
146
|
+
"annotations": {"title": self.title, **self.annotations},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Server:
|
|
151
|
+
def __init__(self, project: Optional[Path] = None, python: Optional[str] = None) -> None:
|
|
152
|
+
env_project = os.environ.get("GPU_PROJECT_DIR")
|
|
153
|
+
self.project = Path(project or env_project or os.getcwd()).expanduser()
|
|
154
|
+
self.python = python or sys.executable
|
|
155
|
+
self.jobs: dict[Path, Job] = {}
|
|
156
|
+
self.tools = {tool.name: tool for tool in self._tools()}
|
|
157
|
+
|
|
158
|
+
# -- plumbing ------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def _start_dir(self, args: dict[str, Any]) -> Path:
|
|
161
|
+
raw = args.get("project_dir") or self.project
|
|
162
|
+
return Path(raw).expanduser().resolve()
|
|
163
|
+
|
|
164
|
+
def _project_dir(self, args: dict[str, Any]) -> Path:
|
|
165
|
+
start = self._start_dir(args)
|
|
166
|
+
config = find_config(start)
|
|
167
|
+
if config is None:
|
|
168
|
+
raise ToolError(
|
|
169
|
+
{
|
|
170
|
+
"error": f"no jambu.yaml in {start} or any parent",
|
|
171
|
+
"exit_code": 2,
|
|
172
|
+
"next": "call gpu_init with a model in the project directory, or pass project_dir",
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
return config.parent
|
|
176
|
+
|
|
177
|
+
def _argv(self, args: list[str], json_output: bool = True) -> list[str]:
|
|
178
|
+
return [self.python, "-m", "gpu4agents", *(["--json"] if json_output else []), *args]
|
|
179
|
+
|
|
180
|
+
def cli(self, args: list[str], cwd: Path, timeout: float = CLI_TIMEOUT_S) -> CliResult:
|
|
181
|
+
try:
|
|
182
|
+
proc = subprocess.run(
|
|
183
|
+
self._argv(args),
|
|
184
|
+
cwd=str(cwd),
|
|
185
|
+
stdin=subprocess.DEVNULL,
|
|
186
|
+
capture_output=True,
|
|
187
|
+
text=True,
|
|
188
|
+
timeout=timeout,
|
|
189
|
+
)
|
|
190
|
+
except subprocess.TimeoutExpired as exc:
|
|
191
|
+
raise ToolError(
|
|
192
|
+
{"error": f"gpu {args[0]} did not finish within {int(timeout)}s", "exit_code": 8}
|
|
193
|
+
) from exc
|
|
194
|
+
result = CliResult(proc.returncode, proc.stdout, proc.stderr)
|
|
195
|
+
try:
|
|
196
|
+
result.data = json.loads(proc.stdout) if proc.stdout.strip() else None
|
|
197
|
+
except ValueError:
|
|
198
|
+
result.data = None
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
def _ok(self, result: CliResult) -> Any:
|
|
202
|
+
if result.returncode != 0:
|
|
203
|
+
raise ToolError(result.error())
|
|
204
|
+
return result.data
|
|
205
|
+
|
|
206
|
+
def _job(self, project_dir: Path) -> Job:
|
|
207
|
+
if project_dir not in self.jobs:
|
|
208
|
+
self.jobs[project_dir] = Job(project_dir)
|
|
209
|
+
return self.jobs[project_dir]
|
|
210
|
+
|
|
211
|
+
# -- tools ---------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def tool_init(self, args: dict[str, Any]) -> Any:
|
|
214
|
+
directory = self._start_dir(args)
|
|
215
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
argv = ["init", "--model", str(args["model"])]
|
|
217
|
+
if args.get("force"):
|
|
218
|
+
argv.append("--force")
|
|
219
|
+
self._ok(self.cli(argv, directory))
|
|
220
|
+
config = directory / "jambu.yaml"
|
|
221
|
+
text = config.read_text()
|
|
222
|
+
if args.get("max_hourly_cost_usd") is not None:
|
|
223
|
+
text = re.sub(
|
|
224
|
+
r"(max_hourly_cost_usd:\s*)[0-9.]+", rf"\g<1>{float(args['max_hourly_cost_usd']):.2f}", text
|
|
225
|
+
)
|
|
226
|
+
if args.get("min_vram_gb") is not None:
|
|
227
|
+
text = re.sub(r"(min_vram_gb:\s*)[0-9]+", rf"\g<1>{int(args['min_vram_gb'])}", text)
|
|
228
|
+
config.write_text(text)
|
|
229
|
+
return {
|
|
230
|
+
"config_path": str(config),
|
|
231
|
+
"model": args["model"],
|
|
232
|
+
"next": "gpu_validate, then gpu_offers to see prices",
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
def tool_validate(self, args: dict[str, Any]) -> Any:
|
|
236
|
+
return self._ok(self.cli(["validate"], self._project_dir(args)))
|
|
237
|
+
|
|
238
|
+
def tool_offers(self, args: dict[str, Any]) -> Any:
|
|
239
|
+
limit = int(args.get("limit") or 5)
|
|
240
|
+
return self._ok(self.cli(["offers", "--limit", str(limit)], self._project_dir(args)))
|
|
241
|
+
|
|
242
|
+
def tool_setup(self, args: dict[str, Any]) -> Any:
|
|
243
|
+
project_dir = self._project_dir(args)
|
|
244
|
+
job = self._job(project_dir)
|
|
245
|
+
wait = min(float(args.get("wait_seconds", DEFAULT_SETUP_WAIT_S)), MAX_SETUP_WAIT_S)
|
|
246
|
+
started = False
|
|
247
|
+
if not job.running():
|
|
248
|
+
argv = ["setup"] + (["--recreate"] if args.get("recreate") else [])
|
|
249
|
+
job.start(self._argv(argv), project_dir)
|
|
250
|
+
started = True
|
|
251
|
+
job.wait(wait)
|
|
252
|
+
report = job.report()
|
|
253
|
+
report["started_now"] = started
|
|
254
|
+
status = report["status"]
|
|
255
|
+
if status == "running":
|
|
256
|
+
report["next"] = (
|
|
257
|
+
"still provisioning (a first setup takes 5-15 min); call gpu_setup again to keep "
|
|
258
|
+
"waiting, or gpu_status / gpu_logs(source='onstart') to watch"
|
|
259
|
+
)
|
|
260
|
+
return report
|
|
261
|
+
if status == "succeeded":
|
|
262
|
+
connection = report.pop("result", None) or {}
|
|
263
|
+
connection.pop("watchdog", None)
|
|
264
|
+
return {
|
|
265
|
+
"status": "ready",
|
|
266
|
+
"elapsed_s": report.get("elapsed_s"),
|
|
267
|
+
**connection,
|
|
268
|
+
"next": "use base_url, api_key and model with any OpenAI client, or gpu_run",
|
|
269
|
+
}
|
|
270
|
+
code = int(report.get("exit_code") or 1)
|
|
271
|
+
stderr = "\n".join(report.get("stderr_tail") or [])
|
|
272
|
+
error = CliResult(code, "", stderr).error()
|
|
273
|
+
error["setup"] = {k: report.get(k) for k in ("status", "elapsed_s", "pid")}
|
|
274
|
+
if status == "lost":
|
|
275
|
+
error["error"] = "the setup process died without reporting; call gpu_status"
|
|
276
|
+
raise ToolError(error)
|
|
277
|
+
|
|
278
|
+
def tool_status(self, args: dict[str, Any]) -> Any:
|
|
279
|
+
project_dir = self._project_dir(args)
|
|
280
|
+
payload = self._ok(self.cli(["status"], project_dir))
|
|
281
|
+
job = self._job(project_dir).report()
|
|
282
|
+
if job["status"] != "none":
|
|
283
|
+
job.pop("result", None)
|
|
284
|
+
payload["setup_job"] = job
|
|
285
|
+
return payload
|
|
286
|
+
|
|
287
|
+
def tool_connection(self, args: dict[str, Any]) -> Any:
|
|
288
|
+
return self._ok(self.cli(["env"], self._project_dir(args)))
|
|
289
|
+
|
|
290
|
+
def tool_run(self, args: dict[str, Any]) -> Any:
|
|
291
|
+
project_dir = self._project_dir(args)
|
|
292
|
+
command = [str(part) for part in args.get("command") or []]
|
|
293
|
+
if not command:
|
|
294
|
+
raise ToolError({"error": "command is empty", "exit_code": 2})
|
|
295
|
+
timeout = float(args.get("timeout_seconds") or DEFAULT_RUN_TIMEOUT_S)
|
|
296
|
+
argv = self._argv(["run", "--label", str(args.get("label") or "mcp"), "--", *command], json_output=False)
|
|
297
|
+
kwargs: dict[str, Any] = {}
|
|
298
|
+
if os.name != "nt":
|
|
299
|
+
kwargs["start_new_session"] = True
|
|
300
|
+
proc = subprocess.Popen(
|
|
301
|
+
argv,
|
|
302
|
+
cwd=str(project_dir),
|
|
303
|
+
stdin=subprocess.DEVNULL,
|
|
304
|
+
stdout=subprocess.PIPE,
|
|
305
|
+
stderr=subprocess.PIPE,
|
|
306
|
+
text=True,
|
|
307
|
+
**kwargs,
|
|
308
|
+
)
|
|
309
|
+
timed_out = False
|
|
310
|
+
try:
|
|
311
|
+
stdout, stderr = proc.communicate(timeout=timeout)
|
|
312
|
+
except subprocess.TimeoutExpired:
|
|
313
|
+
timed_out = True
|
|
314
|
+
# SIGINT first: gpu run ends the workload record cleanly on Ctrl-C.
|
|
315
|
+
if os.name != "nt":
|
|
316
|
+
os.killpg(proc.pid, signal.SIGINT)
|
|
317
|
+
else:
|
|
318
|
+
proc.terminate()
|
|
319
|
+
try:
|
|
320
|
+
stdout, stderr = proc.communicate(timeout=15)
|
|
321
|
+
except subprocess.TimeoutExpired:
|
|
322
|
+
proc.kill()
|
|
323
|
+
stdout, stderr = proc.communicate()
|
|
324
|
+
result = {
|
|
325
|
+
"exit_code": proc.returncode,
|
|
326
|
+
"timed_out": timed_out,
|
|
327
|
+
"stdout": _tail_text(stdout),
|
|
328
|
+
"stderr": _tail_text(stderr),
|
|
329
|
+
}
|
|
330
|
+
if timed_out:
|
|
331
|
+
result["next"] = "the command was interrupted; raise timeout_seconds or run it from a shell with `gpu run`"
|
|
332
|
+
return result
|
|
333
|
+
|
|
334
|
+
def tool_logs(self, args: dict[str, Any]) -> Any:
|
|
335
|
+
argv = ["logs", "--lines", str(int(args.get("lines") or 100))]
|
|
336
|
+
if args.get("source"):
|
|
337
|
+
argv += ["--source", str(args["source"])]
|
|
338
|
+
return self._ok(self.cli(argv, self._project_dir(args)))
|
|
339
|
+
|
|
340
|
+
def tool_lock(self, args: dict[str, Any]) -> Any:
|
|
341
|
+
argv = ["lock"]
|
|
342
|
+
if args.get("duration"):
|
|
343
|
+
argv += ["--for", str(args["duration"])]
|
|
344
|
+
if args.get("reason"):
|
|
345
|
+
argv += ["--reason", str(args["reason"])]
|
|
346
|
+
return self._ok(self.cli(argv, self._project_dir(args)))
|
|
347
|
+
|
|
348
|
+
def tool_unlock(self, args: dict[str, Any]) -> Any:
|
|
349
|
+
return self._ok(self.cli(["unlock"], self._project_dir(args)))
|
|
350
|
+
|
|
351
|
+
def _cancel_setup(self, project_dir: Path) -> Optional[dict[str, Any]]:
|
|
352
|
+
"""Interrupt a running setup (it destroys the machine it was booting)."""
|
|
353
|
+
job = self._job(project_dir)
|
|
354
|
+
if not job.running():
|
|
355
|
+
return None
|
|
356
|
+
ended = job.cancel()
|
|
357
|
+
report = job.report()
|
|
358
|
+
if not ended:
|
|
359
|
+
raise ToolError(
|
|
360
|
+
{
|
|
361
|
+
"error": "the running setup did not stop within 2 minutes",
|
|
362
|
+
"exit_code": 12,
|
|
363
|
+
"next": "call gpu_status, then try again",
|
|
364
|
+
"setup": report,
|
|
365
|
+
}
|
|
366
|
+
)
|
|
367
|
+
return {"status": "cancelled", "elapsed_s": report.get("elapsed_s")}
|
|
368
|
+
|
|
369
|
+
def tool_stop(self, args: dict[str, Any]) -> Any:
|
|
370
|
+
project_dir = self._project_dir(args)
|
|
371
|
+
cancelled = self._cancel_setup(project_dir)
|
|
372
|
+
result = self._ok(self.cli(["stop", "--reason", str(args.get("reason") or "agent")], project_dir))
|
|
373
|
+
if cancelled:
|
|
374
|
+
result["setup_cancelled"] = cancelled
|
|
375
|
+
return result
|
|
376
|
+
|
|
377
|
+
def tool_destroy(self, args: dict[str, Any]) -> Any:
|
|
378
|
+
if args.get("confirm") is not True:
|
|
379
|
+
raise ToolError(
|
|
380
|
+
{
|
|
381
|
+
"error": "gpu_destroy needs confirm=true",
|
|
382
|
+
"exit_code": 2,
|
|
383
|
+
"next": "ask the user to confirm deleting the machine, then call again with confirm=true",
|
|
384
|
+
}
|
|
385
|
+
)
|
|
386
|
+
project_dir = self._project_dir(args)
|
|
387
|
+
cancelled = self._cancel_setup(project_dir)
|
|
388
|
+
argv = ["destroy", "--yes", "--reason", str(args.get("reason") or "agent")]
|
|
389
|
+
result = self._ok(self.cli(argv, project_dir))
|
|
390
|
+
if cancelled:
|
|
391
|
+
result["setup_cancelled"] = cancelled
|
|
392
|
+
return result
|
|
393
|
+
|
|
394
|
+
def tool_ls(self, args: dict[str, Any]) -> Any:
|
|
395
|
+
start = self._start_dir(args)
|
|
396
|
+
config = find_config(start)
|
|
397
|
+
cwd = config.parent if config else (start if start.is_dir() else Path.home())
|
|
398
|
+
return self._ok(self.cli(["ls"], cwd))
|
|
399
|
+
|
|
400
|
+
def _tools(self) -> list[Tool]:
|
|
401
|
+
read_only = {"readOnlyHint": True, "openWorldHint": True}
|
|
402
|
+
return [
|
|
403
|
+
Tool(
|
|
404
|
+
"gpu_init",
|
|
405
|
+
"Create jambu.yaml",
|
|
406
|
+
"Write a starter jambu.yaml for a HuggingFace model in project_dir. Use once per "
|
|
407
|
+
"project, before anything else. Defaults: 24 GB VRAM, $1.00/h cap, stop after 15 min idle.",
|
|
408
|
+
_schema(
|
|
409
|
+
{
|
|
410
|
+
"model": {"type": "string", "description": "HuggingFace model id, e.g. Qwen/Qwen2.5-7B-Instruct"},
|
|
411
|
+
"max_hourly_cost_usd": {"type": "number", "description": "Price cap per hour."},
|
|
412
|
+
"min_vram_gb": {"type": "integer", "description": "Minimum GPU memory."},
|
|
413
|
+
"force": {"type": "boolean", "description": "Overwrite an existing jambu.yaml."},
|
|
414
|
+
},
|
|
415
|
+
["model"],
|
|
416
|
+
),
|
|
417
|
+
self.tool_init,
|
|
418
|
+
{"readOnlyHint": False, "destructiveHint": False, "idempotentHint": False, "openWorldHint": False},
|
|
419
|
+
),
|
|
420
|
+
Tool(
|
|
421
|
+
"gpu_validate",
|
|
422
|
+
"Validate config",
|
|
423
|
+
"Check jambu.yaml and the provider credentials without renting anything.",
|
|
424
|
+
_schema({}),
|
|
425
|
+
self.tool_validate,
|
|
426
|
+
read_only,
|
|
427
|
+
),
|
|
428
|
+
Tool(
|
|
429
|
+
"gpu_offers",
|
|
430
|
+
"Show GPU offers",
|
|
431
|
+
"Cheapest machines that match jambu.yaml, with their hourly price. Nothing is rented.",
|
|
432
|
+
_schema({"limit": {"type": "integer", "minimum": 1, "maximum": 50}}),
|
|
433
|
+
self.tool_offers,
|
|
434
|
+
read_only,
|
|
435
|
+
),
|
|
436
|
+
Tool(
|
|
437
|
+
"gpu_setup",
|
|
438
|
+
"Start the GPU",
|
|
439
|
+
"Make the model endpoint ready: rent a machine if there is none, restart it if stopped, "
|
|
440
|
+
"wait until the model answers. COSTS MONEY: confirm with the user first. Returns "
|
|
441
|
+
"status 'running' while it works (first time 5-15 min): call again to keep waiting; "
|
|
442
|
+
"it never starts a second machine. On 'ready' returns base_url, api_key, model.",
|
|
443
|
+
_schema(
|
|
444
|
+
{
|
|
445
|
+
"wait_seconds": {
|
|
446
|
+
"type": "number",
|
|
447
|
+
"minimum": 0,
|
|
448
|
+
"maximum": MAX_SETUP_WAIT_S,
|
|
449
|
+
"description": f"How long to block before reporting progress (default {DEFAULT_SETUP_WAIT_S}).",
|
|
450
|
+
},
|
|
451
|
+
"recreate": {
|
|
452
|
+
"type": "boolean",
|
|
453
|
+
"description": "Replace a machine built from an older jambu.yaml (exit code 2 tells you when).",
|
|
454
|
+
},
|
|
455
|
+
}
|
|
456
|
+
),
|
|
457
|
+
self.tool_setup,
|
|
458
|
+
{"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True, "openWorldHint": True},
|
|
459
|
+
),
|
|
460
|
+
Tool(
|
|
461
|
+
"gpu_status",
|
|
462
|
+
"GPU status",
|
|
463
|
+
"State, endpoint, cost so far, idle time and what the watchdog will do next, plus "
|
|
464
|
+
"the progress of a background gpu_setup.",
|
|
465
|
+
_schema({}),
|
|
466
|
+
self.tool_status,
|
|
467
|
+
read_only,
|
|
468
|
+
),
|
|
469
|
+
Tool(
|
|
470
|
+
"gpu_connection",
|
|
471
|
+
"Endpoint details",
|
|
472
|
+
"base_url, api_key and model for an OpenAI-compatible client, without starting anything.",
|
|
473
|
+
_schema({}),
|
|
474
|
+
self.tool_connection,
|
|
475
|
+
read_only,
|
|
476
|
+
),
|
|
477
|
+
Tool(
|
|
478
|
+
"gpu_run",
|
|
479
|
+
"Run a command against the GPU",
|
|
480
|
+
"Run a local command with OPENAI_BASE_URL, OPENAI_API_KEY and JAMBU_MODEL_ID set, "
|
|
481
|
+
"keeping the GPU alive while it runs (starts it first if needed, which costs money). "
|
|
482
|
+
"Returns exit code and output tails.",
|
|
483
|
+
_schema(
|
|
484
|
+
{
|
|
485
|
+
"command": {
|
|
486
|
+
"type": "array",
|
|
487
|
+
"items": {"type": "string"},
|
|
488
|
+
"minItems": 1,
|
|
489
|
+
"description": "argv, e.g. [\"python\", \"eval.py\", \"--n\", \"50\"]",
|
|
490
|
+
},
|
|
491
|
+
"timeout_seconds": {"type": "number", "minimum": 1},
|
|
492
|
+
"label": {"type": "string"},
|
|
493
|
+
},
|
|
494
|
+
["command"],
|
|
495
|
+
),
|
|
496
|
+
self.tool_run,
|
|
497
|
+
{"readOnlyHint": False, "destructiveHint": False, "idempotentHint": False, "openWorldHint": True},
|
|
498
|
+
),
|
|
499
|
+
Tool(
|
|
500
|
+
"gpu_logs",
|
|
501
|
+
"GPU logs",
|
|
502
|
+
"Last lines of a log on the machine: runtime (vLLM), onstart (boot), watchdog or provider.",
|
|
503
|
+
_schema(
|
|
504
|
+
{
|
|
505
|
+
"source": {"type": "string", "enum": ["runtime", "onstart", "watchdog", "provider"]},
|
|
506
|
+
"lines": {"type": "integer", "minimum": 1, "maximum": 2000},
|
|
507
|
+
}
|
|
508
|
+
),
|
|
509
|
+
self.tool_logs,
|
|
510
|
+
read_only,
|
|
511
|
+
),
|
|
512
|
+
Tool(
|
|
513
|
+
"gpu_lock",
|
|
514
|
+
"Keep the GPU up",
|
|
515
|
+
"Keep the machine running through quiet periods (a long job, a demo). Requests "
|
|
516
|
+
"already count as activity; lock only for silences longer than the idle timeout.",
|
|
517
|
+
_schema(
|
|
518
|
+
{
|
|
519
|
+
"duration": {"type": "string", "description": "e.g. 2h; default lifecycle.lock.default_ttl"},
|
|
520
|
+
"reason": {"type": "string"},
|
|
521
|
+
}
|
|
522
|
+
),
|
|
523
|
+
self.tool_lock,
|
|
524
|
+
{"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True, "openWorldHint": True},
|
|
525
|
+
),
|
|
526
|
+
Tool(
|
|
527
|
+
"gpu_unlock",
|
|
528
|
+
"Release the lock",
|
|
529
|
+
"Return to the normal idle and lifetime rules.",
|
|
530
|
+
_schema({}),
|
|
531
|
+
self.tool_unlock,
|
|
532
|
+
{"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True, "openWorldHint": True},
|
|
533
|
+
),
|
|
534
|
+
Tool(
|
|
535
|
+
"gpu_stop",
|
|
536
|
+
"Stop the GPU",
|
|
537
|
+
"Stop the machine now. Compute billing ends; the disk and model cache are kept, so "
|
|
538
|
+
"the next gpu_setup restarts it in about a minute. Cancels a setup in progress "
|
|
539
|
+
"(a machine still booting is destroyed).",
|
|
540
|
+
_schema({"reason": {"type": "string"}}),
|
|
541
|
+
self.tool_stop,
|
|
542
|
+
{"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True, "openWorldHint": True},
|
|
543
|
+
),
|
|
544
|
+
Tool(
|
|
545
|
+
"gpu_destroy",
|
|
546
|
+
"Delete the GPU",
|
|
547
|
+
"Delete the machine and its disk: nothing is billed afterwards, and the next "
|
|
548
|
+
"gpu_setup rents and downloads from scratch. Cancels a setup in progress. Requires "
|
|
549
|
+
"confirm=true after the user agrees.",
|
|
550
|
+
_schema(
|
|
551
|
+
{"confirm": {"type": "boolean"}, "reason": {"type": "string"}},
|
|
552
|
+
["confirm"],
|
|
553
|
+
),
|
|
554
|
+
self.tool_destroy,
|
|
555
|
+
{"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True, "openWorldHint": True},
|
|
556
|
+
),
|
|
557
|
+
Tool(
|
|
558
|
+
"gpu_ls",
|
|
559
|
+
"List all GPUs",
|
|
560
|
+
"Every machine gpu created in the provider account, from any project, with state and "
|
|
561
|
+
"price. Use it to make sure nothing is left billing.",
|
|
562
|
+
_schema({}),
|
|
563
|
+
self.tool_ls,
|
|
564
|
+
read_only,
|
|
565
|
+
),
|
|
566
|
+
]
|
|
567
|
+
|
|
568
|
+
# -- JSON-RPC ------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
571
|
+
tool = self.tools.get(name)
|
|
572
|
+
if tool is None:
|
|
573
|
+
raise KeyError(name)
|
|
574
|
+
try:
|
|
575
|
+
payload = tool.handler(arguments or {})
|
|
576
|
+
is_error = False
|
|
577
|
+
except ToolError as exc:
|
|
578
|
+
payload, is_error = exc.payload, True
|
|
579
|
+
except Exception as exc: # a tool bug must not kill the server
|
|
580
|
+
payload, is_error = {"error": f"{type(exc).__name__}: {exc}", "exit_code": 1}, True
|
|
581
|
+
text = json.dumps(payload, indent=2, default=str)
|
|
582
|
+
return {"content": [{"type": "text", "text": text}], "isError": is_error}
|
|
583
|
+
|
|
584
|
+
def handle(self, message: dict[str, Any]) -> Optional[dict[str, Any]]:
|
|
585
|
+
"""One request in, one response out (None for notifications)."""
|
|
586
|
+
method = message.get("method")
|
|
587
|
+
msg_id = message.get("id")
|
|
588
|
+
if msg_id is None:
|
|
589
|
+
return None # notifications/initialized, cancelled, ...
|
|
590
|
+
params = message.get("params") or {}
|
|
591
|
+
try:
|
|
592
|
+
if method == "initialize":
|
|
593
|
+
requested = params.get("protocolVersion")
|
|
594
|
+
version = requested if requested in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0]
|
|
595
|
+
result: Any = {
|
|
596
|
+
"protocolVersion": version,
|
|
597
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
598
|
+
"serverInfo": {"name": SERVER_NAME, "title": "gpu (GPU for Agents)", "version": __version__},
|
|
599
|
+
"instructions": INSTRUCTIONS,
|
|
600
|
+
}
|
|
601
|
+
elif method == "ping":
|
|
602
|
+
result = {}
|
|
603
|
+
elif method == "tools/list":
|
|
604
|
+
result = {"tools": [tool.describe() for tool in self.tools.values()]}
|
|
605
|
+
elif method == "tools/call":
|
|
606
|
+
try:
|
|
607
|
+
result = self.call_tool(params.get("name", ""), params.get("arguments") or {})
|
|
608
|
+
except KeyError:
|
|
609
|
+
return _error(msg_id, -32602, f"unknown tool: {params.get('name')}")
|
|
610
|
+
else:
|
|
611
|
+
return _error(msg_id, -32601, f"method not found: {method}")
|
|
612
|
+
except Exception as exc:
|
|
613
|
+
return _error(msg_id, -32603, f"{type(exc).__name__}: {exc}")
|
|
614
|
+
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
|
|
615
|
+
|
|
616
|
+
def serve(self, stdin=None, stdout=None) -> None:
|
|
617
|
+
stdin = stdin or sys.stdin
|
|
618
|
+
out = stdout or sys.stdout
|
|
619
|
+
original = sys.stdout
|
|
620
|
+
sys.stdout = sys.stderr # a stray print must never corrupt the protocol
|
|
621
|
+
try:
|
|
622
|
+
for line in stdin:
|
|
623
|
+
line = line.strip()
|
|
624
|
+
if not line:
|
|
625
|
+
continue
|
|
626
|
+
try:
|
|
627
|
+
message = json.loads(line)
|
|
628
|
+
except ValueError:
|
|
629
|
+
response: Optional[dict[str, Any]] = _error(None, -32700, "parse error")
|
|
630
|
+
else:
|
|
631
|
+
if isinstance(message, list): # batches (2025-03-26)
|
|
632
|
+
replies = [r for r in (self.handle(m) for m in message) if r is not None]
|
|
633
|
+
response = replies or None # type: ignore[assignment]
|
|
634
|
+
elif isinstance(message, dict):
|
|
635
|
+
response = self.handle(message)
|
|
636
|
+
else:
|
|
637
|
+
response = _error(None, -32600, "invalid request")
|
|
638
|
+
if response is not None:
|
|
639
|
+
out.write(json.dumps(response) + "\n")
|
|
640
|
+
out.flush()
|
|
641
|
+
finally:
|
|
642
|
+
sys.stdout = original
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _error(msg_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
646
|
+
return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
|