gpu4agents 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gpu4agents/__init__.py +5 -0
- gpu4agents/__main__.py +4 -0
- gpu4agents/agent/__init__.py +1 -0
- gpu4agents/agent/install.py +173 -0
- gpu4agents/agent/jobs.py +193 -0
- gpu4agents/agent/mcp.py +646 -0
- gpu4agents/agent/skill/SKILL.md +68 -0
- gpu4agents/cli/__init__.py +0 -0
- gpu4agents/cli/_common.py +131 -0
- gpu4agents/cli/account.py +330 -0
- gpu4agents/cli/agent.py +74 -0
- gpu4agents/cli/inspect.py +207 -0
- gpu4agents/cli/lifecycle.py +127 -0
- gpu4agents/cli/main.py +163 -0
- gpu4agents/cli/providers.py +96 -0
- gpu4agents/cli/run.py +38 -0
- gpu4agents/cli/setup.py +164 -0
- gpu4agents/cli/status.py +224 -0
- gpu4agents/cli/timer.py +143 -0
- gpu4agents/cli/validate.py +57 -0
- gpu4agents/core/__init__.py +0 -0
- gpu4agents/core/beacon.py +133 -0
- gpu4agents/core/config.py +647 -0
- gpu4agents/core/credentials.py +95 -0
- gpu4agents/core/durations.py +47 -0
- gpu4agents/core/engine.py +1335 -0
- gpu4agents/core/errors.py +94 -0
- gpu4agents/core/events.py +117 -0
- gpu4agents/core/execution.py +169 -0
- gpu4agents/core/health.py +62 -0
- gpu4agents/core/hosts.py +58 -0
- gpu4agents/core/model_inspect.py +368 -0
- gpu4agents/core/models.py +313 -0
- gpu4agents/core/paths.py +42 -0
- gpu4agents/core/policy.py +264 -0
- gpu4agents/core/state.py +406 -0
- gpu4agents/core/sweep.py +478 -0
- gpu4agents/core/tool_parsers.py +63 -0
- gpu4agents/providers/__init__.py +4 -0
- gpu4agents/providers/base.py +137 -0
- gpu4agents/providers/registry.py +63 -0
- gpu4agents/providers/vast/__init__.py +3 -0
- gpu4agents/providers/vast/client.py +255 -0
- gpu4agents/providers/vast/config.py +56 -0
- gpu4agents/providers/vast/mapper.py +292 -0
- gpu4agents/providers/vast/provider.py +438 -0
- gpu4agents/runtime/__init__.py +6 -0
- gpu4agents/runtime/base.py +184 -0
- gpu4agents/runtime/vllm.py +295 -0
- gpu4agents/watchdog/__init__.py +3 -0
- gpu4agents/watchdog/agent_body.py +959 -0
- gpu4agents/watchdog/builder.py +248 -0
- gpu4agents-0.5.0.dist-info/METADATA +157 -0
- gpu4agents-0.5.0.dist-info/RECORD +59 -0
- gpu4agents-0.5.0.dist-info/WHEEL +4 -0
- gpu4agents-0.5.0.dist-info/entry_points.txt +2 -0
- gpu4agents-0.5.0.dist-info/licenses/LICENSE +21 -0
- jambu_gpu/__init__.py +9 -0
- jambu_gpu/__main__.py +4 -0
|
@@ -0,0 +1,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
|
+
"""
|