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,164 @@
1
+ """`gpu setup` / `init` / `offers` - provisioning entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ from ..core.config import (
12
+ DEFAULT_CONFIG_TEMPLATE,
13
+ DEFAULT_CONFIG_TEMPLATE_FLAT,
14
+ CONFIG_FILENAMES,
15
+ )
16
+ from ..core.errors import JambuError
17
+ from ._common import console, fmt_money, get_context, handle, print_json, register_project
18
+
19
+
20
+ def init_command(
21
+ ctx: typer.Context,
22
+ model: str = typer.Option("empero-ai/Qwythos-9B-v2", "--model", help="HuggingFace model id."),
23
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing config file."),
24
+ flat: bool = typer.Option(
25
+ False,
26
+ "--flat",
27
+ help=(
28
+ "Write a legacy, gpu-runtime-only config.yml (no gpu_runtime: "
29
+ "wrapper) instead of the shared jambu.yaml."
30
+ ),
31
+ ),
32
+ ) -> None:
33
+ """Write a starter jambu.yaml (and .env.example) in the current directory."""
34
+ if flat:
35
+ target = Path.cwd() / "config.yml"
36
+ content = DEFAULT_CONFIG_TEMPLATE_FLAT.format(model_id=model)
37
+ else:
38
+ target = Path.cwd() / CONFIG_FILENAMES[0] # jambu.yaml
39
+ content = DEFAULT_CONFIG_TEMPLATE.format(model_id=model)
40
+
41
+ if target.exists() and not force:
42
+ console.print(Text(f"! {target} already exists (use --force)", style="yellow"))
43
+ raise typer.Exit(1)
44
+ target.write_text(content)
45
+ console.print(Text(f"+ wrote {target}", style="green"))
46
+ if not flat:
47
+ console.print(
48
+ Text(
49
+ " gpu-runtime config lives under the gpu_runtime: key - other "
50
+ "Jambu Lab tools can share this file",
51
+ style="dim",
52
+ )
53
+ )
54
+
55
+ env_example = Path.cwd() / ".env.example"
56
+ if not env_example.exists():
57
+ env_example.write_text(
58
+ "# Credentials never belong in jambu.yaml / config.yml.\n"
59
+ "VAST_API_KEY=\n"
60
+ "# Optional, for gated HuggingFace models:\n"
61
+ "HF_TOKEN=\n"
62
+ )
63
+ console.print(Text(f"+ wrote {env_example}", style="green"))
64
+ console.print("next: export VAST_API_KEY=... && gpu validate")
65
+
66
+
67
+ def setup_command(
68
+ ctx: typer.Context,
69
+ recreate: bool = typer.Option(
70
+ False, "--recreate", help="Replace an existing instance that no longer matches jambu.yaml."
71
+ ),
72
+ allow_cost_override: bool = typer.Option(
73
+ False, "--allow-cost-override", help="Provision even above budget.max_hourly_cost_usd."
74
+ ),
75
+ no_wait: bool = typer.Option(
76
+ False, "--no-wait", help="Return as soon as the instance is created."
77
+ ),
78
+ ) -> None:
79
+ """Provision the instance, install the watchdog and start the model server."""
80
+ context = get_context(ctx)
81
+ try:
82
+ engine = context.engine()
83
+ register_project(context)
84
+ state = engine.setup(
85
+ recreate=recreate, allow_cost_override=allow_cost_override, wait=not no_wait
86
+ )
87
+ except JambuError as exc:
88
+ handle(exc)
89
+ return
90
+
91
+ payload = engine.connection(state)
92
+ watchdog = state.watchdog.to_dict()
93
+ watchdog.pop("token", None)
94
+ payload["watchdog"] = watchdog
95
+ if context.json:
96
+ print_json(payload)
97
+ return
98
+ if state.endpoint:
99
+ console.print(
100
+ f"\n endpoint [bold]{state.endpoint}[/bold]\n"
101
+ f" openai [bold]{payload['base_url']}[/bold]\n"
102
+ f" model [bold]{payload['model']}[/bold]\n"
103
+ " api key [dim]gpu env[/dim]"
104
+ )
105
+ console.print("\nnext: gpu run python your_experiment.py")
106
+
107
+
108
+ def offers_command(
109
+ ctx: typer.Context,
110
+ limit: int = typer.Option(10, "--limit", "-n", help="How many offers to show."),
111
+ ) -> None:
112
+ """Show matching capacity and its price before committing to it."""
113
+ context = get_context(ctx)
114
+ try:
115
+ engine = context.engine()
116
+ engine.provider.require_capability("pricing_query")
117
+ spec = engine.build_spec()
118
+ offers = engine.provider.find_offers(spec, limit=limit)
119
+ except JambuError as exc:
120
+ handle(exc)
121
+ return
122
+
123
+ rows = [
124
+ {
125
+ "id": o.id,
126
+ "gpu": o.gpu_name,
127
+ "count": o.gpu_count,
128
+ "vram_gb": o.gpu_vram_gb,
129
+ "disk_gb": o.disk_gb,
130
+ "hourly_cost_usd": o.hourly_cost_usd,
131
+ "region": o.region,
132
+ "cuda_max": o.cuda_max,
133
+ }
134
+ for o in offers
135
+ ]
136
+ if context.json:
137
+ print_json({"offers": rows})
138
+ return
139
+
140
+ if not rows:
141
+ console.print(Text("no matching offers", style="yellow"))
142
+ return
143
+
144
+ ceiling = engine.config.budget.max_hourly_cost_usd
145
+ table = Table(title=f"{engine.provider.display_name} offers", title_justify="left")
146
+ for column in ("OFFER", "GPU", "N", "VRAM", "DISK", "$/H", "REGION"):
147
+ table.add_column(column)
148
+ for row in rows:
149
+ over = ceiling is not None and row["hourly_cost_usd"] > float(ceiling)
150
+ table.add_row(
151
+ row["id"],
152
+ row["gpu"],
153
+ str(row["count"]),
154
+ f"{row['vram_gb']:.0f} GB",
155
+ f"{row['disk_gb']:.0f} GB",
156
+ Text(
157
+ fmt_money(row["hourly_cost_usd"]) or "-",
158
+ style="red" if over else "green",
159
+ ),
160
+ row["region"] or "-",
161
+ )
162
+ console.print(table)
163
+ if ceiling:
164
+ console.print(Text(f" budget ceiling: ${float(ceiling):.3f}/h", style="dim"))
@@ -0,0 +1,224 @@
1
+ """`gpu status` / `logs` / `events` - reconciled observability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import typer
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ from ..core.durations import format_duration
12
+ from ..core.errors import JambuError
13
+ from ._common import (
14
+ console,
15
+ fmt_money,
16
+ fmt_time,
17
+ get_context,
18
+ handle,
19
+ kv_table,
20
+ print_json,
21
+ state_text,
22
+ )
23
+
24
+
25
+ def status_command(
26
+ ctx: typer.Context,
27
+ watch: float = typer.Option(
28
+ 0, "--watch", "-w", help="Refresh every N seconds (0 = once)."
29
+ ),
30
+ ) -> None:
31
+ """Reconcile local and provider state, then report the normalized result."""
32
+ context = get_context(ctx)
33
+ while True:
34
+ try:
35
+ payload = context.engine().status()
36
+ except JambuError as exc:
37
+ handle(exc)
38
+ return
39
+
40
+ if context.json:
41
+ print_json(payload)
42
+ return
43
+
44
+ _render(payload)
45
+ if not watch:
46
+ return
47
+ time.sleep(max(2.0, watch))
48
+ console.rule(style="dim")
49
+
50
+
51
+ def _render(payload: dict) -> None:
52
+ decision = payload.get("decision") or {}
53
+ watchdog = payload.get("watchdog") or {}
54
+ rows = [
55
+ ("state", state_text(payload.get("state", "unknown"))),
56
+ ("provider", payload.get("provider")),
57
+ ("instance", payload.get("instance_id") or "-"),
58
+ ("model", payload.get("model")),
59
+ ("endpoint", payload.get("endpoint") or "-"),
60
+ ("created", fmt_time(payload.get("created_at"))),
61
+ (
62
+ "age",
63
+ format_duration(payload.get("age_seconds"))
64
+ if payload.get("age_seconds") is not None
65
+ else None,
66
+ ),
67
+ ("idle", format_duration(payload.get("idle_seconds"))),
68
+ ("cost", _cost(payload)),
69
+ ("locked", _lock(payload)),
70
+ (
71
+ "watchdog",
72
+ _watchdog(watchdog),
73
+ ),
74
+ ("guard", _guard(decision, payload)),
75
+ ]
76
+ console.print(kv_table("Runtime", rows))
77
+
78
+ workloads = [w for w in payload.get("workloads", []) if w["state"] in ("running", "queued")]
79
+ recent = sorted(
80
+ payload.get("workloads", []), key=lambda w: w.get("started_at") or 0, reverse=True
81
+ )[:5]
82
+ if recent:
83
+ table = Table(title="Workloads", title_justify="left", box=None)
84
+ for column in ("ID", "STATE", "LABEL", "STARTED", "RC"):
85
+ table.add_column(column)
86
+ for record in recent:
87
+ table.add_row(
88
+ record["id"],
89
+ Text(
90
+ record["state"],
91
+ style="cyan" if record["state"] == "running" else "dim",
92
+ ),
93
+ (record.get("label") or "")[:48],
94
+ fmt_time(record.get("started_at")) or "-",
95
+ "-" if record.get("returncode") is None else str(record["returncode"]),
96
+ )
97
+ console.print(table)
98
+ if workloads:
99
+ console.print(
100
+ Text(f" {len(workloads)} active workload(s) hold the instance open", style="cyan")
101
+ )
102
+
103
+
104
+ def _cost(payload: dict) -> str:
105
+ hourly = payload.get("hourly_cost_usd")
106
+ session = payload.get("session_cost_usd")
107
+ if hourly is None and session is None:
108
+ return "-"
109
+ parts = []
110
+ if hourly is not None:
111
+ parts.append(f"{fmt_money(hourly)}/h")
112
+ if session:
113
+ parts.append(f"{fmt_money(session)} this session")
114
+ return " ".join(parts) or "-"
115
+
116
+
117
+ def _lock(payload: dict) -> Text:
118
+ if not payload.get("locked"):
119
+ return Text("no", style="dim")
120
+ until = fmt_time(payload.get("locked_until"))
121
+ return Text(f"yes (until {until})" if until else "yes (indefinite)", style="yellow")
122
+
123
+
124
+ def _watchdog(watchdog: dict) -> Text:
125
+ if not watchdog.get("enabled"):
126
+ return Text("disabled - shutdown depends on this CLI staying alive", style="yellow")
127
+ if watchdog.get("reachable"):
128
+ return Text(f"live at {watchdog.get('url')}", style="green")
129
+ if watchdog.get("installed"):
130
+ return Text(
131
+ f"unreachable ({watchdog.get('last_error') or 'no answer'})", style="yellow"
132
+ )
133
+ return Text("not installed", style="yellow")
134
+
135
+
136
+ def _guard(decision: dict, payload: dict) -> Text:
137
+ action = decision.get("action", "none")
138
+ reason = decision.get("reason") or "-"
139
+ if action == "none":
140
+ policy = payload.get("policy") or {}
141
+ idle_timeout = policy.get("idle_timeout_s")
142
+ idle = payload.get("idle_seconds") or 0
143
+ if idle_timeout and reason == "within_policy":
144
+ remaining = max(0.0, float(idle_timeout) - float(idle))
145
+ return Text(
146
+ f"ok ({reason}); auto-stop in {format_duration(remaining)} if idle",
147
+ style="green",
148
+ )
149
+ if (decision.get("detail") or {}).get("idle_skipped"):
150
+ return Text(f"ok ({reason}); idle left to the unreachable watchdog", style="yellow")
151
+ return Text(f"ok ({reason})", style="green")
152
+ return Text(f"{action} pending: {reason}", style="bold yellow")
153
+
154
+
155
+ def logs_command(
156
+ ctx: typer.Context,
157
+ lines: int = typer.Option(200, "--lines", "-n", help="How many lines to show."),
158
+ source: str = typer.Option(
159
+ "",
160
+ "--source",
161
+ "-s",
162
+ help="runtime | onstart | watchdog (read on the instance through the "
163
+ "watchdog) or provider (container log). Default: runtime when the "
164
+ "watchdog answers, else provider.",
165
+ ),
166
+ ) -> None:
167
+ """Show the instance's logs: the model server's by default."""
168
+ context = get_context(ctx)
169
+ try:
170
+ used, output = context.engine().read_logs(limit=lines, source=source or None)
171
+ except JambuError as exc:
172
+ handle(exc)
173
+ return
174
+ if context.json:
175
+ print_json({"source": used, "lines": output})
176
+ return
177
+ for line in output:
178
+ console.print(line, markup=False, highlight=False)
179
+
180
+
181
+ def events_command(
182
+ ctx: typer.Context,
183
+ limit: int = typer.Option(40, "--limit", "-n", help="How many events to show."),
184
+ event: str = typer.Option("", "--event", help="Filter by event name."),
185
+ ) -> None:
186
+ """Show the structured lifecycle event log (spec section 24)."""
187
+ context = get_context(ctx)
188
+ try:
189
+ engine = context.engine()
190
+ except JambuError as exc:
191
+ handle(exc)
192
+ return
193
+ records = engine.events.tail(limit=limit, event=event or None)
194
+ if context.json:
195
+ print_json({"events": records})
196
+ return
197
+ if not records:
198
+ console.print(Text("no events recorded yet", style="dim"))
199
+ return
200
+ table = Table(title="Lifecycle events", title_justify="left", box=None)
201
+ for column in ("TS", "EVENT", "DETAIL"):
202
+ table.add_column(column)
203
+ for record in records:
204
+ detail = {
205
+ k: v
206
+ for k, v in record.items()
207
+ if k not in ("ts", "event", "pid", "provider", "source")
208
+ }
209
+ table.add_row(
210
+ str(record.get("ts", "")),
211
+ Text(str(record.get("event", "")), style=_event_style(record.get("event", ""))),
212
+ ", ".join(f"{k}={v}" for k, v in detail.items())[:110],
213
+ )
214
+ console.print(table)
215
+
216
+
217
+ def _event_style(event: str) -> str:
218
+ if "error" in event or "failed" in event:
219
+ return "red"
220
+ if "guard" in event or "stopped" in event or "destroyed" in event:
221
+ return "yellow"
222
+ if "ready" in event or "created" in event:
223
+ return "green"
224
+ return "white"
@@ -0,0 +1,143 @@
1
+ """systemd *user* units that run `gpu sweep --apply` on a timer.
2
+
3
+ One ``ExecStart`` covers every project: the sweep guards each project in
4
+ ``$GPU_HOME/projects.json`` with its own config, so the unit does not depend
5
+ on its working directory. A user unit needs no root, but it does not inherit
6
+ the shell's environment: credentials must live in ``~/.gpu/credentials`` or
7
+ the project ``.env``. Writing the files is all this module does; enabling them
8
+ is left to the printed ``systemctl`` command.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import shlex
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ UNIT_NAME = "jambu-gpu-sweep"
20
+ # A stuck provider API must not wedge the oneshot (and with it every later run).
21
+ START_TIMEOUT = "10min"
22
+
23
+
24
+ def unit_dir() -> Path:
25
+ base = os.environ.get("XDG_CONFIG_HOME")
26
+ root = Path(base).expanduser() if base else Path.home() / ".config"
27
+ return root / "systemd" / "user"
28
+
29
+
30
+ def unit_paths() -> tuple[Path, Path]:
31
+ directory = unit_dir()
32
+ return directory / f"{UNIT_NAME}.service", directory / f"{UNIT_NAME}.timer"
33
+
34
+
35
+ def _specifiers(value: str) -> str:
36
+ # systemd expands %h, %u, ... in unit values; a literal % must be doubled.
37
+ return value.replace("%", "%%")
38
+
39
+
40
+ def _quote(arg: str) -> str:
41
+ """One ExecStart= argument: % and $ escaped, quoted when it needs it."""
42
+ arg = _specifiers(arg).replace("$", "$$")
43
+ if not arg or any(c in arg for c in " \t\"'\\;"):
44
+ return '"' + arg.replace("\\", "\\\\").replace('"', '\\"') + '"'
45
+ return arg
46
+
47
+
48
+ def _environment(name: str, value: str) -> str:
49
+ text = _specifiers(f"{name}={value}").replace("\\", "\\\\").replace('"', '\\"')
50
+ return f'Environment="{text}"'
51
+
52
+
53
+ def render_units(
54
+ *, every_s: float, python: str, jambu_home: Optional[str] = None
55
+ ) -> tuple[str, str]:
56
+ lines = [
57
+ "# Generated by `gpu sweep --install-timer`. Remove with --uninstall-timer.",
58
+ "[Unit]",
59
+ "Description=gpu4agents: guard every registered project and clean up orphans",
60
+ "",
61
+ "[Service]",
62
+ "Type=oneshot",
63
+ "WorkingDirectory=~",
64
+ f"TimeoutStartSec={START_TIMEOUT}",
65
+ ]
66
+ if jambu_home:
67
+ lines.append(_environment("GPU_HOME", jambu_home))
68
+ # No "-" prefix: a failing sweep must show up as a failed unit.
69
+ lines.append("ExecStart=" + " ".join(_quote(a) for a in [python, "-m", "gpu4agents", "sweep", "--apply"]))
70
+ lines.append("")
71
+ service = "\n".join(lines)
72
+ seconds = max(60, int(every_s))
73
+ timer = "\n".join(
74
+ [
75
+ "# Generated by `gpu sweep --install-timer`.",
76
+ "[Unit]",
77
+ f"Description=Run {UNIT_NAME}.service every {seconds}s",
78
+ "",
79
+ "[Timer]",
80
+ "OnActiveSec=60s",
81
+ f"OnUnitActiveSec={seconds}s",
82
+ "AccuracySec=30s",
83
+ f"Unit={UNIT_NAME}.service",
84
+ "",
85
+ "[Install]",
86
+ "WantedBy=timers.target",
87
+ "",
88
+ ]
89
+ )
90
+ return service, timer
91
+
92
+
93
+ def legacy_guard_configs(service: Path) -> list[Path]:
94
+ """Config paths of the per-project `guard` lines older units carried."""
95
+ if not service.is_file():
96
+ return []
97
+ found = []
98
+ for line in service.read_text().splitlines():
99
+ if not line.startswith("ExecStart="):
100
+ continue
101
+ try:
102
+ args = shlex.split(line[len("ExecStart="):].lstrip("-"))
103
+ except ValueError:
104
+ continue
105
+ if args and args[-1] == "guard" and "--config" in args:
106
+ index = args.index("--config") + 1
107
+ if index < len(args):
108
+ found.append(Path(args[index].replace("%%", "%")))
109
+ return found
110
+
111
+
112
+ def install_timer(every_s: float, python: Optional[str] = None) -> dict:
113
+ """Write (or rewrite) the units."""
114
+ python = python or sys.executable
115
+ service_path, timer_path = unit_paths()
116
+ legacy = legacy_guard_configs(service_path)
117
+ service, timer = render_units(
118
+ every_s=every_s,
119
+ python=python,
120
+ jambu_home=os.environ.get("GPU_HOME") or os.environ.get("JAMBU_HOME") or None,
121
+ )
122
+ service_path.parent.mkdir(parents=True, exist_ok=True)
123
+ service_path.write_text(service)
124
+ timer_path.write_text(timer)
125
+ return {
126
+ "service": str(service_path),
127
+ "timer": str(timer_path),
128
+ "every_s": max(60, int(every_s)),
129
+ "legacy_guard_configs": [str(p) for p in legacy],
130
+ "enable": f"systemctl --user daemon-reload && systemctl --user enable --now {UNIT_NAME}.timer",
131
+ }
132
+
133
+
134
+ def uninstall_timer() -> dict:
135
+ removed = []
136
+ for path in unit_paths():
137
+ if path.exists():
138
+ path.unlink()
139
+ removed.append(str(path))
140
+ return {
141
+ "removed": removed,
142
+ "disable": f"systemctl --user disable --now {UNIT_NAME}.timer; systemctl --user daemon-reload",
143
+ }
@@ -0,0 +1,57 @@
1
+ """`gpu validate` - fail before provisioning (spec sections 5, 15)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.text import Text
7
+
8
+ from ..core.errors import JambuError
9
+ from ._common import console, get_context, handle, print_json
10
+
11
+
12
+ def validate_command(
13
+ ctx: typer.Context,
14
+ offline: bool = typer.Option(
15
+ False, "--offline", help="Skip provider API checks (schema and policy only)."
16
+ ),
17
+ ) -> None:
18
+ """Validate jambu.yaml, credentials, capabilities and available capacity."""
19
+ context = get_context(ctx)
20
+ try:
21
+ engine = context.engine()
22
+ result = engine.validate(check_provider=not offline)
23
+ except JambuError as exc:
24
+ if context.json:
25
+ print_json({"ok": False, "errors": [{"message": str(exc)}]})
26
+ raise typer.Exit(getattr(exc, "exit_code", 1)) from exc
27
+ handle(exc)
28
+ return
29
+
30
+ payload = {
31
+ "ok": result.ok,
32
+ "provider": engine.provider.name,
33
+ "model": engine.config.model.id,
34
+ "runtime": engine.config.runtime.engine,
35
+ "fingerprint": engine.config.fingerprint(),
36
+ "errors": [{"field": i.field, "message": i.message} for i in result.errors],
37
+ "warnings": [{"field": i.field, "message": i.message} for i in result.warnings],
38
+ }
39
+ if context.json:
40
+ print_json(payload)
41
+ raise typer.Exit(0 if result.ok else 2)
42
+
43
+ console.print(
44
+ f"config: [bold]{engine.config.source_path}[/bold]\n"
45
+ f"provider: [bold]{engine.provider.display_name}[/bold] "
46
+ f"runtime: [bold]{engine.runtime.describe()}[/bold]"
47
+ )
48
+ for issue in result.warnings:
49
+ console.print(Text(f"! {issue.field or 'config'}: {issue.message}", style="yellow"))
50
+ for issue in result.errors:
51
+ console.print(Text(f"x {issue.field or 'config'}: {issue.message}", style="bold red"))
52
+
53
+ if result.ok:
54
+ console.print(Text("+ configuration is valid", style="bold green"))
55
+ else:
56
+ console.print(Text(f"{len(result.errors)} error(s) must be fixed", style="bold red"))
57
+ raise typer.Exit(2)
File without changes