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,471 @@
1
+ """Configuration management module for mlx-stack.
2
+
3
+ Handles persistent configuration in ~/.mlx-stack/config.yaml with
4
+ type validation, default values, and masking of sensitive data.
5
+
6
+ Supports 8 configuration keys:
7
+ - openrouter-key: OpenRouter API key (masked in display)
8
+ - default-quant: Default quantization level (int4, int8, bf16)
9
+ - memory-budget-pct: Memory budget percentage (1-100)
10
+ - litellm-port: LiteLLM proxy port (1-65535)
11
+ - model-dir: Model storage directory path
12
+ - auto-health-check: Auto health check on startup (true/false)
13
+ - log-max-size-mb: Maximum log file size in MB before rotation (1+)
14
+ - log-max-files: Maximum number of rotated log archives to keep (1+)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from typing import Any
21
+
22
+ import yaml
23
+
24
+ from mlx_stack.core.paths import ensure_data_home, get_config_path, get_models_dir
25
+
26
+ # --------------------------------------------------------------------------- #
27
+ # Exceptions
28
+ # --------------------------------------------------------------------------- #
29
+
30
+
31
+ class ConfigError(Exception):
32
+ """Raised when configuration operations fail."""
33
+
34
+
35
+ class ConfigValidationError(ConfigError):
36
+ """Raised when a configuration value fails validation."""
37
+
38
+
39
+ class ConfigCorruptError(ConfigError):
40
+ """Raised when the config file is corrupt or unreadable."""
41
+
42
+
43
+ # --------------------------------------------------------------------------- #
44
+ # Config key definitions
45
+ # --------------------------------------------------------------------------- #
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ConfigKeyDef:
50
+ """Definition of a configuration key with its type and validation."""
51
+
52
+ name: str
53
+ description: str
54
+ default: Any
55
+ value_type: str # "string", "int", "bool", "path"
56
+ validator: str | None = None # name of validation function
57
+
58
+
59
+ # The 6 supported configuration keys with their defaults
60
+ CONFIG_KEYS: dict[str, ConfigKeyDef] = {
61
+ "openrouter-key": ConfigKeyDef(
62
+ name="openrouter-key",
63
+ description="OpenRouter API key for cloud fallback",
64
+ default="",
65
+ value_type="string",
66
+ ),
67
+ "default-quant": ConfigKeyDef(
68
+ name="default-quant",
69
+ description="Default quantization level",
70
+ default="int4",
71
+ value_type="string",
72
+ validator="quant",
73
+ ),
74
+ "memory-budget-pct": ConfigKeyDef(
75
+ name="memory-budget-pct",
76
+ description="Memory budget percentage (1-100)",
77
+ default=40,
78
+ value_type="int",
79
+ validator="memory_pct",
80
+ ),
81
+ "litellm-port": ConfigKeyDef(
82
+ name="litellm-port",
83
+ description="LiteLLM proxy port",
84
+ default=4000,
85
+ value_type="int",
86
+ validator="port",
87
+ ),
88
+ "model-dir": ConfigKeyDef(
89
+ name="model-dir",
90
+ description="Model storage directory",
91
+ default="~/.mlx-stack/models",
92
+ value_type="path",
93
+ ),
94
+ "auto-health-check": ConfigKeyDef(
95
+ name="auto-health-check",
96
+ description="Auto health check on startup",
97
+ default=True,
98
+ value_type="bool",
99
+ ),
100
+ "log-max-size-mb": ConfigKeyDef(
101
+ name="log-max-size-mb",
102
+ description="Maximum log file size in MB before rotation",
103
+ default=50,
104
+ value_type="int",
105
+ validator="positive_int",
106
+ ),
107
+ "log-max-files": ConfigKeyDef(
108
+ name="log-max-files",
109
+ description="Maximum number of rotated log archives to keep",
110
+ default=5,
111
+ value_type="int",
112
+ validator="positive_int",
113
+ ),
114
+ }
115
+
116
+ VALID_KEYS: list[str] = sorted(CONFIG_KEYS.keys())
117
+
118
+ # Valid quantization values
119
+ _VALID_QUANTS: set[str] = {"int4", "int8", "bf16"}
120
+
121
+
122
+ # --------------------------------------------------------------------------- #
123
+ # Validation
124
+ # --------------------------------------------------------------------------- #
125
+
126
+
127
+ def validate_key(key: str) -> ConfigKeyDef:
128
+ """Validate that a key is a known configuration key.
129
+
130
+ Args:
131
+ key: The configuration key name.
132
+
133
+ Returns:
134
+ The ConfigKeyDef for the key.
135
+
136
+ Raises:
137
+ ConfigError: If the key is not recognized.
138
+ """
139
+ if key not in CONFIG_KEYS:
140
+ valid_list = ", ".join(VALID_KEYS)
141
+ msg = f"Unknown config key '{key}'. Valid keys: {valid_list}"
142
+ raise ConfigError(msg)
143
+ return CONFIG_KEYS[key]
144
+
145
+
146
+ def _validate_quant(value: str) -> str:
147
+ """Validate a quantization value.
148
+
149
+ Raises:
150
+ ConfigValidationError: If the value is not a valid quantization.
151
+ """
152
+ if value not in _VALID_QUANTS:
153
+ valid = ", ".join(sorted(_VALID_QUANTS))
154
+ msg = f"Invalid quantization '{value}'. Valid values: {valid}"
155
+ raise ConfigValidationError(msg)
156
+ return value
157
+
158
+
159
+ def _validate_memory_pct(value: int) -> int:
160
+ """Validate a memory budget percentage.
161
+
162
+ Raises:
163
+ ConfigValidationError: If the value is not in 1-100 range.
164
+ """
165
+ if not (1 <= value <= 100):
166
+ msg = f"Invalid memory budget '{value}'. Must be between 1 and 100."
167
+ raise ConfigValidationError(msg)
168
+ return value
169
+
170
+
171
+ def _validate_port(value: int) -> int:
172
+ """Validate a TCP port number.
173
+
174
+ Raises:
175
+ ConfigValidationError: If the value is not in 1-65535 range.
176
+ """
177
+ if not (1 <= value <= 65535):
178
+ msg = f"Invalid port '{value}'. Must be between 1 and 65535."
179
+ raise ConfigValidationError(msg)
180
+ return value
181
+
182
+
183
+ def _validate_positive_int(key_name: str, value: int) -> int:
184
+ """Validate that a value is a positive integer (>= 1).
185
+
186
+ Raises:
187
+ ConfigValidationError: If the value is less than 1.
188
+ """
189
+ if value < 1:
190
+ msg = f"Invalid value '{value}' for '{key_name}'. Must be at least 1."
191
+ raise ConfigValidationError(msg)
192
+ return value
193
+
194
+
195
+ def parse_value(key_def: ConfigKeyDef, raw_value: str) -> Any:
196
+ """Parse a raw string value into the appropriate type for a config key.
197
+
198
+ Args:
199
+ key_def: The configuration key definition.
200
+ raw_value: The raw string value from CLI input.
201
+
202
+ Returns:
203
+ The parsed and validated value.
204
+
205
+ Raises:
206
+ ConfigValidationError: If the value fails type conversion or validation.
207
+ """
208
+ value: Any
209
+
210
+ if key_def.value_type == "int":
211
+ try:
212
+ value = int(raw_value)
213
+ except ValueError:
214
+ msg = f"Invalid value '{raw_value}' for '{key_def.name}'. Expected an integer."
215
+ raise ConfigValidationError(msg) from None
216
+
217
+ elif key_def.value_type == "bool":
218
+ lower = raw_value.lower()
219
+ if lower in ("true", "1", "yes", "on"):
220
+ value = True
221
+ elif lower in ("false", "0", "no", "off"):
222
+ value = False
223
+ else:
224
+ msg = (
225
+ f"Invalid value '{raw_value}' for '{key_def.name}'. "
226
+ f"Expected true/false, yes/no, 1/0, or on/off."
227
+ )
228
+ raise ConfigValidationError(msg)
229
+
230
+ elif key_def.value_type == "path":
231
+ value = raw_value
232
+
233
+ else:
234
+ # string type
235
+ value = raw_value
236
+
237
+ # Run specific validators
238
+ if key_def.validator == "quant":
239
+ _validate_quant(str(value))
240
+ elif key_def.validator == "memory_pct":
241
+ _validate_memory_pct(int(value))
242
+ elif key_def.validator == "port":
243
+ _validate_port(int(value))
244
+ elif key_def.validator == "positive_int":
245
+ _validate_positive_int(key_def.name, int(value))
246
+
247
+ return value
248
+
249
+
250
+ # --------------------------------------------------------------------------- #
251
+ # Masking
252
+ # --------------------------------------------------------------------------- #
253
+
254
+
255
+ def mask_value(key: str, value: Any) -> str:
256
+ """Mask sensitive values for display.
257
+
258
+ The openrouter-key is masked to show only the first 3 and last 3
259
+ characters. All other values are returned as-is.
260
+
261
+ Args:
262
+ key: The configuration key name.
263
+ value: The value to potentially mask.
264
+
265
+ Returns:
266
+ The masked or unmasked string representation.
267
+ """
268
+ str_value = str(value)
269
+ if key == "openrouter-key" and str_value and str_value != "":
270
+ if len(str_value) <= 6:
271
+ return "****"
272
+ return f"{str_value[:3]}****{str_value[-3:]}"
273
+ return str_value
274
+
275
+
276
+ # --------------------------------------------------------------------------- #
277
+ # File I/O
278
+ # --------------------------------------------------------------------------- #
279
+
280
+
281
+ def _load_raw_config() -> dict[str, Any]:
282
+ """Load the raw config dictionary from disk.
283
+
284
+ Returns:
285
+ A dictionary of config key-value pairs. Empty dict if file
286
+ is missing or empty.
287
+
288
+ Raises:
289
+ ConfigCorruptError: If the config file contains invalid YAML.
290
+ """
291
+ config_path = get_config_path()
292
+
293
+ if not config_path.exists():
294
+ return {}
295
+
296
+ try:
297
+ content = config_path.read_text(encoding="utf-8")
298
+ except OSError as exc:
299
+ msg = f"Could not read config file: {exc}"
300
+ raise ConfigCorruptError(msg) from None
301
+
302
+ if not content.strip():
303
+ return {}
304
+
305
+ try:
306
+ data = yaml.safe_load(content)
307
+ except yaml.YAMLError as exc:
308
+ msg = (
309
+ f"Config file is corrupt ({config_path}): {exc}\n"
310
+ f"Run 'mlx-stack config reset --yes' to restore defaults."
311
+ )
312
+ raise ConfigCorruptError(msg) from None
313
+
314
+ if data is None:
315
+ return {}
316
+
317
+ if not isinstance(data, dict):
318
+ msg = (
319
+ f"Config file has invalid format ({config_path}): "
320
+ f"expected a mapping, got {type(data).__name__}.\n"
321
+ f"Run 'mlx-stack config reset --yes' to restore defaults."
322
+ )
323
+ raise ConfigCorruptError(msg) from None
324
+
325
+ return data
326
+
327
+
328
+ def _save_raw_config(data: dict[str, Any]) -> None:
329
+ """Save the raw config dictionary to disk.
330
+
331
+ Creates the data directory if it doesn't exist.
332
+
333
+ Args:
334
+ data: The config dictionary to write.
335
+
336
+ Raises:
337
+ ConfigError: If the file cannot be written.
338
+ """
339
+ ensure_data_home()
340
+ config_path = get_config_path()
341
+
342
+ try:
343
+ content = yaml.dump(data, default_flow_style=False, sort_keys=True)
344
+ config_path.write_text(content, encoding="utf-8")
345
+ except OSError as exc:
346
+ msg = f"Could not write config file: {exc}"
347
+ raise ConfigError(msg) from None
348
+
349
+
350
+ # --------------------------------------------------------------------------- #
351
+ # Public API
352
+ # --------------------------------------------------------------------------- #
353
+
354
+
355
+ def get_default_value(key: str) -> Any:
356
+ """Return the default value for a config key.
357
+
358
+ For model-dir, expands ~ to the actual models directory path.
359
+
360
+ Args:
361
+ key: The configuration key name.
362
+
363
+ Returns:
364
+ The default value.
365
+
366
+ Raises:
367
+ ConfigError: If the key is not recognized.
368
+ """
369
+ key_def = validate_key(key)
370
+ if key == "model-dir":
371
+ return str(get_models_dir())
372
+ return key_def.default
373
+
374
+
375
+ def get_value(key: str) -> Any:
376
+ """Get the current value of a config key.
377
+
378
+ Returns the user-set value if present, otherwise the default.
379
+
380
+ Args:
381
+ key: The configuration key name.
382
+
383
+ Returns:
384
+ The current value (user-set or default).
385
+
386
+ Raises:
387
+ ConfigError: If the key is not recognized.
388
+ ConfigCorruptError: If the config file is corrupt.
389
+ """
390
+ validate_key(key)
391
+ data = _load_raw_config()
392
+
393
+ if key in data:
394
+ return data[key]
395
+
396
+ return get_default_value(key)
397
+
398
+
399
+ def set_value(key: str, raw_value: str) -> Any:
400
+ """Set a config key to a new value.
401
+
402
+ Validates the key and value, then persists to disk.
403
+
404
+ Args:
405
+ key: The configuration key name.
406
+ raw_value: The raw string value from CLI input.
407
+
408
+ Returns:
409
+ The parsed and validated value that was stored.
410
+
411
+ Raises:
412
+ ConfigError: If the key is not recognized.
413
+ ConfigValidationError: If the value fails validation.
414
+ ConfigCorruptError: If the config file is corrupt.
415
+ """
416
+ key_def = validate_key(key)
417
+ value = parse_value(key_def, raw_value)
418
+
419
+ data = _load_raw_config()
420
+ data[key] = value
421
+ _save_raw_config(data)
422
+
423
+ return value
424
+
425
+
426
+ def get_all_config() -> list[dict[str, Any]]:
427
+ """Get all configuration keys with their current values and metadata.
428
+
429
+ Returns:
430
+ A list of dicts, each with keys: name, value, default, is_default,
431
+ description, masked_value.
432
+
433
+ Raises:
434
+ ConfigCorruptError: If the config file is corrupt.
435
+ """
436
+ data = _load_raw_config()
437
+ result: list[dict[str, Any]] = []
438
+
439
+ for key, key_def in CONFIG_KEYS.items():
440
+ default = get_default_value(key)
441
+ is_default = key not in data
442
+ value = data.get(key, default)
443
+
444
+ result.append({
445
+ "name": key,
446
+ "value": value,
447
+ "default": default,
448
+ "is_default": is_default,
449
+ "description": key_def.description,
450
+ "masked_value": mask_value(key, value),
451
+ })
452
+
453
+ return result
454
+
455
+
456
+ def reset_config() -> None:
457
+ """Reset all user-set config values by removing the config file.
458
+
459
+ After reset, all keys return their default values.
460
+
461
+ Raises:
462
+ ConfigError: If the config file cannot be removed.
463
+ """
464
+ config_path = get_config_path()
465
+
466
+ if config_path.exists():
467
+ try:
468
+ config_path.unlink()
469
+ except OSError as exc:
470
+ msg = f"Could not remove config file: {exc}"
471
+ raise ConfigError(msg) from None