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.
Files changed (61) hide show
  1. mlx_stack/__init__.py +5 -0
  2. mlx_stack/_version.py +24 -0
  3. mlx_stack/cli/__init__.py +5 -0
  4. mlx_stack/cli/bench.py +221 -0
  5. mlx_stack/cli/config.py +166 -0
  6. mlx_stack/cli/down.py +109 -0
  7. mlx_stack/cli/init.py +180 -0
  8. mlx_stack/cli/install.py +165 -0
  9. mlx_stack/cli/logs.py +234 -0
  10. mlx_stack/cli/main.py +187 -0
  11. mlx_stack/cli/models.py +304 -0
  12. mlx_stack/cli/profile.py +65 -0
  13. mlx_stack/cli/pull.py +134 -0
  14. mlx_stack/cli/recommend.py +397 -0
  15. mlx_stack/cli/status.py +111 -0
  16. mlx_stack/cli/up.py +163 -0
  17. mlx_stack/cli/watch.py +252 -0
  18. mlx_stack/core/__init__.py +1 -0
  19. mlx_stack/core/benchmark.py +1182 -0
  20. mlx_stack/core/catalog.py +560 -0
  21. mlx_stack/core/config.py +471 -0
  22. mlx_stack/core/deps.py +323 -0
  23. mlx_stack/core/hardware.py +304 -0
  24. mlx_stack/core/launchd.py +531 -0
  25. mlx_stack/core/litellm_gen.py +188 -0
  26. mlx_stack/core/log_rotation.py +231 -0
  27. mlx_stack/core/log_viewer.py +386 -0
  28. mlx_stack/core/models.py +639 -0
  29. mlx_stack/core/paths.py +79 -0
  30. mlx_stack/core/process.py +887 -0
  31. mlx_stack/core/pull.py +815 -0
  32. mlx_stack/core/scoring.py +611 -0
  33. mlx_stack/core/stack_down.py +317 -0
  34. mlx_stack/core/stack_init.py +524 -0
  35. mlx_stack/core/stack_status.py +229 -0
  36. mlx_stack/core/stack_up.py +856 -0
  37. mlx_stack/core/watchdog.py +744 -0
  38. mlx_stack/data/__init__.py +1 -0
  39. mlx_stack/data/catalog/__init__.py +1 -0
  40. mlx_stack/data/catalog/deepseek-r1-32b.yaml +46 -0
  41. mlx_stack/data/catalog/deepseek-r1-8b.yaml +45 -0
  42. mlx_stack/data/catalog/gemma3-12b.yaml +45 -0
  43. mlx_stack/data/catalog/gemma3-27b.yaml +45 -0
  44. mlx_stack/data/catalog/gemma3-4b.yaml +45 -0
  45. mlx_stack/data/catalog/llama3.3-8b.yaml +44 -0
  46. mlx_stack/data/catalog/nemotron-49b.yaml +41 -0
  47. mlx_stack/data/catalog/nemotron-8b.yaml +44 -0
  48. mlx_stack/data/catalog/qwen3-8b.yaml +45 -0
  49. mlx_stack/data/catalog/qwen3.5-0.8b.yaml +45 -0
  50. mlx_stack/data/catalog/qwen3.5-14b.yaml +46 -0
  51. mlx_stack/data/catalog/qwen3.5-32b.yaml +45 -0
  52. mlx_stack/data/catalog/qwen3.5-3b.yaml +44 -0
  53. mlx_stack/data/catalog/qwen3.5-72b.yaml +42 -0
  54. mlx_stack/data/catalog/qwen3.5-8b.yaml +45 -0
  55. mlx_stack/py.typed +1 -0
  56. mlx_stack/utils/__init__.py +1 -0
  57. mlx_stack-0.1.0.dist-info/METADATA +397 -0
  58. mlx_stack-0.1.0.dist-info/RECORD +61 -0
  59. mlx_stack-0.1.0.dist-info/WHEEL +4 -0
  60. mlx_stack-0.1.0.dist-info/entry_points.txt +2 -0
  61. 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)