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.
- jambu_gpu/__init__.py +5 -0
- jambu_gpu/__main__.py +4 -0
- jambu_gpu/cli/__init__.py +0 -0
- jambu_gpu/cli/_common.py +117 -0
- jambu_gpu/cli/inspect.py +207 -0
- jambu_gpu/cli/lifecycle.py +127 -0
- jambu_gpu/cli/main.py +103 -0
- jambu_gpu/cli/providers.py +96 -0
- jambu_gpu/cli/run.py +37 -0
- jambu_gpu/cli/setup.py +165 -0
- jambu_gpu/cli/status.py +214 -0
- jambu_gpu/cli/validate.py +57 -0
- jambu_gpu/core/__init__.py +0 -0
- jambu_gpu/core/beacon.py +124 -0
- jambu_gpu/core/config.py +596 -0
- jambu_gpu/core/credentials.py +75 -0
- jambu_gpu/core/durations.py +47 -0
- jambu_gpu/core/engine.py +951 -0
- jambu_gpu/core/errors.py +68 -0
- jambu_gpu/core/events.py +115 -0
- jambu_gpu/core/execution.py +169 -0
- jambu_gpu/core/health.py +62 -0
- jambu_gpu/core/model_inspect.py +368 -0
- jambu_gpu/core/models.py +283 -0
- jambu_gpu/core/policy.py +233 -0
- jambu_gpu/core/state.py +361 -0
- jambu_gpu/core/tool_parsers.py +63 -0
- jambu_gpu/providers/__init__.py +4 -0
- jambu_gpu/providers/base.py +125 -0
- jambu_gpu/providers/registry.py +63 -0
- jambu_gpu/providers/vast/__init__.py +3 -0
- jambu_gpu/providers/vast/client.py +230 -0
- jambu_gpu/providers/vast/config.py +51 -0
- jambu_gpu/providers/vast/mapper.py +221 -0
- jambu_gpu/providers/vast/provider.py +422 -0
- jambu_gpu/runtime/__init__.py +6 -0
- jambu_gpu/runtime/base.py +99 -0
- jambu_gpu/runtime/vllm.py +224 -0
- jambu_gpu/watchdog/__init__.py +3 -0
- jambu_gpu/watchdog/agent_body.py +547 -0
- jambu_gpu/watchdog/builder.py +178 -0
- jambu_gpu-0.1.0.dist-info/METADATA +82 -0
- jambu_gpu-0.1.0.dist-info/RECORD +46 -0
- jambu_gpu-0.1.0.dist-info/WHEEL +4 -0
- jambu_gpu-0.1.0.dist-info/entry_points.txt +2 -0
- jambu_gpu-0.1.0.dist-info/licenses/LICENSE +21 -0
jambu_gpu/core/config.py
ADDED
|
@@ -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
|