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