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,647 @@
1
+ """jambu.yaml schema and loader (spec section 4).
2
+
3
+ jambu.yaml is the declarative desired-state Source of Truth. It describes
4
+ *what* should run and *how long it may live*. It never contains credentials
5
+ and never contains provider-specific API details.
6
+
7
+ jambu.yaml is a SHARED manifest across Jambu.ai Lab sub-projects, not a file
8
+ this tool owns exclusively - our config lives under one namespaced top-level
9
+ key (``gpu_runtime:``), so other Jambu tools can add their own keys to the
10
+ same file without colliding with ours:
11
+
12
+ # jambu.yaml
13
+ gpu_runtime:
14
+ version: 1
15
+ model: {id: ...}
16
+ ...
17
+ some_other_jambu_tool:
18
+ ...
19
+
20
+ The legacy, unnamespaced ``config.yml`` / ``config.yaml`` (the whole document
21
+ IS the gpu-runtime config, no wrapper key) is still read for projects that
22
+ predate this convention or that intentionally want a gpu-runtime-only file.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ from pathlib import Path
29
+ from typing import Any, Literal, Optional
30
+
31
+ import yaml
32
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
33
+
34
+ from .durations import parse_duration
35
+ from .errors import ConfigError
36
+ from .paths import project_state_dir
37
+
38
+ # jambu.yaml (namespaced, shared with other Jambu Lab tools) is searched
39
+ # first; config.yml (flat, gpu-runtime-only) is the legacy fallback.
40
+ CONFIG_FILENAMES = ("jambu.yaml", "jambu.yml", "config.yml", "config.yaml")
41
+ GPU_RUNTIME_KEY = "gpu_runtime"
42
+
43
+ # The model catalog (spec: "Choosing a model" doc) - a project's jambu.yaml
44
+ # references one entry by name instead of redeclaring the whole model/compute/
45
+ # runtime block per model, per project. Searched the same way as jambu.yaml
46
+ # itself (this directory, then parents), so one catalog can serve every
47
+ # sub-project in a repo without duplication.
48
+ CATALOG_FILENAMES = ("jambu.models.yaml", "jambu.models.yml")
49
+ PROFILE_KEY = "model_profile"
50
+
51
+
52
+ class _Base(BaseModel):
53
+ model_config = ConfigDict(extra="forbid")
54
+
55
+
56
+ class Duration(str):
57
+ """A duration string; ``.seconds`` is resolved at validation time."""
58
+
59
+
60
+ def _duration(field_name: str):
61
+ def _validate(cls, v): # noqa: N805
62
+ try:
63
+ parse_duration(v)
64
+ except ValueError as exc:
65
+ raise ValueError(str(exc)) from exc
66
+ return v
67
+
68
+ return field_validator(field_name)(classmethod(_validate))
69
+
70
+
71
+ class ProviderConfig(_Base):
72
+ name: str = "vast"
73
+ profile: str = "default"
74
+ options: dict[str, Any] = Field(default_factory=dict)
75
+
76
+
77
+ class GpuConfig(_Base):
78
+ min_vram_gb: int = 24
79
+ count: int = 1
80
+ name_filter: Optional[str] = None
81
+ cuda_min: Optional[str] = None
82
+ # Minimum GPU compute capability (architecture), e.g. 8.0 to require
83
+ # Ampere or newer. Raised to what the runtime needs; see
84
+ # ModelRuntime.required_compute_cap. Leave unset to accept the runtime's
85
+ # own floor.
86
+ compute_cap_min: Optional[float] = None
87
+
88
+ @field_validator("count")
89
+ @classmethod
90
+ def _positive(cls, v: int) -> int:
91
+ if v < 1:
92
+ raise ValueError("compute.gpu.count must be >= 1")
93
+ return v
94
+
95
+
96
+ class ComputeConfig(_Base):
97
+ gpu: GpuConfig = Field(default_factory=GpuConfig)
98
+ disk_gb: int = 80
99
+ region: Optional[str] = None
100
+ spot: bool = False
101
+ image: Optional[str] = None
102
+ # Machines to try when one fails to boot (bad network, stuck image pull)
103
+ # before setup gives up. Each attempt rents a different host.
104
+ host_attempts: int = 3
105
+
106
+ @field_validator("host_attempts")
107
+ @classmethod
108
+ def _at_least_one(cls, v: int) -> int:
109
+ if v < 1:
110
+ raise ValueError("compute.host_attempts must be >= 1")
111
+ return v
112
+
113
+
114
+ class ModelConfig(_Base):
115
+ id: str
116
+ revision: Optional[str] = None
117
+ hf_token_env: Optional[str] = "HF_TOKEN"
118
+ trust_remote_code: bool = False
119
+ served_name: Optional[str] = None
120
+
121
+
122
+ class RuntimeConfigSection(_Base):
123
+ engine: Literal["vllm", "none"] = "vllm"
124
+ port: int = 8000
125
+ context_length: Optional[int] = 16384
126
+ dtype: str = "auto"
127
+ gpu_memory_utilization: float = 0.90
128
+ tensor_parallel_size: Optional[int] = None
129
+ api_key: Optional[str] = None
130
+
131
+ # Escape hatch for any vLLM CLI flag this schema doesn't model explicitly
132
+ # (vLLM adds flags faster than any fixed contract can track). Two forms,
133
+ # composable, applied in this order - later wins on conflict:
134
+ # vllm_args: a structured {flag: value} map, flattened into --flag
135
+ # value pairs (see runtime/vllm.py::flatten_vllm_args for the exact
136
+ # rules - booleans, dicts/lists as JSON, dashes vs underscores).
137
+ # extra_args: a raw argv list, appended verbatim - for anything the
138
+ # structured form can't express (repeated flags, exotic syntax).
139
+ vllm_args: dict[str, Any] = Field(default_factory=dict)
140
+ extra_args: list[str] = Field(default_factory=list)
141
+
142
+ # Agentic flows ride on vLLM's own OpenAI-compatible server - both
143
+ # /v1/chat/completions (with tools) and /v1/responses (the OpenAI
144
+ # Responses API) are already implemented there. Nothing for gpu to
145
+ # build; these fields only turn on the right vLLM flags.
146
+ # auto -> guess a --tool-call-parser from model.id, enable if recognized
147
+ # on -> require a resolvable parser (explicit tool_call_parser or a
148
+ # recognized model.id); fail validation otherwise
149
+ # off -> never pass tool-calling flags
150
+ tool_calling: Literal["auto", "on", "off"] = "auto"
151
+
152
+ @field_validator("tool_calling", mode="before")
153
+ @classmethod
154
+ def _tool_calling_yaml_bool(cls, v: Any) -> Any:
155
+ # YAML 1.1 (what PyYAML's safe_load speaks) reads a bare `on`/`off`
156
+ # as the boolean True/False, not the string - a classic footgun (the
157
+ # "Norway problem"). Accept the boolean so an unquoted `on`/`off` in
158
+ # jambu.yaml still does what it looks like it does, instead of a
159
+ # confusing "Input should be 'auto', 'on' or 'off'" error.
160
+ if v is True:
161
+ return "on"
162
+ if v is False:
163
+ return "off"
164
+ return v
165
+
166
+ tool_call_parser: Optional[str] = None
167
+ # MCP server vLLM should route Responses-API tool calls to server-side
168
+ # (a URL, or "demo" for vLLM's bundled demo server). Leave unset to let
169
+ # the calling agent frameworks execute their own tools client-side.
170
+ tool_server: Optional[str] = None
171
+
172
+
173
+ class LockConfig(_Base):
174
+ enabled: bool = False
175
+ default_ttl: Optional[str] = "2h"
176
+
177
+ _v_ttl = _duration("default_ttl")
178
+
179
+
180
+ class WatchdogConfig(_Base):
181
+ """Independent lifecycle enforcement on the instance (spec section 14)."""
182
+
183
+ enabled: bool = True
184
+ port: int = 8777
185
+ interval: str = "60s"
186
+ # Env var holding the credential the watchdog uses to stop/destroy itself.
187
+ credential_env: Optional[str] = None
188
+
189
+ _v_interval = _duration("interval")
190
+
191
+
192
+ class LifecycleConfig(_Base):
193
+ max_lifetime: Optional[str] = "6h"
194
+ idle_timeout: Optional[str] = "15m"
195
+ auto_stop: bool = True
196
+ auto_destroy: bool = False
197
+ cleanup_on_setup_failure: bool = True
198
+ allow_indefinite_lock: bool = False
199
+ heartbeat_interval: str = "30s"
200
+ heartbeat_grace: str = "5m"
201
+ ssh_counts_as_activity: bool = False
202
+ # Requests served by the model runtime count as activity, so an endpoint
203
+ # in active use by something other than `gpu run` is not idled out.
204
+ traffic_counts_as_activity: bool = True
205
+ # How long an instance may sit STOPPED before it is destroyed. Matters
206
+ # where a stopped instance still bills (storage); null keeps it forever.
207
+ stopped_ttl: Optional[str] = "24h"
208
+ lock: LockConfig = Field(default_factory=LockConfig)
209
+ watchdog: WatchdogConfig = Field(default_factory=WatchdogConfig)
210
+
211
+ _v_max = _duration("max_lifetime")
212
+ _v_idle = _duration("idle_timeout")
213
+ _v_hb = _duration("heartbeat_interval")
214
+ _v_grace = _duration("heartbeat_grace")
215
+ _v_stopped_ttl = _duration("stopped_ttl")
216
+
217
+ @model_validator(mode="after")
218
+ def _sane(self) -> "LifecycleConfig":
219
+ idle = parse_duration(self.idle_timeout)
220
+ life = parse_duration(self.max_lifetime)
221
+ if idle is not None and life is not None and idle > life:
222
+ raise ValueError("lifecycle.idle_timeout must not exceed lifecycle.max_lifetime")
223
+ if not self.auto_stop and not self.auto_destroy:
224
+ # Not fatal, but the guard becomes advisory only.
225
+ pass
226
+ return self
227
+
228
+
229
+ class HealthConfig(_Base):
230
+ interval: str = "60s"
231
+ # How long the machine may take to reach running (mostly the image pull).
232
+ boot_timeout: str = "30m"
233
+ # How long that pull may go without progress before the host counts as
234
+ # stalled; a stalled pull never resumes, so waiting out boot_timeout
235
+ # only bills the dead host longer.
236
+ pull_stall_timeout: str = "10m"
237
+ # How long the model server may then take to answer its health check.
238
+ startup_timeout: str = "10m"
239
+ path: str = "/health"
240
+
241
+ _v_interval = _duration("interval")
242
+ _v_boot = _duration("boot_timeout")
243
+ _v_stall = _duration("pull_stall_timeout")
244
+ _v_startup = _duration("startup_timeout")
245
+
246
+
247
+ class BudgetConfig(_Base):
248
+ max_hourly_cost_usd: Optional[float] = None
249
+ max_session_cost_usd: Optional[float] = None
250
+
251
+
252
+ class WorkloadConfig(_Base):
253
+ mode: Literal["local", "remote"] = "local"
254
+ env: dict[str, str] = Field(default_factory=dict)
255
+ workdir: Optional[str] = None
256
+ sync: bool = False
257
+
258
+
259
+ class RuntimeConfig(_Base):
260
+ """The whole parsed gpu-runtime config (jambu.yaml's gpu_runtime: section, or a legacy config.yml)."""
261
+
262
+ version: int = 1
263
+ provider: ProviderConfig = Field(default_factory=ProviderConfig)
264
+ compute: ComputeConfig = Field(default_factory=ComputeConfig)
265
+ model: ModelConfig
266
+ runtime: RuntimeConfigSection = Field(default_factory=RuntimeConfigSection)
267
+ lifecycle: LifecycleConfig = Field(default_factory=LifecycleConfig)
268
+ health: HealthConfig = Field(default_factory=HealthConfig)
269
+ budget: BudgetConfig = Field(default_factory=BudgetConfig)
270
+ workload: WorkloadConfig = Field(default_factory=WorkloadConfig)
271
+
272
+ # Populated by the loader; not part of the YAML document.
273
+ source_path: Optional[Path] = Field(default=None, exclude=True)
274
+ profile_name: Optional[str] = Field(default=None, exclude=True)
275
+ catalog_path: Optional[Path] = Field(default=None, exclude=True)
276
+
277
+ @field_validator("version")
278
+ @classmethod
279
+ def _version(cls, v: int) -> int:
280
+ if v != 1:
281
+ raise ValueError(f"unsupported config version: {v} (expected 1)")
282
+ return v
283
+
284
+ # -- convenience accessors, all in seconds ------------------------------
285
+
286
+ @property
287
+ def idle_timeout_s(self) -> Optional[float]:
288
+ return parse_duration(self.lifecycle.idle_timeout)
289
+
290
+ @property
291
+ def max_lifetime_s(self) -> Optional[float]:
292
+ return parse_duration(self.lifecycle.max_lifetime)
293
+
294
+ @property
295
+ def stopped_ttl_s(self) -> Optional[float]:
296
+ return parse_duration(self.lifecycle.stopped_ttl)
297
+
298
+ @property
299
+ def heartbeat_interval_s(self) -> float:
300
+ return parse_duration(self.lifecycle.heartbeat_interval) or 30.0
301
+
302
+ @property
303
+ def heartbeat_grace_s(self) -> float:
304
+ return parse_duration(self.lifecycle.heartbeat_grace) or 300.0
305
+
306
+ @property
307
+ def watchdog_interval_s(self) -> float:
308
+ return parse_duration(self.lifecycle.watchdog.interval) or 60.0
309
+
310
+ @property
311
+ def health_interval_s(self) -> float:
312
+ return parse_duration(self.health.interval) or 15.0
313
+
314
+ @property
315
+ def boot_timeout_s(self) -> float:
316
+ return parse_duration(self.health.boot_timeout) or 1800.0
317
+
318
+ @property
319
+ def pull_stall_timeout_s(self) -> Optional[float]:
320
+ return parse_duration(self.health.pull_stall_timeout)
321
+
322
+ @property
323
+ def startup_timeout_s(self) -> float:
324
+ return parse_duration(self.health.startup_timeout) or 600.0
325
+
326
+ @property
327
+ def lock_default_ttl_s(self) -> Optional[float]:
328
+ return parse_duration(self.lifecycle.lock.default_ttl)
329
+
330
+ @property
331
+ def project_dir(self) -> Path:
332
+ return self.source_path.parent if self.source_path else Path.cwd()
333
+
334
+ @property
335
+ def state_dir(self) -> Path:
336
+ return project_state_dir(self.project_dir)
337
+
338
+ def fingerprint(self) -> str:
339
+ """Identity of the *desired* runtime.
340
+
341
+ Two configs with the same fingerprint may reuse the same instance
342
+ (spec section 23 idempotency); a change means the running instance is
343
+ incompatible with the requested runtime.
344
+
345
+ MUST cover every field that feeds the actual `vllm serve` command
346
+ line (see runtime/vllm.py::server_args()) - not just model/compute.
347
+ The onstart script (and the command it launches) is baked in at
348
+ instance CREATION time and re-executed verbatim on every subsequent
349
+ provider-level start, INCLUDING a plain restart of a stopped
350
+ instance - it is never regenerated for an existing instance. A field
351
+ missing here means changing it (e.g. runtime.tool_calling) silently
352
+ keeps serving the OLD command forever, no matter how many times the
353
+ instance is stopped and restarted, while `gpu status` reports
354
+ the NEW config as if it were live. Confirmed the hard way: flipping
355
+ tool_calling on for an already-provisioned instance did nothing until
356
+ this was fixed - only `--recreate` regenerates the onstart script.
357
+ """
358
+ import hashlib
359
+ import json
360
+
361
+ material = {
362
+ "provider": self.provider.name,
363
+ "gpu": self.compute.gpu.model_dump(),
364
+ "disk_gb": self.compute.disk_gb,
365
+ "image": self.compute.image,
366
+ "model": {
367
+ "id": self.model.id,
368
+ "revision": self.model.revision,
369
+ "trust_remote_code": self.model.trust_remote_code,
370
+ "served_name": self.model.served_name,
371
+ },
372
+ "runtime": {
373
+ "engine": self.runtime.engine,
374
+ "port": self.runtime.port,
375
+ "context_length": self.runtime.context_length,
376
+ "dtype": self.runtime.dtype,
377
+ "gpu_memory_utilization": self.runtime.gpu_memory_utilization,
378
+ "tensor_parallel_size": self.runtime.tensor_parallel_size,
379
+ "api_key": self.runtime.api_key,
380
+ "tool_calling": self.runtime.tool_calling,
381
+ "tool_call_parser": self.runtime.tool_call_parser,
382
+ "tool_server": self.runtime.tool_server,
383
+ "vllm_args": self.runtime.vllm_args,
384
+ "extra_args": self.runtime.extra_args,
385
+ },
386
+ }
387
+ blob = json.dumps(material, sort_keys=True, default=str).encode()
388
+ return hashlib.sha256(blob).hexdigest()[:16]
389
+
390
+
391
+ def find_config(start: Optional[Path] = None) -> Optional[Path]:
392
+ """Walk up from ``start`` looking for a config file."""
393
+ current = (start or Path.cwd()).resolve()
394
+ for directory in [current, *current.parents]:
395
+ for name in CONFIG_FILENAMES:
396
+ candidate = directory / name
397
+ if candidate.is_file():
398
+ return candidate
399
+ return None
400
+
401
+
402
+ def find_catalog(start: Path) -> Optional[Path]:
403
+ """Walk up from ``start`` looking for a model catalog file."""
404
+ current = start.resolve()
405
+ for directory in [current, *current.parents]:
406
+ for name in CATALOG_FILENAMES:
407
+ candidate = directory / name
408
+ if candidate.is_file():
409
+ return candidate
410
+ return None
411
+
412
+
413
+ def load_catalog(path: Path) -> dict[str, Any]:
414
+ """Load a model catalog: ``{profile-name: config-fragment}``."""
415
+ try:
416
+ raw = yaml.safe_load(path.read_text()) or {}
417
+ except yaml.YAMLError as exc:
418
+ raise ConfigError(f"{path}: invalid YAML: {exc}") from exc
419
+ if not isinstance(raw, dict):
420
+ raise ConfigError(
421
+ f"{path}: expected a YAML mapping of profile-name -> config fragment"
422
+ )
423
+ return _expand_env(raw)
424
+
425
+
426
+ def deep_merge(base: Any, override: Any) -> Any:
427
+ """Recursively merge ``override`` onto ``base``; override wins on conflict.
428
+
429
+ Dicts merge key by key (recursively); anything else (including lists) is
430
+ replaced wholesale by ``override`` when present - a profile's
431
+ ``extra_args`` list is not concatenated with the project's, it's swapped.
432
+ """
433
+ if isinstance(base, dict) and isinstance(override, dict):
434
+ merged = dict(base)
435
+ for key, value in override.items():
436
+ merged[key] = deep_merge(merged.get(key), value) if key in merged else value
437
+ return merged
438
+ return override
439
+
440
+
441
+ def _resolve_profile(body: dict[str, Any], config_dir: Path) -> tuple[dict[str, Any], Optional[str], Optional[Path]]:
442
+ """Merge a ``model_profile:`` reference (if any) into ``body``.
443
+
444
+ The project's own fields always win over the profile's - a profile is a
445
+ set of defaults, not a lock. Returns (merged_body, profile_name, catalog_path).
446
+ """
447
+ profile_name = body.get(PROFILE_KEY)
448
+ if profile_name is None:
449
+ return body, None, None
450
+ if not isinstance(profile_name, str):
451
+ raise ConfigError(f"'{PROFILE_KEY}' must be a string, got {type(profile_name).__name__}")
452
+
453
+ catalog_path = find_catalog(config_dir)
454
+ if catalog_path is None:
455
+ raise ConfigError(
456
+ f"{PROFILE_KEY}: '{profile_name}' is set, but no "
457
+ f"{'/'.join(CATALOG_FILENAMES)} was found in {config_dir} or any parent. "
458
+ "Create one (see docs/CHOOSING_A_MODEL.md) or remove model_profile "
459
+ "and declare model:/compute: directly."
460
+ )
461
+ catalog = load_catalog(catalog_path)
462
+ if profile_name not in catalog:
463
+ available = ", ".join(sorted(catalog)) or "<none defined>"
464
+ raise ConfigError(
465
+ f"{PROFILE_KEY} '{profile_name}' not found in {catalog_path} "
466
+ f"(available: {available})"
467
+ )
468
+ fragment = catalog[profile_name]
469
+ if not isinstance(fragment, dict):
470
+ raise ConfigError(f"{catalog_path}: profile '{profile_name}' must be a mapping")
471
+
472
+ overrides = {k: v for k, v in body.items() if k != PROFILE_KEY}
473
+ merged = deep_merge(fragment, overrides)
474
+ return merged, profile_name, catalog_path
475
+
476
+
477
+ def load_config(path: Optional[Path] = None, start: Optional[Path] = None) -> RuntimeConfig:
478
+ """Load and validate jambu.yaml (or legacy config.yml), raising ConfigError
479
+ with a readable message.
480
+ """
481
+ resolved = Path(path).resolve() if path else find_config(start)
482
+ if resolved is None:
483
+ raise ConfigError(
484
+ "no jambu.yaml (or config.yml) found in this directory or any "
485
+ "parent. Run `gpu init` to create one."
486
+ )
487
+ if not resolved.is_file():
488
+ raise ConfigError(f"config file not found: {resolved}")
489
+
490
+ try:
491
+ raw = yaml.safe_load(resolved.read_text()) or {}
492
+ except yaml.YAMLError as exc:
493
+ raise ConfigError(f"{resolved}: invalid YAML: {exc}") from exc
494
+ if not isinstance(raw, dict):
495
+ raise ConfigError(f"{resolved}: expected a YAML mapping at the top level")
496
+
497
+ raw = _expand_env(raw)
498
+
499
+ # jambu.yaml is shared with other Jambu Lab tools: our section lives
500
+ # under gpu_runtime: and everything else in the document belongs to
501
+ # someone else. A bare config.yml (no such key) IS the whole document.
502
+ if GPU_RUNTIME_KEY in raw:
503
+ body = raw[GPU_RUNTIME_KEY]
504
+ if not isinstance(body, dict):
505
+ raise ConfigError(
506
+ f"{resolved}: '{GPU_RUNTIME_KEY}:' must be a mapping, got "
507
+ f"{type(body).__name__}"
508
+ )
509
+ else:
510
+ body = raw
511
+
512
+ body, profile_name, catalog_path = _resolve_profile(body, resolved.parent)
513
+
514
+ try:
515
+ config = RuntimeConfig.model_validate(body)
516
+ except Exception as exc: # pydantic.ValidationError
517
+ raise ConfigError(f"{resolved}: {_format_validation_error(exc)}") from exc
518
+
519
+ config.source_path = resolved
520
+ config.profile_name = profile_name
521
+ config.catalog_path = catalog_path
522
+ return config
523
+
524
+
525
+ def _expand_env(node: Any) -> Any:
526
+ """Expand ``${VAR}`` references inside string values (never credentials in the file)."""
527
+ if isinstance(node, dict):
528
+ return {k: _expand_env(v) for k, v in node.items()}
529
+ if isinstance(node, list):
530
+ return [_expand_env(v) for v in node]
531
+ if isinstance(node, str) and "${" in node:
532
+ return os.path.expandvars(node)
533
+ return node
534
+
535
+
536
+ def _format_validation_error(exc: Exception) -> str:
537
+ errors = getattr(exc, "errors", None)
538
+ if not callable(errors):
539
+ return str(exc)
540
+ lines = []
541
+ for err in errors():
542
+ loc = ".".join(str(p) for p in err.get("loc", ())) or "<root>"
543
+ lines.append(f"{loc}: {err.get('msg')}")
544
+ return "invalid configuration\n - " + "\n - ".join(lines)
545
+
546
+
547
+ DEFAULT_CONFIG_TEMPLATE = """\
548
+ # jambu.yaml - shared across Jambu.ai Lab sub-projects. This tool only reads
549
+ # the gpu_runtime: key below; add other tools' own top-level keys alongside
550
+ # it without conflict.
551
+ gpu_runtime:
552
+ version: 1
553
+
554
+ provider:
555
+ name: vast
556
+ profile: default
557
+
558
+ compute:
559
+ gpu:
560
+ min_vram_gb: 24
561
+ count: 1
562
+ disk_gb: 80
563
+
564
+ model:
565
+ id: {model_id}
566
+
567
+ runtime:
568
+ engine: vllm
569
+ port: 8000
570
+ context_length: 16384
571
+
572
+ lifecycle:
573
+ max_lifetime: 6h
574
+ idle_timeout: 15m
575
+ stopped_ttl: 24h
576
+ auto_stop: true
577
+ auto_destroy: false
578
+ traffic_counts_as_activity: true
579
+ cleanup_on_setup_failure: true
580
+ allow_indefinite_lock: false
581
+ lock:
582
+ enabled: false
583
+ watchdog:
584
+ enabled: true
585
+ port: 8777
586
+ interval: 60s
587
+
588
+ health:
589
+ interval: 60s
590
+ boot_timeout: 30m
591
+ pull_stall_timeout: 10m
592
+ startup_timeout: 10m
593
+
594
+ budget:
595
+ max_hourly_cost_usd: 1.00
596
+ max_session_cost_usd: 5.00
597
+ """
598
+
599
+ # Legacy shape: the whole document IS the gpu-runtime config, no wrapper key.
600
+ # Still accepted by load_config() for config.yml / config.yaml.
601
+ DEFAULT_CONFIG_TEMPLATE_FLAT = """\
602
+ version: 1
603
+
604
+ provider:
605
+ name: vast
606
+ profile: default
607
+
608
+ compute:
609
+ gpu:
610
+ min_vram_gb: 24
611
+ count: 1
612
+ disk_gb: 80
613
+
614
+ model:
615
+ id: {model_id}
616
+
617
+ runtime:
618
+ engine: vllm
619
+ port: 8000
620
+ context_length: 16384
621
+
622
+ lifecycle:
623
+ max_lifetime: 6h
624
+ idle_timeout: 15m
625
+ stopped_ttl: 24h
626
+ auto_stop: true
627
+ auto_destroy: false
628
+ traffic_counts_as_activity: true
629
+ cleanup_on_setup_failure: true
630
+ allow_indefinite_lock: false
631
+ lock:
632
+ enabled: false
633
+ watchdog:
634
+ enabled: true
635
+ port: 8777
636
+ interval: 60s
637
+
638
+ health:
639
+ interval: 60s
640
+ boot_timeout: 30m
641
+ pull_stall_timeout: 10m
642
+ startup_timeout: 10m
643
+
644
+ budget:
645
+ max_hourly_cost_usd: 1.00
646
+ max_session_cost_usd: 5.00
647
+ """