jambu-gpu 0.1.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 (46) hide show
  1. jambu_gpu/__init__.py +5 -0
  2. jambu_gpu/__main__.py +4 -0
  3. jambu_gpu/cli/__init__.py +0 -0
  4. jambu_gpu/cli/_common.py +117 -0
  5. jambu_gpu/cli/inspect.py +207 -0
  6. jambu_gpu/cli/lifecycle.py +127 -0
  7. jambu_gpu/cli/main.py +103 -0
  8. jambu_gpu/cli/providers.py +96 -0
  9. jambu_gpu/cli/run.py +37 -0
  10. jambu_gpu/cli/setup.py +165 -0
  11. jambu_gpu/cli/status.py +214 -0
  12. jambu_gpu/cli/validate.py +57 -0
  13. jambu_gpu/core/__init__.py +0 -0
  14. jambu_gpu/core/beacon.py +124 -0
  15. jambu_gpu/core/config.py +596 -0
  16. jambu_gpu/core/credentials.py +75 -0
  17. jambu_gpu/core/durations.py +47 -0
  18. jambu_gpu/core/engine.py +951 -0
  19. jambu_gpu/core/errors.py +68 -0
  20. jambu_gpu/core/events.py +115 -0
  21. jambu_gpu/core/execution.py +169 -0
  22. jambu_gpu/core/health.py +62 -0
  23. jambu_gpu/core/model_inspect.py +368 -0
  24. jambu_gpu/core/models.py +283 -0
  25. jambu_gpu/core/policy.py +233 -0
  26. jambu_gpu/core/state.py +361 -0
  27. jambu_gpu/core/tool_parsers.py +63 -0
  28. jambu_gpu/providers/__init__.py +4 -0
  29. jambu_gpu/providers/base.py +125 -0
  30. jambu_gpu/providers/registry.py +63 -0
  31. jambu_gpu/providers/vast/__init__.py +3 -0
  32. jambu_gpu/providers/vast/client.py +230 -0
  33. jambu_gpu/providers/vast/config.py +51 -0
  34. jambu_gpu/providers/vast/mapper.py +221 -0
  35. jambu_gpu/providers/vast/provider.py +422 -0
  36. jambu_gpu/runtime/__init__.py +6 -0
  37. jambu_gpu/runtime/base.py +99 -0
  38. jambu_gpu/runtime/vllm.py +224 -0
  39. jambu_gpu/watchdog/__init__.py +3 -0
  40. jambu_gpu/watchdog/agent_body.py +547 -0
  41. jambu_gpu/watchdog/builder.py +178 -0
  42. jambu_gpu-0.1.0.dist-info/METADATA +82 -0
  43. jambu_gpu-0.1.0.dist-info/RECORD +46 -0
  44. jambu_gpu-0.1.0.dist-info/WHEEL +4 -0
  45. jambu_gpu-0.1.0.dist-info/entry_points.txt +2 -0
  46. jambu_gpu-0.1.0.dist-info/licenses/LICENSE +21 -0
jambu_gpu/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """gpu: provider-agnostic GPU runtime provisioning and lifecycle CLI."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
jambu_gpu/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
File without changes
@@ -0,0 +1,117 @@
1
+ """Shared CLI plumbing: config loading, output and error rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as jsonlib
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ from ..core.config import RuntimeConfig, load_config
16
+ from ..core.engine import Engine
17
+ from ..core.errors import JambuError
18
+ from ..core.state import to_iso
19
+
20
+ console = Console()
21
+ err_console = Console(stderr=True)
22
+
23
+ STATE_STYLES = {
24
+ "ready": "bold green",
25
+ "busy": "bold cyan",
26
+ "idle": "yellow",
27
+ "starting": "cyan",
28
+ "provisioning": "cyan",
29
+ "stopped": "dim",
30
+ "destroyed": "dim",
31
+ "failed": "bold red",
32
+ "unknown": "magenta",
33
+ "none": "dim",
34
+ }
35
+
36
+
37
+ class Context:
38
+ """Per-invocation state carried on the typer context object."""
39
+
40
+ def __init__(self) -> None:
41
+ self.config_path: Optional[Path] = None
42
+ self.json: bool = False
43
+ self.verbose: bool = False
44
+ self._config: Optional[RuntimeConfig] = None
45
+ self._engine: Optional[Engine] = None
46
+
47
+ def config(self) -> RuntimeConfig:
48
+ if self._config is None:
49
+ self._config = load_config(self.config_path)
50
+ return self._config
51
+
52
+ def engine(self) -> Engine:
53
+ if self._engine is None:
54
+ self._engine = Engine(self.config(), echo=self.echo)
55
+ return self._engine
56
+
57
+ def echo(self, message: str) -> None:
58
+ if self.json:
59
+ return
60
+ style = ""
61
+ if message.startswith("!"):
62
+ style = "yellow"
63
+ elif message.startswith("+"):
64
+ style = "green"
65
+ elif message.startswith("-"):
66
+ style = "red"
67
+ elif message.startswith(">"):
68
+ style = "cyan"
69
+ console.print(Text(message, style=style) if style else message)
70
+
71
+
72
+ def get_context(ctx: typer.Context) -> Context:
73
+ if ctx.obj is None:
74
+ ctx.obj = Context()
75
+ return ctx.obj
76
+
77
+
78
+ def fail(message: str, code: int = 1) -> None:
79
+ err_console.print(Text(f"error: {message}", style="bold red"))
80
+ raise typer.Exit(code)
81
+
82
+
83
+ def handle(exc: Exception) -> None:
84
+ """Render a JambuError the same way regardless of which provider raised it."""
85
+ if isinstance(exc, JambuError):
86
+ err_console.print(Text(f"error: {exc}", style="bold red"))
87
+ raise typer.Exit(getattr(exc, "exit_code", 1))
88
+ raise exc
89
+
90
+
91
+ def print_json(payload: Any) -> None:
92
+ sys.stdout.write(jsonlib.dumps(payload, indent=2, default=str) + "\n")
93
+
94
+
95
+ def state_text(state: str) -> Text:
96
+ return Text(state.upper(), style=STATE_STYLES.get(state, "white"))
97
+
98
+
99
+ def kv_table(title: str, rows: list[tuple[str, Any]]) -> Table:
100
+ table = Table(title=title, show_header=False, box=None, title_justify="left", pad_edge=False)
101
+ table.add_column("field", style="dim", no_wrap=True)
102
+ table.add_column("value")
103
+ for key, value in rows:
104
+ if value is None:
105
+ continue
106
+ table.add_row(key, value if isinstance(value, Text) else str(value))
107
+ return table
108
+
109
+
110
+ def fmt_time(ts: Optional[float]) -> Optional[str]:
111
+ return to_iso(ts) if ts else None
112
+
113
+
114
+ def fmt_money(value: Optional[float], suffix: str = "") -> Optional[str]:
115
+ if value is None:
116
+ return None
117
+ return f"${value:.3f}{suffix}"
@@ -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
+ jambu_gpu/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))
jambu_gpu/cli/main.py ADDED
@@ -0,0 +1,103 @@
1
+ """gpu entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from ..core.errors import JambuError
12
+ from . import inspect as inspect_cli
13
+ from . import lifecycle, providers, run, setup, status, validate
14
+ from ._common import Context, err_console, get_context
15
+
16
+ app = typer.Typer(
17
+ name="gpu",
18
+ help=(
19
+ "Provider-agnostic CLI for provisioning and managing temporary GPU runtimes.\n\n"
20
+ "jambu.yaml is the desired-state source of truth; .jambu/state.json is what "
21
+ "was last observed."
22
+ ),
23
+ no_args_is_help=True,
24
+ add_completion=False,
25
+ rich_markup_mode="rich",
26
+ )
27
+
28
+
29
+ @app.callback()
30
+ def main_callback(
31
+ ctx: typer.Context,
32
+ config: Optional[Path] = typer.Option(
33
+ None, "--config", "-c", help="Path to jambu.yaml (default: nearest one upwards)."
34
+ ),
35
+ json_output: bool = typer.Option(
36
+ False, "--json", help="Machine-readable output on stdout."
37
+ ),
38
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose diagnostics."),
39
+ ) -> None:
40
+ context = Context()
41
+ context.config_path = config
42
+ context.json = json_output
43
+ context.verbose = verbose
44
+ ctx.obj = context
45
+
46
+
47
+ app.command("init")(setup.init_command)
48
+ app.command("inspect-model")(inspect_cli.inspect_model_command)
49
+ app.command("profiles")(inspect_cli.profiles_command)
50
+ app.command("providers")(providers.providers_command)
51
+ app.command("validate")(validate.validate_command)
52
+ app.command("offers")(setup.offers_command)
53
+ app.command("setup")(setup.setup_command)
54
+ app.command(
55
+ "run",
56
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
57
+ )(run.run_command)
58
+ app.command("status")(status.status_command)
59
+ app.command("logs")(status.logs_command)
60
+ app.command("events")(status.events_command)
61
+ app.command("lock")(lifecycle.lock_command)
62
+ app.command("unlock")(lifecycle.unlock_command)
63
+ app.command("stop")(lifecycle.stop_command)
64
+ app.command("destroy")(lifecycle.destroy_command)
65
+ app.command("guard")(lifecycle.guard_command)
66
+
67
+
68
+ @app.command("endpoint")
69
+ def endpoint_command(ctx: typer.Context) -> None:
70
+ """Print the model endpoint URL (handy for shell pipelines)."""
71
+ context = get_context(ctx)
72
+ try:
73
+ state = context.engine().store.load()
74
+ except JambuError as exc:
75
+ err_console.print(f"error: {exc}")
76
+ raise typer.Exit(getattr(exc, "exit_code", 1)) from exc
77
+ if not state.endpoint:
78
+ err_console.print("error: no endpoint; run `gpu setup` first")
79
+ raise typer.Exit(3)
80
+ print(state.endpoint)
81
+
82
+
83
+ @app.command("version")
84
+ def version_command() -> None:
85
+ """Print the CLI version."""
86
+ from .. import __version__
87
+
88
+ print(__version__)
89
+
90
+
91
+ def main() -> None:
92
+ try:
93
+ app()
94
+ except JambuError as exc: # safety net: errors are normalized, never tracebacks
95
+ err_console.print(f"error: {exc}")
96
+ sys.exit(getattr(exc, "exit_code", 1))
97
+ except KeyboardInterrupt:
98
+ err_console.print("interrupted")
99
+ sys.exit(130)
100
+
101
+
102
+ if __name__ == "__main__":
103
+ 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"