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,207 @@
1
+ """`gpu inspect-model` - where jambu.yaml's numbers actually come from.
2
+
3
+ Fetches the two public HuggingFace documents that answer "what should
4
+ compute.gpu / runtime look like for this model" (see
5
+ gpu4agents/core/model_inspect.py for exactly which fields), and prints a
6
+ ready-to-paste gpu_runtime: snippet plus the reasoning behind each value.
7
+
8
+ `--add-profile` closes the loop: instead of hand-writing a new full jambu.yaml
9
+ per model (which only ever grows, never shrinks, and duplicates across every
10
+ project directory), it writes the model-specific fragment straight into the
11
+ shared jambu.models.yaml catalog under a name - `model_profile: <name>` in a
12
+ project's jambu.yaml is then the only thing that ever needs to change.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ import typer
21
+ import yaml
22
+ from rich.text import Text
23
+
24
+ from rich.table import Table
25
+
26
+ from ..core.config import CATALOG_FILENAMES, find_catalog, load_catalog
27
+ from ..core.credentials import CredentialResolver
28
+ from ..core.errors import ConfigError, JambuError
29
+ from ..core.model_inspect import inspect_model
30
+ from ._common import console, get_context, handle, print_json
31
+
32
+
33
+ def inspect_model_command(
34
+ ctx: typer.Context,
35
+ repo_id: str = typer.Argument(
36
+ ..., help="HuggingFace repo id to inspect, e.g. Qwen/Qwen2.5-7B-Instruct."
37
+ ),
38
+ revision: str = typer.Option("main", "--revision", help="Repo revision/branch/tag."),
39
+ hf_token_env: str = typer.Option(
40
+ "HF_TOKEN", "--hf-token-env", help="Env var holding a token for gated repos."
41
+ ),
42
+ add_profile: str = typer.Option(
43
+ "",
44
+ "--add-profile",
45
+ help=(
46
+ "Write the result straight into jambu.models.yaml under this name "
47
+ "instead of just printing a snippet (creates the catalog if none "
48
+ "exists yet). Use model_profile: <name> in jambu.yaml to consume it."
49
+ ),
50
+ ),
51
+ catalog_path_opt: Optional[Path] = typer.Option(
52
+ None,
53
+ "--catalog",
54
+ help=f"Catalog file to write to with --add-profile (default: nearest {CATALOG_FILENAMES[0]}, or a new one here).",
55
+ ),
56
+ force: bool = typer.Option(
57
+ False, "--force", help="With --add-profile, overwrite an existing profile of that name."
58
+ ),
59
+ ) -> None:
60
+ """Inspect a HuggingFace repo and suggest jambu.yaml values for it.
61
+
62
+ Reads exactly two public documents, cited in the output:
63
+
64
+ \b
65
+ https://huggingface.co/api/models/<repo> (param count, dtype)
66
+ https://huggingface.co/<repo>/raw/<rev>/config.json (architecture, context)
67
+
68
+ This is a starting point, not an oracle - the VRAM figure is weights plus
69
+ a flat margin for KV cache/activations (which depend on context length
70
+ and concurrency this command has no way to know). Confirm with
71
+ `gpu offers` and a real health check before trusting it in production.
72
+ """
73
+ context = get_context(ctx)
74
+ resolver = CredentialResolver(Path.cwd())
75
+ token = resolver.get(hf_token_env)
76
+
77
+ try:
78
+ result = inspect_model(repo_id, revision=revision, token=token)
79
+ except JambuError as exc:
80
+ handle(exc)
81
+ return
82
+
83
+ if add_profile:
84
+ try:
85
+ target = _write_profile(result, add_profile, catalog_path_opt, force=force)
86
+ except JambuError as exc:
87
+ handle(exc)
88
+ return
89
+ if context.json:
90
+ print_json({**result.to_dict(), "profile_name": add_profile, "catalog_path": str(target)})
91
+ else:
92
+ console.print(Text(f"+ wrote profile '{add_profile}' to {target}", style="green"))
93
+ console.print(f" use it with: model_profile: {add_profile}")
94
+ return
95
+
96
+ if context.json:
97
+ print_json(result.to_dict())
98
+ return
99
+
100
+ console.print(f"[bold]{result.repo_id}[/bold] @ {result.revision}")
101
+ console.print(
102
+ f" architecture {', '.join(result.architectures) or '?'} "
103
+ f"(model_type: {result.model_type or '?'})"
104
+ )
105
+ if result.total_params:
106
+ console.print(
107
+ f" parameters {result.total_params / 1e9:.2f}B "
108
+ f"({result.param_dtype or '?'}"
109
+ + (f", quantization: {result.quant_method}" if result.quant_method else "")
110
+ + ")"
111
+ )
112
+ console.print(
113
+ f" weight size ~{result.weight_gb:.1f} GB "
114
+ f"-> suggested min_vram_gb: {result.suggested_min_vram_gb}"
115
+ )
116
+ else:
117
+ console.print(Text(" parameters unknown (no safetensors metadata)", style="yellow"))
118
+ console.print(f" context native {result.max_position_embeddings or '?'}")
119
+ console.print(
120
+ f" tool calling {result.suggested_tool_call_parser or '(no parser guessed)'}"
121
+ )
122
+ if result.gated:
123
+ console.print(Text(" gated yes - a real HF token is required", style="yellow"))
124
+ if result.is_hybrid_attention:
125
+ console.print(
126
+ Text(" hybrid attention yes - needs Ampere-or-newer GPU", style="yellow")
127
+ )
128
+
129
+ for note in result.notes:
130
+ console.print(Text(f" ! {note}", style="yellow"))
131
+
132
+ console.print("\n[bold]Sources[/bold]")
133
+ console.print(f" https://huggingface.co/api/models/{repo_id}")
134
+ console.print(f" https://huggingface.co/{repo_id}/raw/{revision}/config.json")
135
+
136
+ console.print("\n[bold]Suggested gpu_runtime: snippet[/bold]")
137
+ console.print(result.to_yaml_snippet())
138
+ console.print(
139
+ Text(
140
+ f"\nTip: `gpu inspect-model {repo_id} --add-profile <name>` writes this "
141
+ "straight into your model catalog instead of a one-off file.",
142
+ style="dim",
143
+ )
144
+ )
145
+
146
+
147
+ def profiles_command(
148
+ ctx: typer.Context,
149
+ catalog_path_opt: Optional[Path] = typer.Option(
150
+ None, "--catalog", help=f"Catalog to list (default: nearest {CATALOG_FILENAMES[0]})."
151
+ ),
152
+ ) -> None:
153
+ """List the model profiles available in the model catalog (jambu.models.yaml)."""
154
+ context = get_context(ctx)
155
+ target = catalog_path_opt or find_catalog(Path.cwd())
156
+ if target is None or not target.is_file():
157
+ if context.json:
158
+ print_json({"catalog": None, "profiles": {}})
159
+ else:
160
+ console.print(
161
+ Text(
162
+ f"no {CATALOG_FILENAMES[0]} found in this directory or any parent. "
163
+ "Create one with `gpu inspect-model <repo> --add-profile <name>`.",
164
+ style="yellow",
165
+ )
166
+ )
167
+ return
168
+
169
+ try:
170
+ catalog = load_catalog(target)
171
+ except JambuError as exc:
172
+ handle(exc)
173
+ return
174
+
175
+ if context.json:
176
+ print_json({"catalog": str(target), "profiles": catalog})
177
+ return
178
+
179
+ table = Table(title=f"Model profiles ({target})", title_justify="left")
180
+ for column in ("PROFILE", "MODEL", "MIN VRAM", "TOOL CALLING"):
181
+ table.add_column(column)
182
+ for name, fragment in sorted(catalog.items()):
183
+ if not isinstance(fragment, dict):
184
+ continue
185
+ model_id = (fragment.get("model") or {}).get("id", "?")
186
+ vram = ((fragment.get("compute") or {}).get("gpu") or {}).get("min_vram_gb", "-")
187
+ runtime = fragment.get("runtime") or {}
188
+ tool_calling = runtime.get("tool_call_parser") or runtime.get("tool_calling", "-")
189
+ table.add_row(name, str(model_id), str(vram), str(tool_calling))
190
+ console.print(table)
191
+ console.print(Text(f" use with: model_profile: <name> in jambu.yaml", style="dim"))
192
+
193
+
194
+ def _write_profile(result, name: str, catalog_path_opt: Optional[Path], *, force: bool) -> Path:
195
+ if catalog_path_opt:
196
+ target = catalog_path_opt
197
+ else:
198
+ target = find_catalog(Path.cwd()) or (Path.cwd() / CATALOG_FILENAMES[0])
199
+
200
+ catalog: dict = load_catalog(target) if target.is_file() else {}
201
+ if name in catalog and not force:
202
+ raise ConfigError(
203
+ f"profile '{name}' already exists in {target} (use --force to overwrite)"
204
+ )
205
+ catalog[name] = result.to_profile_fragment()
206
+ target.write_text(yaml.safe_dump(catalog, sort_keys=False))
207
+ return target
@@ -0,0 +1,127 @@
1
+ """`gpu lock|unlock|stop|destroy|guard` - lifecycle control."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.text import Text
7
+
8
+ from ..core.durations import parse_duration
9
+ from ..core.errors import ConfigError, JambuError
10
+ from ..core.state import to_iso
11
+ from ._common import console, get_context, handle, print_json
12
+
13
+
14
+ def lock_command(
15
+ ctx: typer.Context,
16
+ duration: str = typer.Option(
17
+ "", "--for", help="Lock TTL, e.g. 2h. Omit to use lifecycle.lock.default_ttl."
18
+ ),
19
+ reason: str = typer.Option("", "--reason", help="Why the instance must stay up."),
20
+ ) -> None:
21
+ """Prevent automatic shutdown until the lock expires (spec section 11)."""
22
+ context = get_context(ctx)
23
+ try:
24
+ engine = context.engine()
25
+ if duration:
26
+ ttl = parse_duration(duration)
27
+ else:
28
+ ttl = engine.config.lock_default_ttl_s
29
+ result = engine.lock(ttl, reason=reason)
30
+ except ValueError as exc:
31
+ handle(ConfigError(str(exc)))
32
+ return
33
+ except JambuError as exc:
34
+ handle(exc)
35
+ return
36
+
37
+ if context.json:
38
+ print_json(result)
39
+ return
40
+ until = result.get("locked_until")
41
+ console.print(
42
+ Text(
43
+ f"+ locked until {to_iso(until)}" if until else "+ locked indefinitely",
44
+ style="yellow",
45
+ )
46
+ )
47
+
48
+
49
+ def unlock_command(ctx: typer.Context) -> None:
50
+ """Resume normal lifecycle rules."""
51
+ context = get_context(ctx)
52
+ try:
53
+ result = context.engine().unlock()
54
+ except JambuError as exc:
55
+ handle(exc)
56
+ return
57
+ if context.json:
58
+ print_json(result)
59
+ return
60
+ console.print(Text("+ unlocked; idle and lifetime rules resume", style="green"))
61
+
62
+
63
+ def stop_command(
64
+ ctx: typer.Context,
65
+ reason: str = typer.Option("manual", "--reason", help="Recorded with the stop event."),
66
+ ) -> None:
67
+ """Stop the compute resource (idempotent)."""
68
+ context = get_context(ctx)
69
+ try:
70
+ result = context.engine().stop(reason=reason)
71
+ except JambuError as exc:
72
+ handle(exc)
73
+ return
74
+ if context.json:
75
+ print_json(result)
76
+
77
+
78
+ def destroy_command(
79
+ ctx: typer.Context,
80
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
81
+ reason: str = typer.Option("manual", "--reason", help="Recorded with the destroy event."),
82
+ ) -> None:
83
+ """Destroy the remote resource (idempotent)."""
84
+ context = get_context(ctx)
85
+ try:
86
+ engine = context.engine()
87
+ if not yes and not context.json:
88
+ state = engine.store.load()
89
+ if state.instance_id:
90
+ confirm = typer.confirm(
91
+ f"destroy instance {state.instance_id} on {engine.provider.display_name}?"
92
+ )
93
+ if not confirm:
94
+ console.print("aborted")
95
+ raise typer.Exit(1)
96
+ result = engine.destroy(reason=reason)
97
+ except JambuError as exc:
98
+ handle(exc)
99
+ return
100
+ if context.json:
101
+ print_json(result)
102
+
103
+
104
+ def guard_command(
105
+ ctx: typer.Context,
106
+ dry_run: bool = typer.Option(
107
+ False, "--dry-run", help="Evaluate the policy without enforcing it."
108
+ ),
109
+ ) -> None:
110
+ """Evaluate the lifecycle rule now (external controller path, spec section 14).
111
+
112
+ The remote watchdog already does this on the instance. Use this from cron
113
+ or CI as a second, independent enforcement path.
114
+ """
115
+ context = get_context(ctx)
116
+ try:
117
+ result = context.engine().guard_tick(apply=not dry_run)
118
+ except JambuError as exc:
119
+ handle(exc)
120
+ return
121
+ if context.json:
122
+ print_json(result)
123
+ return
124
+ action = result.get("action")
125
+ reason = result.get("reason")
126
+ style = "yellow" if action != "none" else "green"
127
+ console.print(Text(f"{action}: {reason}", style=style))
gpu4agents/cli/main.py ADDED
@@ -0,0 +1,163 @@
1
+ """gpu entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+
12
+ from ..core.errors import JambuError
13
+ from . import account
14
+ from . import agent as agent_cli
15
+ from . import inspect as inspect_cli
16
+ from . import lifecycle, providers, run, setup, status, validate
17
+ from ._common import Context, err_console, get_context, print_json
18
+
19
+ app = typer.Typer(
20
+ name="gpu",
21
+ help=(
22
+ "GPU for Agents: rent a GPU, serve a model, run the work; the machine stops "
23
+ "when nothing uses it.\n\n"
24
+ "jambu.yaml is the desired-state source of truth; .gpu/state.json is what "
25
+ "was last observed."
26
+ ),
27
+ no_args_is_help=True,
28
+ add_completion=False,
29
+ rich_markup_mode="rich",
30
+ )
31
+
32
+
33
+ def _print_version(value: bool) -> None:
34
+ if value:
35
+ from .. import __version__
36
+
37
+ print(__version__)
38
+ raise typer.Exit()
39
+
40
+
41
+ @app.callback()
42
+ def main_callback(
43
+ ctx: typer.Context,
44
+ config: Optional[Path] = typer.Option(
45
+ None, "--config", "-c", help="Path to jambu.yaml (default: nearest one upwards)."
46
+ ),
47
+ json_output: bool = typer.Option(
48
+ False, "--json", help="Machine-readable output on stdout."
49
+ ),
50
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose diagnostics."),
51
+ version: bool = typer.Option(
52
+ False, "--version", callback=_print_version, is_eager=True, help="Print the version and exit."
53
+ ),
54
+ ) -> None:
55
+ context = Context()
56
+ context.config_path = config
57
+ context.json = json_output
58
+ context.verbose = verbose
59
+ ctx.obj = context
60
+
61
+
62
+ app.command("init")(setup.init_command)
63
+ app.command("inspect-model")(inspect_cli.inspect_model_command)
64
+ app.command("profiles")(inspect_cli.profiles_command)
65
+ app.command("providers")(providers.providers_command)
66
+ app.command("validate")(validate.validate_command)
67
+ app.command("offers")(setup.offers_command)
68
+ app.command("setup")(setup.setup_command)
69
+ app.command(
70
+ "run",
71
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
72
+ )(run.run_command)
73
+ app.command("status")(status.status_command)
74
+ app.command("logs")(status.logs_command)
75
+ app.command("events")(status.events_command)
76
+ app.command("lock")(lifecycle.lock_command)
77
+ app.command("unlock")(lifecycle.unlock_command)
78
+ app.command("stop")(lifecycle.stop_command)
79
+ app.command("destroy")(lifecycle.destroy_command)
80
+ app.command("guard")(lifecycle.guard_command)
81
+ app.command("ls")(account.ls_command)
82
+ app.command("sweep")(account.sweep_command)
83
+ app.command("mcp")(agent_cli.mcp_command)
84
+ app.add_typer(agent_cli.agent_app, name="agent")
85
+
86
+
87
+ @app.command("endpoint")
88
+ def endpoint_command(ctx: typer.Context) -> None:
89
+ """Print the model endpoint URL (handy for shell pipelines)."""
90
+ context = get_context(ctx)
91
+ try:
92
+ state = context.engine().store.load()
93
+ except JambuError as exc:
94
+ err_console.print(f"error: {exc}")
95
+ raise typer.Exit(getattr(exc, "exit_code", 1)) from exc
96
+ if not state.endpoint:
97
+ err_console.print("error: no endpoint; run `gpu setup` first")
98
+ raise typer.Exit(3)
99
+ print(state.endpoint)
100
+
101
+
102
+ @app.command("env")
103
+ def env_command(
104
+ ctx: typer.Context,
105
+ fmt: str = typer.Option(
106
+ "shell", "--format", "-f", help="shell (export lines), dotenv or json."
107
+ ),
108
+ ) -> None:
109
+ """Print how to reach the model (base URL, API key, model) without starting anything.
110
+
111
+ `eval "$(gpu env)"` sets OPENAI_BASE_URL, OPENAI_API_KEY and JAMBU_MODEL_ID,
112
+ which every OpenAI SDK reads. Exit code 3 when there is no endpoint.
113
+ """
114
+ context = get_context(ctx)
115
+ if fmt not in ("shell", "dotenv", "json"):
116
+ err_console.print("error: --format must be shell, dotenv or json")
117
+ raise typer.Exit(2)
118
+ try:
119
+ engine = context.engine()
120
+ conn = engine.connection(engine.store.load())
121
+ except JambuError as exc:
122
+ err_console.print(f"error: {exc}")
123
+ raise typer.Exit(getattr(exc, "exit_code", 1)) from exc
124
+ if context.json or fmt == "json":
125
+ print_json(conn)
126
+ if not conn["endpoint"]:
127
+ raise typer.Exit(3)
128
+ return
129
+ if not conn["endpoint"]:
130
+ err_console.print("error: no endpoint; run `gpu setup` first")
131
+ raise typer.Exit(3)
132
+ values = {
133
+ "OPENAI_BASE_URL": conn["base_url"],
134
+ "OPENAI_API_KEY": conn["api_key"],
135
+ "JAMBU_GPU_ENDPOINT": conn["endpoint"],
136
+ "JAMBU_MODEL_ID": conn["model"],
137
+ }
138
+ prefix = "export " if fmt == "shell" else ""
139
+ for key, value in values.items():
140
+ print(f"{prefix}{key}={shlex.quote(str(value))}")
141
+
142
+
143
+ @app.command("version")
144
+ def version_command() -> None:
145
+ """Print the CLI version."""
146
+ from .. import __version__
147
+
148
+ print(__version__)
149
+
150
+
151
+ def main() -> None:
152
+ try:
153
+ app()
154
+ except JambuError as exc: # safety net: errors are normalized, never tracebacks
155
+ err_console.print(f"error: {exc}")
156
+ sys.exit(getattr(exc, "exit_code", 1))
157
+ except KeyboardInterrupt:
158
+ err_console.print("interrupted")
159
+ sys.exit(130)
160
+
161
+
162
+ if __name__ == "__main__":
163
+ main()
@@ -0,0 +1,96 @@
1
+ """`gpu providers` - capability matrix (spec section 7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.table import Table
7
+ from rich.text import Text
8
+
9
+ from ..core.credentials import CredentialResolver
10
+ from ..core.errors import ConfigError
11
+ from ..providers.registry import load_builtin_providers
12
+ from ._common import get_context, print_json
13
+
14
+
15
+ def providers_command(ctx: typer.Context) -> None:
16
+ """List available providers and what each one can do."""
17
+ context = get_context(ctx)
18
+ registry = load_builtin_providers()
19
+
20
+ try:
21
+ config = context.config()
22
+ project_dir = config.project_dir
23
+ profile = config.provider.profile
24
+ selected = config.provider.name
25
+ except ConfigError:
26
+ config = None
27
+ project_dir = None
28
+ profile = "default"
29
+ selected = None
30
+
31
+ resolver = CredentialResolver(project_dir, profile=profile)
32
+ rows = []
33
+ for provider_cls in registry.classes():
34
+ missing = [n for n in provider_cls.required_credentials if not resolver.get(n)]
35
+ rows.append(
36
+ {
37
+ "provider": provider_cls.name,
38
+ "display_name": provider_cls.display_name,
39
+ "selected": provider_cls.name == selected,
40
+ "status": "configured" if not missing else "unavailable",
41
+ "missing_credentials": missing,
42
+ "required_credentials": list(provider_cls.required_credentials),
43
+ "capabilities": dict(provider_cls.capabilities.__dict__),
44
+ }
45
+ )
46
+
47
+ if context.json:
48
+ print_json({"providers": rows})
49
+ return
50
+
51
+ table = Table(title="Providers", title_justify="left")
52
+ table.add_column("PROVIDER")
53
+ table.add_column("STATUS")
54
+ table.add_column("GPU")
55
+ table.add_column("STOP")
56
+ table.add_column("DESTROY")
57
+ table.add_column("SSH")
58
+ table.add_column("PORTS")
59
+ table.add_column("SPOT")
60
+ table.add_column("PRICING")
61
+ for row in rows:
62
+ caps = row["capabilities"]
63
+ name = Text(row["provider"] + (" *" if row["selected"] else ""))
64
+ status = Text(
65
+ row["status"],
66
+ style="green" if row["status"] == "configured" else "yellow",
67
+ )
68
+ table.add_row(
69
+ name,
70
+ status,
71
+ _yn(caps["gpu_selection"]),
72
+ _yn(caps["stop"]),
73
+ _yn(caps["destroy"]),
74
+ _yn(caps["ssh"]),
75
+ _yn(caps["public_ports"]),
76
+ _yn(caps["spot_instances"]),
77
+ _yn(caps["pricing_query"]),
78
+ )
79
+ from ._common import console
80
+
81
+ console.print(table)
82
+ for row in rows:
83
+ if row["missing_credentials"]:
84
+ console.print(
85
+ Text(
86
+ f" {row['provider']}: set "
87
+ + ", ".join(row["missing_credentials"]),
88
+ style="yellow",
89
+ )
90
+ )
91
+ if selected:
92
+ console.print(Text(f" * selected in jambu.yaml ({selected})", style="dim"))
93
+
94
+
95
+ def _yn(value: bool) -> str:
96
+ return "yes" if value else "no"
gpu4agents/cli/run.py ADDED
@@ -0,0 +1,38 @@
1
+ """`gpu run` - execute a workload against a guaranteed runtime (spec section 16)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+ import typer
8
+
9
+ from ..core.errors import JambuError, WorkloadFailed
10
+ from ._common import get_context, handle, print_json, register_project
11
+
12
+
13
+ def run_command(
14
+ ctx: typer.Context,
15
+ command: List[str] = typer.Argument(
16
+ ...,
17
+ help=(
18
+ "Command to execute, e.g. `python experiments/run.py`. Flags are passed "
19
+ "through; put `--` first if one collides with a gpu option."
20
+ ),
21
+ ),
22
+ label: str = typer.Option("", "--label", help="Human label recorded with the workload."),
23
+ ) -> None:
24
+ """Ensure the runtime is READY, then run the command with heartbeat tracking."""
25
+ context = get_context(ctx)
26
+ try:
27
+ engine = context.engine()
28
+ register_project(context) # `run` provisions when nothing is up
29
+ engine.run(list(command), label=label)
30
+ except WorkloadFailed as exc:
31
+ if context.json:
32
+ print_json({"ok": False, "returncode": exc.returncode})
33
+ raise typer.Exit(exc.returncode) from exc
34
+ except JambuError as exc:
35
+ handle(exc)
36
+ return
37
+ if context.json:
38
+ print_json({"ok": True, "returncode": 0})