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/policy.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Lifecycle policy (spec sections 10-13, 25).
|
|
2
|
+
|
|
3
|
+
This module is the SINGLE SOURCE OF TRUTH for "may this instance stay
|
|
4
|
+
billable?". It is deliberately stdlib-only and dependency-free because its
|
|
5
|
+
source is inlined verbatim into the remote watchdog agent that runs on the
|
|
6
|
+
rented instance (see jambu_gpu/watchdog/builder.py). Do not import anything
|
|
7
|
+
from the rest of the package here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
# --- normalized runtime state (spec section 8) -------------------------------
|
|
16
|
+
|
|
17
|
+
PROVISIONING = "provisioning"
|
|
18
|
+
STARTING = "starting"
|
|
19
|
+
READY = "ready"
|
|
20
|
+
BUSY = "busy"
|
|
21
|
+
IDLE = "idle"
|
|
22
|
+
STOPPED = "stopped"
|
|
23
|
+
FAILED = "failed"
|
|
24
|
+
DESTROYED = "destroyed"
|
|
25
|
+
UNKNOWN = "unknown"
|
|
26
|
+
|
|
27
|
+
# --- actions -----------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
ACTION_NONE = "none"
|
|
30
|
+
ACTION_STOP = "stop"
|
|
31
|
+
ACTION_DESTROY = "destroy"
|
|
32
|
+
|
|
33
|
+
# --- reasons (recorded on every automatic shutdown, spec criterion 12) --------
|
|
34
|
+
|
|
35
|
+
REASON_IDLE = "idle_timeout"
|
|
36
|
+
REASON_MAX_LIFETIME = "max_lifetime"
|
|
37
|
+
REASON_BUDGET = "budget_exceeded"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Workload:
|
|
42
|
+
"""A registered unit of useful work (spec section 12)."""
|
|
43
|
+
|
|
44
|
+
id: str
|
|
45
|
+
started_at: float
|
|
46
|
+
last_heartbeat: float
|
|
47
|
+
label: str = ""
|
|
48
|
+
state: str = "running" # running | queued | finished | failed
|
|
49
|
+
|
|
50
|
+
def is_pending(self) -> bool:
|
|
51
|
+
return self.state in ("running", "queued")
|
|
52
|
+
|
|
53
|
+
def is_stale(self, now: float, grace: float) -> bool:
|
|
54
|
+
"""Heartbeat vanished for longer than the grace period (spec section 13)."""
|
|
55
|
+
if not self.is_pending():
|
|
56
|
+
return False
|
|
57
|
+
if self.state == "queued":
|
|
58
|
+
return False
|
|
59
|
+
return (now - self.last_heartbeat) > grace
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class PolicyConfig:
|
|
64
|
+
idle_timeout_s: Optional[float] = None
|
|
65
|
+
max_lifetime_s: Optional[float] = None
|
|
66
|
+
heartbeat_grace_s: float = 300.0
|
|
67
|
+
auto_stop: bool = True
|
|
68
|
+
auto_destroy: bool = False
|
|
69
|
+
allow_indefinite_lock: bool = False
|
|
70
|
+
stop_supported: bool = True
|
|
71
|
+
destroy_supported: bool = True
|
|
72
|
+
# False when a stopped instance keeps costing real money (spec section 25).
|
|
73
|
+
stop_eliminates_billing: bool = True
|
|
74
|
+
# Cost guardrails (spec section 17).
|
|
75
|
+
hourly_cost_usd: Optional[float] = None
|
|
76
|
+
max_session_cost_usd: Optional[float] = None
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_dict(cls, data: dict) -> "PolicyConfig":
|
|
80
|
+
known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
|
|
81
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict:
|
|
84
|
+
return {f: getattr(self, f) for f in self.__dataclass_fields__} # type: ignore[attr-defined]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class Snapshot:
|
|
89
|
+
"""Everything the policy needs to decide. Times are unix seconds."""
|
|
90
|
+
|
|
91
|
+
now: float
|
|
92
|
+
created_at: float
|
|
93
|
+
last_activity: float
|
|
94
|
+
workloads: list = field(default_factory=list) # list[Workload]
|
|
95
|
+
starting: bool = False
|
|
96
|
+
locked: bool = False
|
|
97
|
+
locked_until: Optional[float] = None
|
|
98
|
+
provider_state: str = READY
|
|
99
|
+
|
|
100
|
+
# -- derived helpers ----------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def lock_active(self, cfg: PolicyConfig) -> bool:
|
|
103
|
+
if not self.locked:
|
|
104
|
+
return False
|
|
105
|
+
if self.locked_until is None:
|
|
106
|
+
# An indefinite lock only holds when the project explicitly allows it.
|
|
107
|
+
return bool(cfg.allow_indefinite_lock)
|
|
108
|
+
return self.locked_until > self.now
|
|
109
|
+
|
|
110
|
+
def lock_expired(self, cfg: PolicyConfig) -> bool:
|
|
111
|
+
if not self.locked:
|
|
112
|
+
return False
|
|
113
|
+
if self.locked_until is None:
|
|
114
|
+
return not cfg.allow_indefinite_lock
|
|
115
|
+
return self.locked_until <= self.now
|
|
116
|
+
|
|
117
|
+
def pending(self, cfg: PolicyConfig) -> list:
|
|
118
|
+
"""Workloads that still count as useful work."""
|
|
119
|
+
return [
|
|
120
|
+
w
|
|
121
|
+
for w in self.workloads
|
|
122
|
+
if w.is_pending() and not w.is_stale(self.now, cfg.heartbeat_grace_s)
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
def stale(self, cfg: PolicyConfig) -> list:
|
|
126
|
+
return [w for w in self.workloads if w.is_stale(self.now, cfg.heartbeat_grace_s)]
|
|
127
|
+
|
|
128
|
+
def idle_seconds(self, cfg: PolicyConfig) -> float:
|
|
129
|
+
"""Seconds since the last thing that counted as activity."""
|
|
130
|
+
if self.pending(cfg) or self.starting:
|
|
131
|
+
return 0.0
|
|
132
|
+
marks = [self.last_activity]
|
|
133
|
+
for w in self.workloads:
|
|
134
|
+
marks.append(w.last_heartbeat)
|
|
135
|
+
return max(0.0, self.now - max(marks))
|
|
136
|
+
|
|
137
|
+
def age_seconds(self) -> float:
|
|
138
|
+
return max(0.0, self.now - self.created_at)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class Decision:
|
|
143
|
+
action: str = ACTION_NONE
|
|
144
|
+
reason: Optional[str] = None
|
|
145
|
+
detail: dict = field(default_factory=dict)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def terminates(self) -> bool:
|
|
149
|
+
return self.action in (ACTION_STOP, ACTION_DESTROY)
|
|
150
|
+
|
|
151
|
+
def to_dict(self) -> dict:
|
|
152
|
+
return {"action": self.action, "reason": self.reason, "detail": dict(self.detail)}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def derive_state(snapshot: Snapshot, cfg: PolicyConfig) -> str:
|
|
156
|
+
"""Normalized observed state of a live instance."""
|
|
157
|
+
if snapshot.provider_state in (STOPPED, DESTROYED, FAILED, PROVISIONING):
|
|
158
|
+
return snapshot.provider_state
|
|
159
|
+
if snapshot.starting:
|
|
160
|
+
return STARTING
|
|
161
|
+
if snapshot.pending(cfg):
|
|
162
|
+
return BUSY
|
|
163
|
+
if snapshot.provider_state == READY:
|
|
164
|
+
return IDLE if snapshot.idle_seconds(cfg) > 0 else READY
|
|
165
|
+
return snapshot.provider_state
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _terminal_action(cfg: PolicyConfig) -> str:
|
|
169
|
+
"""Pick the cheapest supported action that actually stops the bleeding."""
|
|
170
|
+
if cfg.auto_destroy:
|
|
171
|
+
return ACTION_DESTROY if cfg.destroy_supported else ACTION_NONE
|
|
172
|
+
if not cfg.auto_stop:
|
|
173
|
+
return ACTION_NONE
|
|
174
|
+
if cfg.stop_supported and cfg.stop_eliminates_billing:
|
|
175
|
+
return ACTION_STOP
|
|
176
|
+
# Stopping does not (or cannot) end billing: escalate only when allowed to.
|
|
177
|
+
if not cfg.stop_supported:
|
|
178
|
+
return ACTION_DESTROY if cfg.destroy_supported else ACTION_NONE
|
|
179
|
+
return ACTION_STOP
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def evaluate(snapshot: Snapshot, cfg: PolicyConfig) -> Decision:
|
|
183
|
+
"""Core lifecycle rule (spec section 10). Pure function, no side effects."""
|
|
184
|
+
detail: dict[str, Any] = {
|
|
185
|
+
"state": derive_state(snapshot, cfg),
|
|
186
|
+
"age_seconds": round(snapshot.age_seconds(), 1),
|
|
187
|
+
"idle_seconds": round(snapshot.idle_seconds(cfg), 1),
|
|
188
|
+
"pending_workloads": len(snapshot.pending(cfg)),
|
|
189
|
+
"stale_workloads": len(snapshot.stale(cfg)),
|
|
190
|
+
"locked": snapshot.lock_active(cfg),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if snapshot.provider_state in (STOPPED, DESTROYED):
|
|
194
|
+
return Decision(ACTION_NONE, "already_terminal", detail)
|
|
195
|
+
|
|
196
|
+
locked = snapshot.lock_active(cfg)
|
|
197
|
+
|
|
198
|
+
# max_lifetime wins over everything except an active lock.
|
|
199
|
+
if cfg.max_lifetime_s is not None and snapshot.age_seconds() > cfg.max_lifetime_s:
|
|
200
|
+
if locked:
|
|
201
|
+
return Decision(ACTION_NONE, "locked", detail)
|
|
202
|
+
action = _terminal_action(cfg)
|
|
203
|
+
if action == ACTION_NONE:
|
|
204
|
+
return Decision(ACTION_NONE, "max_lifetime_no_action", detail)
|
|
205
|
+
return Decision(action, REASON_MAX_LIFETIME, detail)
|
|
206
|
+
|
|
207
|
+
# Session budget ceiling.
|
|
208
|
+
if cfg.max_session_cost_usd is not None and cfg.hourly_cost_usd:
|
|
209
|
+
spent = cfg.hourly_cost_usd * snapshot.age_seconds() / 3600.0
|
|
210
|
+
detail["session_cost_usd"] = round(spent, 4)
|
|
211
|
+
if spent > cfg.max_session_cost_usd:
|
|
212
|
+
if locked:
|
|
213
|
+
return Decision(ACTION_NONE, "locked", detail)
|
|
214
|
+
action = _terminal_action(cfg)
|
|
215
|
+
if action != ACTION_NONE:
|
|
216
|
+
return Decision(action, REASON_BUDGET, detail)
|
|
217
|
+
|
|
218
|
+
if locked:
|
|
219
|
+
return Decision(ACTION_NONE, "locked", detail)
|
|
220
|
+
|
|
221
|
+
# Useful work in flight is never interrupted (spec criterion 5).
|
|
222
|
+
if snapshot.starting:
|
|
223
|
+
return Decision(ACTION_NONE, "startup_in_progress", detail)
|
|
224
|
+
if snapshot.pending(cfg):
|
|
225
|
+
return Decision(ACTION_NONE, "workload_active", detail)
|
|
226
|
+
|
|
227
|
+
if cfg.idle_timeout_s is not None and snapshot.idle_seconds(cfg) > cfg.idle_timeout_s:
|
|
228
|
+
action = _terminal_action(cfg)
|
|
229
|
+
if action == ACTION_NONE:
|
|
230
|
+
return Decision(ACTION_NONE, "idle_no_action", detail)
|
|
231
|
+
return Decision(action, REASON_IDLE, detail)
|
|
232
|
+
|
|
233
|
+
return Decision(ACTION_NONE, "within_policy", detail)
|
jambu_gpu/core/state.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Observed runtime state (spec section 9).
|
|
2
|
+
|
|
3
|
+
jambu.yaml is the desired-state SoT; ``.jambu/state.json`` is what we last
|
|
4
|
+
observed. This module owns reading/writing it atomically, plus the on-disk
|
|
5
|
+
process lock that keeps two CLI invocations from provisioning twice.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fcntl
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from dataclasses import asdict, dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Iterator, Optional
|
|
21
|
+
|
|
22
|
+
from .errors import StateError
|
|
23
|
+
from .models import Instance, InstanceState
|
|
24
|
+
from .policy import Workload as PolicyWorkload
|
|
25
|
+
|
|
26
|
+
STATE_VERSION = 1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def utc_now() -> float:
|
|
30
|
+
return time.time()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def to_iso(ts: Optional[float]) -> Optional[str]:
|
|
34
|
+
if ts is None:
|
|
35
|
+
return None
|
|
36
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(timespec="seconds").replace(
|
|
37
|
+
"+00:00", "Z"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def from_iso(value: Any) -> Optional[float]:
|
|
42
|
+
if value is None or value == "":
|
|
43
|
+
return None
|
|
44
|
+
if isinstance(value, (int, float)):
|
|
45
|
+
return float(value)
|
|
46
|
+
text = str(value).replace("Z", "+00:00")
|
|
47
|
+
try:
|
|
48
|
+
return datetime.fromisoformat(text).timestamp()
|
|
49
|
+
except ValueError as exc:
|
|
50
|
+
raise StateError(f"invalid timestamp in state file: {value!r}") from exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class WorkloadRecord:
|
|
55
|
+
id: str
|
|
56
|
+
label: str = ""
|
|
57
|
+
command: list[str] = field(default_factory=list)
|
|
58
|
+
state: str = "running" # running | queued | finished | failed
|
|
59
|
+
started_at: float = field(default_factory=utc_now)
|
|
60
|
+
last_heartbeat: float = field(default_factory=utc_now)
|
|
61
|
+
finished_at: Optional[float] = None
|
|
62
|
+
returncode: Optional[int] = None
|
|
63
|
+
pid: Optional[int] = None
|
|
64
|
+
|
|
65
|
+
def to_policy(self) -> PolicyWorkload:
|
|
66
|
+
return PolicyWorkload(
|
|
67
|
+
id=self.id,
|
|
68
|
+
started_at=self.started_at,
|
|
69
|
+
last_heartbeat=self.last_heartbeat,
|
|
70
|
+
label=self.label,
|
|
71
|
+
state=self.state,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict[str, Any]:
|
|
75
|
+
data = asdict(self)
|
|
76
|
+
data["started_at"] = to_iso(self.started_at)
|
|
77
|
+
data["last_heartbeat"] = to_iso(self.last_heartbeat)
|
|
78
|
+
data["finished_at"] = to_iso(self.finished_at)
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_dict(cls, data: dict[str, Any]) -> "WorkloadRecord":
|
|
83
|
+
return cls(
|
|
84
|
+
id=data["id"],
|
|
85
|
+
label=data.get("label", ""),
|
|
86
|
+
command=list(data.get("command") or []),
|
|
87
|
+
state=data.get("state", "running"),
|
|
88
|
+
started_at=from_iso(data.get("started_at")) or utc_now(),
|
|
89
|
+
last_heartbeat=from_iso(data.get("last_heartbeat")) or utc_now(),
|
|
90
|
+
finished_at=from_iso(data.get("finished_at")),
|
|
91
|
+
returncode=data.get("returncode"),
|
|
92
|
+
pid=data.get("pid"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class WatchdogInfo:
|
|
98
|
+
installed: bool = False
|
|
99
|
+
url: Optional[str] = None
|
|
100
|
+
token: Optional[str] = None
|
|
101
|
+
port: Optional[int] = None
|
|
102
|
+
last_contact: Optional[float] = None
|
|
103
|
+
last_error: Optional[str] = None
|
|
104
|
+
|
|
105
|
+
def to_dict(self) -> dict[str, Any]:
|
|
106
|
+
data = asdict(self)
|
|
107
|
+
data["last_contact"] = to_iso(self.last_contact)
|
|
108
|
+
return data
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_dict(cls, data: dict[str, Any]) -> "WatchdogInfo":
|
|
112
|
+
return cls(
|
|
113
|
+
installed=bool(data.get("installed")),
|
|
114
|
+
url=data.get("url"),
|
|
115
|
+
token=data.get("token"),
|
|
116
|
+
port=data.get("port"),
|
|
117
|
+
last_contact=from_iso(data.get("last_contact")),
|
|
118
|
+
last_error=data.get("last_error"),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class RuntimeState:
|
|
124
|
+
provider: str = ""
|
|
125
|
+
instance_id: Optional[str] = None
|
|
126
|
+
fingerprint: Optional[str] = None
|
|
127
|
+
state: str = InstanceState.DESTROYED.value
|
|
128
|
+
created_at: Optional[float] = None
|
|
129
|
+
last_activity: Optional[float] = None
|
|
130
|
+
locked: bool = False
|
|
131
|
+
locked_until: Optional[float] = None
|
|
132
|
+
lock_reason: str = ""
|
|
133
|
+
starting: bool = False
|
|
134
|
+
model_id: Optional[str] = None
|
|
135
|
+
endpoint: Optional[str] = None
|
|
136
|
+
hourly_cost_usd: Optional[float] = None
|
|
137
|
+
session_cost_usd: float = 0.0
|
|
138
|
+
# Billable-time bookkeeping (spec section 25): session_cost_usd must
|
|
139
|
+
# reflect actual running time, not wall-clock time since creation - a
|
|
140
|
+
# stopped instance costs nothing in compute-hours even if `status` is
|
|
141
|
+
# checked hours later. `billed_seconds` accumulates completed billable
|
|
142
|
+
# periods (e.g. before a stop); `running_since` marks an open one.
|
|
143
|
+
billed_seconds: float = 0.0
|
|
144
|
+
running_since: Optional[float] = None
|
|
145
|
+
instance: Optional[Instance] = None
|
|
146
|
+
workloads: dict[str, WorkloadRecord] = field(default_factory=dict)
|
|
147
|
+
watchdog: WatchdogInfo = field(default_factory=WatchdogInfo)
|
|
148
|
+
|
|
149
|
+
# -- helpers ------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def has_instance(self) -> bool:
|
|
152
|
+
return bool(self.instance_id) and self.state != InstanceState.DESTROYED.value
|
|
153
|
+
|
|
154
|
+
def touch(self) -> None:
|
|
155
|
+
self.last_activity = utc_now()
|
|
156
|
+
|
|
157
|
+
def accrued_billable_seconds(self, now: Optional[float] = None) -> float:
|
|
158
|
+
"""Total seconds actually spent in a billable-compute state.
|
|
159
|
+
|
|
160
|
+
Unlike ``now - created_at``, this excludes time spent stopped -
|
|
161
|
+
checking `status` long after an instance auto-stopped must not keep
|
|
162
|
+
inflating the reported cost.
|
|
163
|
+
"""
|
|
164
|
+
now = now if now is not None else utc_now()
|
|
165
|
+
open_period = max(0.0, now - self.running_since) if self.running_since is not None else 0.0
|
|
166
|
+
return self.billed_seconds + open_period
|
|
167
|
+
|
|
168
|
+
def mark_billable(self, is_billable: bool, now: Optional[float] = None) -> None:
|
|
169
|
+
"""Record a transition into/out of a billable-compute state.
|
|
170
|
+
|
|
171
|
+
Call this every time reconciliation observes the current state -
|
|
172
|
+
it's a no-op unless billable-ness actually changed since the last call.
|
|
173
|
+
"""
|
|
174
|
+
now = now if now is not None else utc_now()
|
|
175
|
+
was_billable = self.running_since is not None
|
|
176
|
+
if is_billable and not was_billable:
|
|
177
|
+
self.running_since = now
|
|
178
|
+
elif not is_billable and was_billable:
|
|
179
|
+
self.billed_seconds += max(0.0, now - self.running_since)
|
|
180
|
+
self.running_since = None
|
|
181
|
+
|
|
182
|
+
def pending_workloads(self) -> list[WorkloadRecord]:
|
|
183
|
+
return [w for w in self.workloads.values() if w.state in ("running", "queued")]
|
|
184
|
+
|
|
185
|
+
def prune_workloads(self, keep: int = 25) -> None:
|
|
186
|
+
finished = sorted(
|
|
187
|
+
(w for w in self.workloads.values() if w.state not in ("running", "queued")),
|
|
188
|
+
key=lambda w: w.finished_at or w.started_at,
|
|
189
|
+
)
|
|
190
|
+
for record in finished[:-keep] if len(finished) > keep else []:
|
|
191
|
+
self.workloads.pop(record.id, None)
|
|
192
|
+
|
|
193
|
+
def clear_instance(self, state: str = InstanceState.DESTROYED.value) -> None:
|
|
194
|
+
# Close out any open billable period and freeze the final total into
|
|
195
|
+
# session_cost_usd (the historical record of the finished session)
|
|
196
|
+
# before resetting the counters for whatever instance comes next.
|
|
197
|
+
self.mark_billable(False)
|
|
198
|
+
if self.hourly_cost_usd:
|
|
199
|
+
self.session_cost_usd = round(self.hourly_cost_usd * self.billed_seconds / 3600.0, 4)
|
|
200
|
+
self.instance_id = None
|
|
201
|
+
self.instance = None
|
|
202
|
+
self.endpoint = None
|
|
203
|
+
self.state = state
|
|
204
|
+
self.starting = False
|
|
205
|
+
self.locked = False
|
|
206
|
+
self.locked_until = None
|
|
207
|
+
self.watchdog = WatchdogInfo()
|
|
208
|
+
self.billed_seconds = 0.0
|
|
209
|
+
self.running_since = None
|
|
210
|
+
for record in self.workloads.values():
|
|
211
|
+
if record.state in ("running", "queued"):
|
|
212
|
+
record.state = "failed"
|
|
213
|
+
record.finished_at = utc_now()
|
|
214
|
+
|
|
215
|
+
# -- serialization ------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def to_dict(self) -> dict[str, Any]:
|
|
218
|
+
return {
|
|
219
|
+
"version": STATE_VERSION,
|
|
220
|
+
"provider": self.provider,
|
|
221
|
+
"instance_id": self.instance_id,
|
|
222
|
+
"fingerprint": self.fingerprint,
|
|
223
|
+
"state": self.state,
|
|
224
|
+
"created_at": to_iso(self.created_at),
|
|
225
|
+
"last_activity": to_iso(self.last_activity),
|
|
226
|
+
"locked": self.locked,
|
|
227
|
+
"locked_until": to_iso(self.locked_until),
|
|
228
|
+
"lock_reason": self.lock_reason,
|
|
229
|
+
"starting": self.starting,
|
|
230
|
+
"model_id": self.model_id,
|
|
231
|
+
"endpoint": self.endpoint,
|
|
232
|
+
"hourly_cost_usd": self.hourly_cost_usd,
|
|
233
|
+
"session_cost_usd": round(self.session_cost_usd, 4),
|
|
234
|
+
"billed_seconds": round(self.billed_seconds, 1),
|
|
235
|
+
"running_since": to_iso(self.running_since),
|
|
236
|
+
"instance": self.instance.to_dict() if self.instance else None,
|
|
237
|
+
"workloads": {k: v.to_dict() for k, v in self.workloads.items()},
|
|
238
|
+
"watchdog": self.watchdog.to_dict(),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
@classmethod
|
|
242
|
+
def from_dict(cls, data: dict[str, Any]) -> "RuntimeState":
|
|
243
|
+
instance_data = data.get("instance")
|
|
244
|
+
return cls(
|
|
245
|
+
provider=data.get("provider", ""),
|
|
246
|
+
instance_id=data.get("instance_id"),
|
|
247
|
+
fingerprint=data.get("fingerprint"),
|
|
248
|
+
state=data.get("state", InstanceState.DESTROYED.value),
|
|
249
|
+
created_at=from_iso(data.get("created_at")),
|
|
250
|
+
last_activity=from_iso(data.get("last_activity")),
|
|
251
|
+
locked=bool(data.get("locked")),
|
|
252
|
+
locked_until=from_iso(data.get("locked_until")),
|
|
253
|
+
lock_reason=data.get("lock_reason", ""),
|
|
254
|
+
starting=bool(data.get("starting")),
|
|
255
|
+
model_id=data.get("model_id"),
|
|
256
|
+
endpoint=data.get("endpoint"),
|
|
257
|
+
hourly_cost_usd=data.get("hourly_cost_usd"),
|
|
258
|
+
session_cost_usd=float(data.get("session_cost_usd") or 0.0),
|
|
259
|
+
billed_seconds=float(data.get("billed_seconds") or 0.0),
|
|
260
|
+
running_since=from_iso(data.get("running_since")),
|
|
261
|
+
instance=Instance.from_dict(instance_data) if instance_data else None,
|
|
262
|
+
workloads={
|
|
263
|
+
k: WorkloadRecord.from_dict(v) for k, v in (data.get("workloads") or {}).items()
|
|
264
|
+
},
|
|
265
|
+
watchdog=WatchdogInfo.from_dict(data.get("watchdog") or {}),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class StateStore:
|
|
270
|
+
"""Atomic reader/writer for ``.jambu/state.json``."""
|
|
271
|
+
|
|
272
|
+
def __init__(self, state_dir: Path) -> None:
|
|
273
|
+
self._depth = 0 # flock is per-open-file-description: keep it reentrant
|
|
274
|
+
self.dir = Path(state_dir)
|
|
275
|
+
self.path = self.dir / "state.json"
|
|
276
|
+
self.lock_path = self.dir / "runtime.lock"
|
|
277
|
+
self.logs_dir = self.dir / "logs"
|
|
278
|
+
|
|
279
|
+
def ensure_dirs(self) -> None:
|
|
280
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
281
|
+
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
gitignore = self.dir / ".gitignore"
|
|
283
|
+
if not gitignore.exists():
|
|
284
|
+
gitignore.write_text("*\n")
|
|
285
|
+
|
|
286
|
+
def exists(self) -> bool:
|
|
287
|
+
return self.path.is_file()
|
|
288
|
+
|
|
289
|
+
def load(self) -> RuntimeState:
|
|
290
|
+
if not self.path.is_file():
|
|
291
|
+
return RuntimeState()
|
|
292
|
+
try:
|
|
293
|
+
data = json.loads(self.path.read_text() or "{}")
|
|
294
|
+
except json.JSONDecodeError as exc:
|
|
295
|
+
raise StateError(f"{self.path} is corrupt: {exc}") from exc
|
|
296
|
+
if not data:
|
|
297
|
+
return RuntimeState()
|
|
298
|
+
return RuntimeState.from_dict(data)
|
|
299
|
+
|
|
300
|
+
def save(self, state: RuntimeState) -> None:
|
|
301
|
+
self.ensure_dirs()
|
|
302
|
+
payload = json.dumps(state.to_dict(), indent=2, sort_keys=False) + "\n"
|
|
303
|
+
fd, tmp = tempfile.mkstemp(dir=str(self.dir), prefix=".state-", suffix=".json")
|
|
304
|
+
try:
|
|
305
|
+
with os.fdopen(fd, "w") as handle:
|
|
306
|
+
handle.write(payload)
|
|
307
|
+
handle.flush()
|
|
308
|
+
os.fsync(handle.fileno())
|
|
309
|
+
os.replace(tmp, self.path)
|
|
310
|
+
finally:
|
|
311
|
+
if os.path.exists(tmp):
|
|
312
|
+
os.unlink(tmp)
|
|
313
|
+
|
|
314
|
+
@contextmanager
|
|
315
|
+
def transaction(self, timeout: float = 30.0) -> Iterator[RuntimeState]:
|
|
316
|
+
"""Exclusive read-modify-write. Guarantees `setup` cannot race itself."""
|
|
317
|
+
with self.process_lock(timeout=timeout):
|
|
318
|
+
state = self.load()
|
|
319
|
+
yield state
|
|
320
|
+
self.save(state)
|
|
321
|
+
|
|
322
|
+
@contextmanager
|
|
323
|
+
def process_lock(self, timeout: float = 30.0) -> Iterator[None]:
|
|
324
|
+
if self._depth > 0:
|
|
325
|
+
self._depth += 1
|
|
326
|
+
try:
|
|
327
|
+
yield
|
|
328
|
+
finally:
|
|
329
|
+
self._depth -= 1
|
|
330
|
+
return
|
|
331
|
+
self.ensure_dirs()
|
|
332
|
+
handle = open(self.lock_path, "a+")
|
|
333
|
+
deadline = time.time() + timeout
|
|
334
|
+
try:
|
|
335
|
+
while True:
|
|
336
|
+
try:
|
|
337
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
338
|
+
break
|
|
339
|
+
except BlockingIOError:
|
|
340
|
+
if time.time() >= deadline:
|
|
341
|
+
raise StateError(
|
|
342
|
+
"another gpu command is holding the runtime lock "
|
|
343
|
+
f"({self.lock_path}); retry when it finishes"
|
|
344
|
+
) from None
|
|
345
|
+
time.sleep(0.2)
|
|
346
|
+
handle.seek(0)
|
|
347
|
+
handle.truncate()
|
|
348
|
+
handle.write(f"{os.getpid()} {to_iso(utc_now())}\n")
|
|
349
|
+
handle.flush()
|
|
350
|
+
self._depth = 1
|
|
351
|
+
yield
|
|
352
|
+
finally:
|
|
353
|
+
self._depth = 0
|
|
354
|
+
try:
|
|
355
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
356
|
+
finally:
|
|
357
|
+
handle.close()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def new_workload_id() -> str:
|
|
361
|
+
return uuid.uuid4().hex[:12]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""model.id -> vLLM ``--tool-call-parser`` heuristics.
|
|
2
|
+
|
|
3
|
+
Lives in ``core`` (not ``runtime``) because both the vLLM runtime and
|
|
4
|
+
``core/model_inspect.py`` need it, and ``core`` must not depend on
|
|
5
|
+
``runtime`` (Provider != Runtime != Core stays a one-way dependency).
|
|
6
|
+
|
|
7
|
+
Source: https://docs.vllm.ai/en/latest/features/tool_calling/ - keep this in
|
|
8
|
+
sync when vLLM adds parsers; override with runtime.tool_call_parser for
|
|
9
|
+
anything not recognized here (fine-tunes, quantized repos, etc.).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
_TOOL_PARSER_HINTS: tuple[tuple[str, str], ...] = (
|
|
15
|
+
("llama-4", "llama4_pythonic"),
|
|
16
|
+
("llama4", "llama4_pythonic"),
|
|
17
|
+
("llama-3", "llama3_json"),
|
|
18
|
+
("llama3", "llama3_json"),
|
|
19
|
+
("hermes", "hermes"),
|
|
20
|
+
("qwen2.5", "hermes"),
|
|
21
|
+
("qwen-2.5", "hermes"),
|
|
22
|
+
("qwen3-coder", "qwen3_xml"),
|
|
23
|
+
("qwen3_coder", "qwen3_xml"),
|
|
24
|
+
# Qwen3.5/3.6/3.8 (hybrid Gated-DeltaNet architecture, vLLM >=0.17) emit
|
|
25
|
+
# tool calls in the SAME XML function/parameter format as Qwen3-Coder, not
|
|
26
|
+
# Hermes' JSON-in-XML format - confirmed empirically on
|
|
27
|
+
# empero-ai/Qwythos-9B-v2 (Qwen3.5-based): with --tool-call-parser hermes
|
|
28
|
+
# the model's <tool_call><function=x><parameter=y>...</tool_call> output
|
|
29
|
+
# landed as inert text in `content`; switching to qwen3_xml parses it into
|
|
30
|
+
# a real `tool_calls` array. Applies to this whole "point release" Qwen3.x
|
|
31
|
+
# family, unlike the original dash-versioned Qwen3 (e.g. Qwen3-8B), which
|
|
32
|
+
# does use hermes per Qwen's own docs.
|
|
33
|
+
("qwen3.8", "qwen3_xml"),
|
|
34
|
+
("qwen3_8", "qwen3_xml"),
|
|
35
|
+
("qwen3.6", "qwen3_xml"),
|
|
36
|
+
("qwen3_6", "qwen3_xml"),
|
|
37
|
+
("qwen3.5", "qwen3_xml"),
|
|
38
|
+
("qwen3_5", "qwen3_xml"),
|
|
39
|
+
("qwen3", "hermes"), # original dash-versioned Qwen3 (e.g. Qwen3-8B): hermes
|
|
40
|
+
("mistral", "mistral"),
|
|
41
|
+
("mixtral", "mistral"),
|
|
42
|
+
("granite-4", "granite4"),
|
|
43
|
+
("granite4", "granite4"),
|
|
44
|
+
("granite", "granite"),
|
|
45
|
+
("internlm", "internlm"),
|
|
46
|
+
("jamba", "jamba"),
|
|
47
|
+
("xlam", "xlam"),
|
|
48
|
+
("toolace", "pythonic"),
|
|
49
|
+
("deepseek-v3.1", "deepseek_v31"),
|
|
50
|
+
("deepseek-v3", "deepseek_v3"),
|
|
51
|
+
("glm-4.5", "glm45"),
|
|
52
|
+
("glm-4.7", "glm47"),
|
|
53
|
+
("functiongemma", "functiongemma"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def guess_tool_call_parser(model_id: str) -> "str | None":
|
|
58
|
+
"""Best-effort ``--tool-call-parser`` guess from a HuggingFace repo id."""
|
|
59
|
+
lowered = model_id.lower()
|
|
60
|
+
for needle, parser in _TOOL_PARSER_HINTS:
|
|
61
|
+
if needle in lowered:
|
|
62
|
+
return parser
|
|
63
|
+
return None
|