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,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}}