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
|
@@ -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})
|