mlx-stack 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.
- mlx_stack/__init__.py +5 -0
- mlx_stack/_version.py +24 -0
- mlx_stack/cli/__init__.py +5 -0
- mlx_stack/cli/bench.py +221 -0
- mlx_stack/cli/config.py +166 -0
- mlx_stack/cli/down.py +109 -0
- mlx_stack/cli/init.py +180 -0
- mlx_stack/cli/install.py +165 -0
- mlx_stack/cli/logs.py +234 -0
- mlx_stack/cli/main.py +187 -0
- mlx_stack/cli/models.py +304 -0
- mlx_stack/cli/profile.py +65 -0
- mlx_stack/cli/pull.py +134 -0
- mlx_stack/cli/recommend.py +397 -0
- mlx_stack/cli/status.py +111 -0
- mlx_stack/cli/up.py +163 -0
- mlx_stack/cli/watch.py +252 -0
- mlx_stack/core/__init__.py +1 -0
- mlx_stack/core/benchmark.py +1182 -0
- mlx_stack/core/catalog.py +560 -0
- mlx_stack/core/config.py +471 -0
- mlx_stack/core/deps.py +323 -0
- mlx_stack/core/hardware.py +304 -0
- mlx_stack/core/launchd.py +531 -0
- mlx_stack/core/litellm_gen.py +188 -0
- mlx_stack/core/log_rotation.py +231 -0
- mlx_stack/core/log_viewer.py +386 -0
- mlx_stack/core/models.py +639 -0
- mlx_stack/core/paths.py +79 -0
- mlx_stack/core/process.py +887 -0
- mlx_stack/core/pull.py +815 -0
- mlx_stack/core/scoring.py +611 -0
- mlx_stack/core/stack_down.py +317 -0
- mlx_stack/core/stack_init.py +524 -0
- mlx_stack/core/stack_status.py +229 -0
- mlx_stack/core/stack_up.py +856 -0
- mlx_stack/core/watchdog.py +744 -0
- mlx_stack/data/__init__.py +1 -0
- mlx_stack/data/catalog/__init__.py +1 -0
- mlx_stack/data/catalog/deepseek-r1-32b.yaml +46 -0
- mlx_stack/data/catalog/deepseek-r1-8b.yaml +45 -0
- mlx_stack/data/catalog/gemma3-12b.yaml +45 -0
- mlx_stack/data/catalog/gemma3-27b.yaml +45 -0
- mlx_stack/data/catalog/gemma3-4b.yaml +45 -0
- mlx_stack/data/catalog/llama3.3-8b.yaml +44 -0
- mlx_stack/data/catalog/nemotron-49b.yaml +41 -0
- mlx_stack/data/catalog/nemotron-8b.yaml +44 -0
- mlx_stack/data/catalog/qwen3-8b.yaml +45 -0
- mlx_stack/data/catalog/qwen3.5-0.8b.yaml +45 -0
- mlx_stack/data/catalog/qwen3.5-14b.yaml +46 -0
- mlx_stack/data/catalog/qwen3.5-32b.yaml +45 -0
- mlx_stack/data/catalog/qwen3.5-3b.yaml +44 -0
- mlx_stack/data/catalog/qwen3.5-72b.yaml +42 -0
- mlx_stack/data/catalog/qwen3.5-8b.yaml +45 -0
- mlx_stack/py.typed +1 -0
- mlx_stack/utils/__init__.py +1 -0
- mlx_stack-0.1.0.dist-info/METADATA +397 -0
- mlx_stack-0.1.0.dist-info/RECORD +61 -0
- mlx_stack-0.1.0.dist-info/WHEEL +4 -0
- mlx_stack-0.1.0.dist-info/entry_points.txt +2 -0
- mlx_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""launchd integration for mlx-stack.
|
|
2
|
+
|
|
3
|
+
Implements plist generation and launchctl management for running the
|
|
4
|
+
watchdog health monitor as a macOS LaunchAgent. Uses plistlib (stdlib)
|
|
5
|
+
for plist generation.
|
|
6
|
+
|
|
7
|
+
Provides:
|
|
8
|
+
- generate_plist(): Generate a plist dict for the watchdog agent
|
|
9
|
+
- write_plist(): Write the plist to ~/Library/LaunchAgents/
|
|
10
|
+
- load_agent(): Load the agent via launchctl bootstrap
|
|
11
|
+
- unload_agent(): Unload the agent via launchctl bootout
|
|
12
|
+
- get_agent_status(): Check if the agent is loaded and get PID
|
|
13
|
+
- get_plist_path(): Get the canonical plist file path
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import plistlib
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from mlx_stack.core.paths import get_logs_dir, get_stacks_dir
|
|
28
|
+
|
|
29
|
+
# --------------------------------------------------------------------------- #
|
|
30
|
+
# Constants
|
|
31
|
+
# --------------------------------------------------------------------------- #
|
|
32
|
+
|
|
33
|
+
LAUNCHD_LABEL = "com.mlx-stack.watchdog"
|
|
34
|
+
PLIST_FILENAME = f"{LAUNCHD_LABEL}.plist"
|
|
35
|
+
PLIST_PERMISSIONS = 0o644
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --------------------------------------------------------------------------- #
|
|
39
|
+
# Exceptions
|
|
40
|
+
# --------------------------------------------------------------------------- #
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LaunchdError(Exception):
|
|
44
|
+
"""Raised when a launchd operation fails."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PlatformError(LaunchdError):
|
|
48
|
+
"""Raised when running on a non-macOS platform."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PrerequisiteError(LaunchdError):
|
|
52
|
+
"""Raised when a prerequisite is not met (e.g., init not run)."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------- #
|
|
56
|
+
# Data classes
|
|
57
|
+
# --------------------------------------------------------------------------- #
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class AgentStatus:
|
|
62
|
+
"""Status of the launchd agent."""
|
|
63
|
+
|
|
64
|
+
installed: bool
|
|
65
|
+
running: bool
|
|
66
|
+
pid: int | None
|
|
67
|
+
label: str = LAUNCHD_LABEL
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def message(self) -> str:
|
|
71
|
+
"""Return a human-readable status message."""
|
|
72
|
+
if not self.installed:
|
|
73
|
+
return "not installed"
|
|
74
|
+
if self.running and self.pid is not None:
|
|
75
|
+
return f"installed and running (PID {self.pid})"
|
|
76
|
+
return "installed but not running"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------- #
|
|
80
|
+
# Path helpers
|
|
81
|
+
# --------------------------------------------------------------------------- #
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_plist_path() -> Path:
|
|
85
|
+
"""Return the canonical path for the watchdog plist file.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
~/Library/LaunchAgents/com.mlx-stack.watchdog.plist
|
|
89
|
+
"""
|
|
90
|
+
return Path.home() / "Library" / "LaunchAgents" / PLIST_FILENAME
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --------------------------------------------------------------------------- #
|
|
94
|
+
# Platform and prerequisite checks
|
|
95
|
+
# --------------------------------------------------------------------------- #
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def check_platform() -> None:
|
|
99
|
+
"""Check that we're running on macOS.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
PlatformError: If not running on macOS (darwin).
|
|
103
|
+
"""
|
|
104
|
+
if sys.platform != "darwin":
|
|
105
|
+
msg = (
|
|
106
|
+
"launchd integration is only available on macOS. "
|
|
107
|
+
f"Current platform: {sys.platform}"
|
|
108
|
+
)
|
|
109
|
+
raise PlatformError(msg)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def check_init_prerequisite() -> None:
|
|
113
|
+
"""Check that mlx-stack init has been run.
|
|
114
|
+
|
|
115
|
+
Verifies that a stack definition exists at
|
|
116
|
+
~/.mlx-stack/stacks/default.yaml.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
PrerequisiteError: If init has not been run.
|
|
120
|
+
"""
|
|
121
|
+
stack_path = get_stacks_dir() / "default.yaml"
|
|
122
|
+
if not stack_path.exists():
|
|
123
|
+
msg = (
|
|
124
|
+
"No stack configuration found. "
|
|
125
|
+
"Run 'mlx-stack init' first."
|
|
126
|
+
)
|
|
127
|
+
raise PrerequisiteError(msg)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------------------------- #
|
|
131
|
+
# Plist generation
|
|
132
|
+
# --------------------------------------------------------------------------- #
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve_mlx_stack_binary() -> str:
|
|
136
|
+
"""Resolve the full path to the mlx-stack binary.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The full path to the mlx-stack executable.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
LaunchdError: If the binary cannot be found.
|
|
143
|
+
"""
|
|
144
|
+
binary = shutil.which("mlx-stack")
|
|
145
|
+
if binary is not None:
|
|
146
|
+
return binary
|
|
147
|
+
|
|
148
|
+
# Fallback: try sys.executable-based resolution
|
|
149
|
+
# (e.g., when installed in a venv, the binary is next to python)
|
|
150
|
+
exe_dir = Path(sys.executable).parent
|
|
151
|
+
candidate = exe_dir / "mlx-stack"
|
|
152
|
+
if candidate.exists():
|
|
153
|
+
return str(candidate)
|
|
154
|
+
|
|
155
|
+
msg = (
|
|
156
|
+
"Could not find the mlx-stack binary on PATH. "
|
|
157
|
+
"Ensure mlx-stack is properly installed."
|
|
158
|
+
)
|
|
159
|
+
raise LaunchdError(msg)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _build_environment_variables(mlx_stack_binary: str) -> dict[str, str]:
|
|
163
|
+
"""Build the EnvironmentVariables dict for the plist.
|
|
164
|
+
|
|
165
|
+
Always includes PATH (with the directory containing the mlx-stack
|
|
166
|
+
binary). Includes MLX_STACK_HOME only if a custom (non-default)
|
|
167
|
+
value is set via environment variable.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
mlx_stack_binary: Full path to the mlx-stack binary.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Dict of environment variable name → value.
|
|
174
|
+
"""
|
|
175
|
+
env: dict[str, str] = {}
|
|
176
|
+
|
|
177
|
+
# Build PATH: include the binary's directory plus standard paths
|
|
178
|
+
binary_dir = str(Path(mlx_stack_binary).parent)
|
|
179
|
+
standard_paths = [
|
|
180
|
+
"/usr/local/bin",
|
|
181
|
+
"/usr/bin",
|
|
182
|
+
"/bin",
|
|
183
|
+
"/usr/sbin",
|
|
184
|
+
"/sbin",
|
|
185
|
+
"/opt/homebrew/bin",
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
# Ensure binary_dir is first, then add standard paths not already present
|
|
189
|
+
path_components = [binary_dir]
|
|
190
|
+
for p in standard_paths:
|
|
191
|
+
if p != binary_dir:
|
|
192
|
+
path_components.append(p)
|
|
193
|
+
|
|
194
|
+
env["PATH"] = ":".join(path_components)
|
|
195
|
+
|
|
196
|
+
# Include MLX_STACK_HOME only if custom (non-default)
|
|
197
|
+
custom_home = os.environ.get("MLX_STACK_HOME")
|
|
198
|
+
if custom_home:
|
|
199
|
+
env["MLX_STACK_HOME"] = custom_home
|
|
200
|
+
|
|
201
|
+
return env
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def generate_plist(mlx_stack_binary: str | None = None) -> dict[str, Any]:
|
|
205
|
+
"""Generate the launchd plist dictionary for the watchdog agent.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
mlx_stack_binary: Full path to the mlx-stack binary.
|
|
209
|
+
If None, resolves automatically.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
A dict suitable for writing with plistlib.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
LaunchdError: If the binary cannot be resolved.
|
|
216
|
+
"""
|
|
217
|
+
if mlx_stack_binary is None:
|
|
218
|
+
mlx_stack_binary = _resolve_mlx_stack_binary()
|
|
219
|
+
|
|
220
|
+
logs_dir = get_logs_dir()
|
|
221
|
+
|
|
222
|
+
plist: dict[str, Any] = {
|
|
223
|
+
"Label": LAUNCHD_LABEL,
|
|
224
|
+
"ProgramArguments": [mlx_stack_binary, "watch"],
|
|
225
|
+
"RunAtLoad": True,
|
|
226
|
+
"KeepAlive": True,
|
|
227
|
+
"StandardOutPath": str(logs_dir / "watchdog.stdout.log"),
|
|
228
|
+
"StandardErrorPath": str(logs_dir / "watchdog.stderr.log"),
|
|
229
|
+
"EnvironmentVariables": _build_environment_variables(mlx_stack_binary),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return plist
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# --------------------------------------------------------------------------- #
|
|
236
|
+
# Plist file management
|
|
237
|
+
# --------------------------------------------------------------------------- #
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def write_plist(plist_data: dict[str, Any], plist_path: Path | None = None) -> Path:
|
|
241
|
+
"""Write the plist dict to the LaunchAgents directory.
|
|
242
|
+
|
|
243
|
+
Creates the ~/Library/LaunchAgents/ directory if it doesn't exist.
|
|
244
|
+
Sets file permissions to 0o644.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
plist_data: The plist dictionary to write.
|
|
248
|
+
plist_path: Override path for testing. Defaults to get_plist_path().
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
The path where the plist was written.
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
LaunchdError: If the plist cannot be written.
|
|
255
|
+
"""
|
|
256
|
+
if plist_path is None:
|
|
257
|
+
plist_path = get_plist_path()
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
# Create LaunchAgents directory if needed
|
|
261
|
+
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
|
|
263
|
+
# Write plist using plistlib
|
|
264
|
+
with open(plist_path, "wb") as f:
|
|
265
|
+
plistlib.dump(plist_data, f)
|
|
266
|
+
|
|
267
|
+
# Set permissions
|
|
268
|
+
plist_path.chmod(PLIST_PERMISSIONS)
|
|
269
|
+
|
|
270
|
+
except OSError as exc:
|
|
271
|
+
msg = f"Failed to write plist to {plist_path}: {exc}"
|
|
272
|
+
raise LaunchdError(msg) from None
|
|
273
|
+
|
|
274
|
+
return plist_path
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# --------------------------------------------------------------------------- #
|
|
278
|
+
# launchctl operations
|
|
279
|
+
# --------------------------------------------------------------------------- #
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _get_gui_uid() -> int:
|
|
283
|
+
"""Get the current user's UID for launchctl gui/ domain.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
The current user's UID.
|
|
287
|
+
"""
|
|
288
|
+
return os.getuid()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def load_agent(plist_path: Path | None = None) -> None:
|
|
292
|
+
"""Load the watchdog agent via launchctl bootstrap.
|
|
293
|
+
|
|
294
|
+
Runs: launchctl bootstrap gui/<uid> <plist_path>
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
plist_path: Path to the plist file. Defaults to get_plist_path().
|
|
298
|
+
|
|
299
|
+
Raises:
|
|
300
|
+
LaunchdError: If launchctl bootstrap fails.
|
|
301
|
+
"""
|
|
302
|
+
if plist_path is None:
|
|
303
|
+
plist_path = get_plist_path()
|
|
304
|
+
|
|
305
|
+
uid = _get_gui_uid()
|
|
306
|
+
cmd = ["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)]
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
result = subprocess.run(
|
|
310
|
+
cmd,
|
|
311
|
+
capture_output=True,
|
|
312
|
+
text=True,
|
|
313
|
+
timeout=30,
|
|
314
|
+
)
|
|
315
|
+
if result.returncode != 0:
|
|
316
|
+
stderr = result.stderr.strip()
|
|
317
|
+
msg = f"launchctl bootstrap failed (exit {result.returncode}): {stderr}"
|
|
318
|
+
raise LaunchdError(msg)
|
|
319
|
+
except subprocess.TimeoutExpired:
|
|
320
|
+
msg = "launchctl bootstrap timed out after 30 seconds"
|
|
321
|
+
raise LaunchdError(msg) from None
|
|
322
|
+
except FileNotFoundError:
|
|
323
|
+
msg = "launchctl not found — is this macOS?"
|
|
324
|
+
raise LaunchdError(msg) from None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def unload_agent(plist_path: Path | None = None) -> None:
|
|
328
|
+
"""Unload the watchdog agent via launchctl bootout.
|
|
329
|
+
|
|
330
|
+
Runs: launchctl bootout gui/<uid> <plist_path>
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
plist_path: Path to the plist file. Defaults to get_plist_path().
|
|
334
|
+
|
|
335
|
+
Raises:
|
|
336
|
+
LaunchdError: If launchctl bootout fails.
|
|
337
|
+
"""
|
|
338
|
+
if plist_path is None:
|
|
339
|
+
plist_path = get_plist_path()
|
|
340
|
+
|
|
341
|
+
uid = _get_gui_uid()
|
|
342
|
+
cmd = ["launchctl", "bootout", f"gui/{uid}", str(plist_path)]
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
result = subprocess.run(
|
|
346
|
+
cmd,
|
|
347
|
+
capture_output=True,
|
|
348
|
+
text=True,
|
|
349
|
+
timeout=30,
|
|
350
|
+
)
|
|
351
|
+
# bootout returns non-zero if the service isn't loaded;
|
|
352
|
+
# we treat that as non-fatal since we're just trying to
|
|
353
|
+
# ensure it's unloaded
|
|
354
|
+
if result.returncode != 0:
|
|
355
|
+
stderr = result.stderr.strip()
|
|
356
|
+
# Error 3 = "No such process" (already unloaded) — non-fatal
|
|
357
|
+
if "3:" not in stderr and "No such process" not in stderr:
|
|
358
|
+
msg = f"launchctl bootout failed (exit {result.returncode}): {stderr}"
|
|
359
|
+
raise LaunchdError(msg)
|
|
360
|
+
except subprocess.TimeoutExpired:
|
|
361
|
+
msg = "launchctl bootout timed out after 30 seconds"
|
|
362
|
+
raise LaunchdError(msg) from None
|
|
363
|
+
except FileNotFoundError:
|
|
364
|
+
msg = "launchctl not found — is this macOS?"
|
|
365
|
+
raise LaunchdError(msg) from None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# --------------------------------------------------------------------------- #
|
|
369
|
+
# Status checking
|
|
370
|
+
# --------------------------------------------------------------------------- #
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def get_agent_status() -> AgentStatus:
|
|
374
|
+
"""Check the current status of the launchd agent.
|
|
375
|
+
|
|
376
|
+
Checks:
|
|
377
|
+
1. Whether the plist file exists (installed)
|
|
378
|
+
2. Whether launchctl list shows the agent (running + PID)
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
AgentStatus with installed, running, and pid fields.
|
|
382
|
+
"""
|
|
383
|
+
plist_path = get_plist_path()
|
|
384
|
+
installed = plist_path.exists()
|
|
385
|
+
|
|
386
|
+
if not installed:
|
|
387
|
+
return AgentStatus(installed=False, running=False, pid=None)
|
|
388
|
+
|
|
389
|
+
# Check launchctl list for the agent
|
|
390
|
+
pid = _get_agent_pid()
|
|
391
|
+
|
|
392
|
+
return AgentStatus(
|
|
393
|
+
installed=True,
|
|
394
|
+
running=pid is not None,
|
|
395
|
+
pid=pid,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _get_agent_pid() -> int | None:
|
|
400
|
+
"""Query launchctl for the agent's PID.
|
|
401
|
+
|
|
402
|
+
Runs: launchctl list com.mlx-stack.watchdog
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
The PID if the agent is loaded and running, None otherwise.
|
|
406
|
+
"""
|
|
407
|
+
try:
|
|
408
|
+
result = subprocess.run(
|
|
409
|
+
["launchctl", "list", LAUNCHD_LABEL],
|
|
410
|
+
capture_output=True,
|
|
411
|
+
text=True,
|
|
412
|
+
timeout=10,
|
|
413
|
+
)
|
|
414
|
+
if result.returncode != 0:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
# Parse the output — launchctl list <label> produces a
|
|
418
|
+
# key-value output. Look for the "PID" key.
|
|
419
|
+
for line in result.stdout.splitlines():
|
|
420
|
+
line = line.strip()
|
|
421
|
+
if line.startswith('"PID"'):
|
|
422
|
+
# Format: "PID" = <number>;
|
|
423
|
+
parts = line.split("=")
|
|
424
|
+
if len(parts) >= 2:
|
|
425
|
+
pid_str = parts[1].strip().rstrip(";").strip()
|
|
426
|
+
try:
|
|
427
|
+
return int(pid_str)
|
|
428
|
+
except ValueError:
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# --------------------------------------------------------------------------- #
|
|
438
|
+
# High-level operations
|
|
439
|
+
# --------------------------------------------------------------------------- #
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def install_agent(mlx_stack_binary: str | None = None) -> tuple[Path, bool]:
|
|
443
|
+
"""Install the watchdog as a launchd agent.
|
|
444
|
+
|
|
445
|
+
Performs:
|
|
446
|
+
1. Platform check (macOS only)
|
|
447
|
+
2. Prerequisite check (init must have been run)
|
|
448
|
+
3. Generate plist
|
|
449
|
+
4. If already installed, bootout old agent
|
|
450
|
+
5. Write new plist (with 0o644 permissions)
|
|
451
|
+
6. Bootstrap new agent
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
mlx_stack_binary: Path to the mlx-stack binary (auto-resolved if None).
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
Tuple of (plist_path, was_reinstall).
|
|
458
|
+
|
|
459
|
+
Raises:
|
|
460
|
+
PlatformError: If not on macOS.
|
|
461
|
+
PrerequisiteError: If init has not been run.
|
|
462
|
+
LaunchdError: If any launchd operation fails.
|
|
463
|
+
"""
|
|
464
|
+
check_platform()
|
|
465
|
+
check_init_prerequisite()
|
|
466
|
+
|
|
467
|
+
# Ensure logs dir exists for stdout/stderr paths
|
|
468
|
+
logs_dir = get_logs_dir()
|
|
469
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
470
|
+
|
|
471
|
+
plist_data = generate_plist(mlx_stack_binary)
|
|
472
|
+
plist_path = get_plist_path()
|
|
473
|
+
|
|
474
|
+
# Check if already installed
|
|
475
|
+
was_reinstall = plist_path.exists()
|
|
476
|
+
if was_reinstall:
|
|
477
|
+
# Bootout old agent before writing new plist
|
|
478
|
+
try:
|
|
479
|
+
unload_agent(plist_path)
|
|
480
|
+
except LaunchdError:
|
|
481
|
+
pass # Best-effort unload of old agent
|
|
482
|
+
|
|
483
|
+
# Write new plist
|
|
484
|
+
write_plist(plist_data, plist_path)
|
|
485
|
+
|
|
486
|
+
# Bootstrap new agent
|
|
487
|
+
load_agent(plist_path)
|
|
488
|
+
|
|
489
|
+
return plist_path, was_reinstall
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def uninstall_agent() -> bool:
|
|
493
|
+
"""Uninstall the watchdog launchd agent.
|
|
494
|
+
|
|
495
|
+
Performs:
|
|
496
|
+
1. Platform check (macOS only)
|
|
497
|
+
2. Check if installed
|
|
498
|
+
3. Bootout the agent
|
|
499
|
+
4. Remove the plist file
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
True if uninstalled, False if not installed.
|
|
503
|
+
|
|
504
|
+
Raises:
|
|
505
|
+
PlatformError: If not on macOS.
|
|
506
|
+
LaunchdError: If launchctl bootout fails.
|
|
507
|
+
"""
|
|
508
|
+
check_platform()
|
|
509
|
+
|
|
510
|
+
plist_path = get_plist_path()
|
|
511
|
+
if not plist_path.exists():
|
|
512
|
+
return False
|
|
513
|
+
|
|
514
|
+
# Bootout the agent — only suppress "No such process" (already unloaded)
|
|
515
|
+
try:
|
|
516
|
+
unload_agent(plist_path)
|
|
517
|
+
except LaunchdError as exc:
|
|
518
|
+
# "No such process" means the agent wasn't loaded — safe to ignore.
|
|
519
|
+
# Any other launchctl error is fatal and should propagate.
|
|
520
|
+
err_msg = str(exc)
|
|
521
|
+
if "No such process" not in err_msg and "3:" not in err_msg:
|
|
522
|
+
raise
|
|
523
|
+
|
|
524
|
+
# Remove plist file
|
|
525
|
+
try:
|
|
526
|
+
plist_path.unlink()
|
|
527
|
+
except OSError as exc:
|
|
528
|
+
msg = f"Failed to remove plist file {plist_path}: {exc}"
|
|
529
|
+
raise LaunchdError(msg) from None
|
|
530
|
+
|
|
531
|
+
return True
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""LiteLLM configuration generation for mlx-stack.
|
|
2
|
+
|
|
3
|
+
Generates a valid LiteLLM proxy config YAML file from a stack definition.
|
|
4
|
+
Produces model_list entries with correct api_base, openai/ prefix, and
|
|
5
|
+
api_key. Includes router_settings and fallback chain between tiers.
|
|
6
|
+
Handles cloud fallback via OpenRouter when an API key is configured.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
# --------------------------------------------------------------------------- #
|
|
16
|
+
# Constants
|
|
17
|
+
# --------------------------------------------------------------------------- #
|
|
18
|
+
|
|
19
|
+
# Default router settings
|
|
20
|
+
DEFAULT_ROUTING_STRATEGY = "simple-shuffle"
|
|
21
|
+
DEFAULT_NUM_RETRIES = 2
|
|
22
|
+
DEFAULT_TIMEOUT = 120
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# --------------------------------------------------------------------------- #
|
|
26
|
+
# Exceptions
|
|
27
|
+
# --------------------------------------------------------------------------- #
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LiteLLMGenError(Exception):
|
|
31
|
+
"""Raised when LiteLLM config generation fails."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --------------------------------------------------------------------------- #
|
|
35
|
+
# LiteLLM config generation
|
|
36
|
+
# --------------------------------------------------------------------------- #
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_model_entry(
|
|
40
|
+
tier_name: str,
|
|
41
|
+
model_id: str,
|
|
42
|
+
port: int,
|
|
43
|
+
) -> dict[str, Any]:
|
|
44
|
+
"""Build a single model_list entry for LiteLLM.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
tier_name: Tier name (e.g., 'standard', 'fast').
|
|
48
|
+
model_id: The catalog model ID.
|
|
49
|
+
port: The port the vllm-mlx instance serves on.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
A dict suitable for inclusion in model_list.
|
|
53
|
+
"""
|
|
54
|
+
return {
|
|
55
|
+
"model_name": tier_name,
|
|
56
|
+
"litellm_params": {
|
|
57
|
+
"model": f"openai/{model_id}",
|
|
58
|
+
"api_base": f"http://localhost:{port}/v1",
|
|
59
|
+
"api_key": "dummy",
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_cloud_entry(tier_name: str, cloud_model: str) -> dict[str, Any]:
|
|
65
|
+
"""Build a cloud fallback model_list entry for LiteLLM.
|
|
66
|
+
|
|
67
|
+
Uses os.environ/OPENROUTER_API_KEY for the API key so LiteLLM
|
|
68
|
+
reads it from the environment at runtime.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
tier_name: Tier name for the cloud entry (e.g., 'premium').
|
|
72
|
+
cloud_model: The cloud model identifier (e.g., 'openrouter/openai/gpt-4o').
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A dict suitable for inclusion in model_list.
|
|
76
|
+
"""
|
|
77
|
+
return {
|
|
78
|
+
"model_name": tier_name,
|
|
79
|
+
"litellm_params": {
|
|
80
|
+
"model": cloud_model,
|
|
81
|
+
"api_key": "os.environ/OPENROUTER_API_KEY",
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _build_fallback_chain(
|
|
87
|
+
tier_names: list[str],
|
|
88
|
+
has_cloud: bool,
|
|
89
|
+
) -> list[dict[str, list[str]]]:
|
|
90
|
+
"""Build the fallback chain for LiteLLM routing.
|
|
91
|
+
|
|
92
|
+
Creates a chain where each tier falls back to the next one.
|
|
93
|
+
If cloud fallback is enabled, the last local tier falls back to premium.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
tier_names: Ordered list of local tier names.
|
|
97
|
+
has_cloud: Whether cloud fallback is enabled.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A list of fallback mappings.
|
|
101
|
+
"""
|
|
102
|
+
if not tier_names:
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
all_tiers = list(tier_names)
|
|
106
|
+
if has_cloud:
|
|
107
|
+
all_tiers.append("premium")
|
|
108
|
+
|
|
109
|
+
fallbacks: list[dict[str, list[str]]] = []
|
|
110
|
+
for i, tier in enumerate(all_tiers[:-1]):
|
|
111
|
+
fallbacks.append({tier: [all_tiers[i + 1]]})
|
|
112
|
+
|
|
113
|
+
return fallbacks
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def generate_litellm_config(
|
|
117
|
+
tiers: list[dict[str, Any]],
|
|
118
|
+
litellm_port: int = 4000,
|
|
119
|
+
openrouter_key: str = "",
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
"""Generate a complete LiteLLM proxy configuration.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
tiers: List of tier dicts, each with 'name', 'model', 'port' keys.
|
|
125
|
+
litellm_port: The port LiteLLM will serve on (used for validation only).
|
|
126
|
+
openrouter_key: OpenRouter API key. If non-empty, cloud fallback is added.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
A dict representing the full LiteLLM YAML config.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
LiteLLMGenError: If generation fails.
|
|
133
|
+
"""
|
|
134
|
+
if not tiers:
|
|
135
|
+
msg = "Cannot generate LiteLLM config with no tiers"
|
|
136
|
+
raise LiteLLMGenError(msg)
|
|
137
|
+
|
|
138
|
+
model_list: list[dict[str, Any]] = []
|
|
139
|
+
tier_names: list[str] = []
|
|
140
|
+
|
|
141
|
+
for tier in tiers:
|
|
142
|
+
name = tier["name"]
|
|
143
|
+
model_id = tier["model"]
|
|
144
|
+
port = tier["port"]
|
|
145
|
+
|
|
146
|
+
model_list.append(_build_model_entry(name, model_id, port))
|
|
147
|
+
tier_names.append(name)
|
|
148
|
+
|
|
149
|
+
# Cloud fallback
|
|
150
|
+
has_cloud = bool(openrouter_key)
|
|
151
|
+
if has_cloud:
|
|
152
|
+
model_list.append(
|
|
153
|
+
_build_cloud_entry("premium", "openrouter/openai/gpt-4o")
|
|
154
|
+
)
|
|
155
|
+
model_list.append(
|
|
156
|
+
_build_cloud_entry("premium", "openrouter/anthropic/claude-sonnet-4-20250514")
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Build fallback chain
|
|
160
|
+
fallbacks = _build_fallback_chain(tier_names, has_cloud)
|
|
161
|
+
|
|
162
|
+
config: dict[str, Any] = {
|
|
163
|
+
"model_list": model_list,
|
|
164
|
+
"router_settings": {
|
|
165
|
+
"routing_strategy": DEFAULT_ROUTING_STRATEGY,
|
|
166
|
+
"num_retries": DEFAULT_NUM_RETRIES,
|
|
167
|
+
"timeout": DEFAULT_TIMEOUT,
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if fallbacks:
|
|
172
|
+
config["general_settings"] = {
|
|
173
|
+
"fallbacks": fallbacks,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return config
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def render_litellm_yaml(config: dict[str, Any]) -> str:
|
|
180
|
+
"""Render a LiteLLM config dict as YAML string.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
config: The LiteLLM configuration dict.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
A YAML string suitable for writing to a file.
|
|
187
|
+
"""
|
|
188
|
+
return yaml.dump(config, default_flow_style=False, sort_keys=False)
|