e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
365 lines
14 KiB
Python
365 lines
14 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Best-effort MLX self-heal for Apple Silicon.
|
|
|
|
On macOS, Studio enables Train/Export only when the MLX training/export stack is
|
|
usable (see utils.hardware.hardware.detect_hardware -> CHAT_ONLY). MLX is pulled
|
|
only transitively via unsloth-zoo, and a resolver backtrack (mlx-vlm ->
|
|
transformers>=5 vs the single-env transformers pin) can silently drop it, leaving
|
|
Train/Export greyed out after a reinstall/update. This reinstalls mlx by name on
|
|
a background thread, then re-detects so the gate re-opens without a manual
|
|
`unsloth studio update`.
|
|
|
|
The install mirrors the main Apple Silicon installer (install_python_stack.py):
|
|
it points UV_OVERRIDE at overrides-darwin-arm64.txt so the resolver keeps the
|
|
Studio transformers pin AND installs a current mlx-vlm, and it requires the same
|
|
minimum versions unsloth-zoo declares so a backtracked old mlx-vlm (which still
|
|
imports but breaks VLM Train/Export) is never accepted as healthy.
|
|
|
|
Mirrors the runtime backend self-heal already used for tilelang
|
|
(core.training.worker._ensure_tilelang_backend_unconditional): default-on,
|
|
best-effort, opt out with UNSLOTH_DISABLE_MLX_AUTOREPAIR=1.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
import structlog
|
|
|
|
from utils.uv_path_safety import uv_safe_path
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
DISABLE_ENV_VAR = "UNSLOTH_DISABLE_MLX_AUTOREPAIR"
|
|
# Minimum versions unsloth-zoo requires on Apple Silicon (its pyproject darwin
|
|
# deps). mlx-vlm especially must be >=0.4.4: an older one still imports but
|
|
# breaks VLM Train/Export, so installing it would wrongly clear chat-only.
|
|
_MLX_MIN_VERSIONS = {"mlx": "0.22.0", "mlx-lm": "0.22.0", "mlx-vlm": "0.4.4"}
|
|
# mlx-lm 0.31.3 regressed QK-norm archs (gemma4 / qwen3_5): strict load_weights
|
|
# rejects q_norm/k_norm, so a self-heal must not pull it. mlx-lm #1242.
|
|
_MLX_BAD_VERSIONS = {"mlx-lm": ("0.31.3",)}
|
|
_MLX_PACKAGE_NAMES = tuple(_MLX_MIN_VERSIONS)
|
|
_MLX_RUNTIME_IMPORTS = ("mlx.core", "mlx_lm", "mlx_lm.sample_utils", "mlx_vlm")
|
|
|
|
|
|
def _mlx_spec(name: str, version: str) -> str:
|
|
spec = f"{name}>={version}"
|
|
for bad in _MLX_BAD_VERSIONS.get(name, ()):
|
|
spec += f",!={bad}"
|
|
return spec
|
|
|
|
|
|
MLX_PACKAGES = tuple(_mlx_spec(name, version) for name, version in _MLX_MIN_VERSIONS.items())
|
|
_MLX_REINSTALL_ARGS = tuple(
|
|
arg for name in _MLX_PACKAGE_NAMES for arg in ("--reinstall-package", name)
|
|
)
|
|
# Require pre-built wheels for the unattended self-heal. A source distribution's
|
|
# PEP 517 build backend runs arbitrary code at install time, and this install is
|
|
# default-on, resolver-driven, and runs before the post-install stack check can
|
|
# reject anything. mlx/mlx-metal ship wheels only (no sdist on PyPI) and
|
|
# mlx-lm/mlx-vlm publish py3-none-any wheels, so requiring wheels does not break a
|
|
# healthy self-heal; if a wheel is genuinely unavailable the install fails and
|
|
# Studio stays chat-only (the existing safe fallback) until `unsloth studio update`.
|
|
_ONLY_BINARY_ARG = "--only-binary=:all:"
|
|
# Allowlist of environment variables forwarded to the install subprocess. The
|
|
# self-heal runs without confirmation on the default startup path, so it must not
|
|
# hand resolver/build code the full Studio environment. Everything outside this
|
|
# set is dropped, which excludes three dangerous classes by construction:
|
|
# * secrets (HF_TOKEN, AWS_*, WANDB_API_KEY, ...) that a malicious wheel/sdist
|
|
# build hook would otherwise read straight out of os.environ;
|
|
# * package-source redirects (UV_INDEX*, UV_DEFAULT_INDEX, UV_FIND_LINKS,
|
|
# PIP_INDEX_URL, ...) so a poisoned process env cannot silently repoint the
|
|
# install at an attacker-controlled index/find-links;
|
|
# * cache-dir redirects (UV_CACHE_DIR, XDG_CACHE_HOME) so a poisoned env cannot
|
|
# point uv at an attacker-staged cache (cache poisoning / symlink writes). uv
|
|
# falls back to its safe user-owned default cache, reused across runs anyway.
|
|
# uv still honours on-disk config (uv.toml / pip.conf), so a corporate mirror
|
|
# configured there keeps working; only process-env redirects are dropped. We set
|
|
# UV_OVERRIDE ourselves in _mlx_install_env, so a poisoned one here is ignored.
|
|
_MLX_ENV_ALLOWLIST = frozenset(
|
|
{
|
|
"PATH",
|
|
"HOME",
|
|
"USER",
|
|
"LOGNAME",
|
|
"TMPDIR",
|
|
"TMP",
|
|
"TEMP",
|
|
"LANG",
|
|
"LC_ALL",
|
|
"LC_CTYPE",
|
|
# proxies + custom CA bundles so installs behind a corporate gateway work
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"NO_PROXY",
|
|
"ALL_PROXY",
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"no_proxy",
|
|
"all_proxy",
|
|
"SSL_CERT_FILE",
|
|
"SSL_CERT_DIR",
|
|
"REQUESTS_CA_BUNDLE",
|
|
"CURL_CA_BUNDLE",
|
|
}
|
|
)
|
|
_REPAIR_TIMEOUT_S = 900
|
|
|
|
# Attempt at most once per process; success is sticky (mlx then imports and the
|
|
# guard short-circuits on the next boot).
|
|
_attempted = False
|
|
_attempted_lock = threading.Lock()
|
|
|
|
|
|
def is_apple_silicon() -> bool:
|
|
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
|
|
|
|
|
def mlx_available() -> bool:
|
|
try:
|
|
import mlx.core # noqa: F401
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _mlx_runtime_imports_available() -> bool:
|
|
for module in _MLX_RUNTIME_IMPORTS:
|
|
try:
|
|
importlib.import_module(module)
|
|
except Exception:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _mlx_versions_satisfy_minimums() -> bool:
|
|
try:
|
|
from importlib.metadata import PackageNotFoundError
|
|
from importlib.metadata import version as _dist_version
|
|
|
|
from packaging.version import Version
|
|
except Exception:
|
|
return False
|
|
for name, minimum in _MLX_MIN_VERSIONS.items():
|
|
try:
|
|
installed = Version(_dist_version(name))
|
|
if installed < Version(minimum):
|
|
return False
|
|
# A known-broken build counts as unsatisfied so the self-heal
|
|
# reinstalls a good one; Version compare matches 0.31.3(.0/+local).
|
|
if any(installed == Version(bad) for bad in _MLX_BAD_VERSIONS.get(name, ())):
|
|
return False
|
|
except PackageNotFoundError:
|
|
return False
|
|
except Exception:
|
|
return False
|
|
return True
|
|
|
|
|
|
def mlx_stack_available() -> bool:
|
|
"""`import mlx.core` works AND mlx/mlx-lm/mlx-vlm meet unsloth-zoo's minimums.
|
|
|
|
Check distribution versions before imports so a too-old but importable MLX
|
|
module is not loaded into this process before repair can replace it."""
|
|
if not _mlx_versions_satisfy_minimums():
|
|
return False
|
|
return _mlx_runtime_imports_available()
|
|
|
|
|
|
def _uv_executable() -> str | None:
|
|
"""Find uv even when macOS GUI launchers start with a minimal PATH."""
|
|
found = shutil.which("uv")
|
|
if found:
|
|
return found
|
|
for candidate in (
|
|
Path.home() / ".local" / "bin" / "uv",
|
|
Path.home() / ".cargo" / "bin" / "uv",
|
|
Path("/opt/homebrew/bin/uv"),
|
|
Path("/usr/local/bin/uv"),
|
|
):
|
|
try:
|
|
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
return str(candidate)
|
|
except OSError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _uv_install_cmd(*args: str) -> list[str] | None:
|
|
uv = _uv_executable()
|
|
if not uv:
|
|
return None
|
|
return [uv, "pip", "install", "--python", sys.executable, *args]
|
|
|
|
|
|
def _mlx_install_env() -> dict[str, str]:
|
|
"""Minimal, allowlisted environment for the unattended mlx install.
|
|
|
|
The self-heal runs without confirmation on the default startup path, so it
|
|
forwards only the variables uv genuinely needs (see _MLX_ENV_ALLOWLIST) instead
|
|
of the full Studio environment: secrets and package-source redirects in
|
|
os.environ are dropped so a malicious resolver-selected artifact cannot read
|
|
Studio secrets or be steered to a hostile index.
|
|
|
|
Mirror the main installer (install_python_stack.py) by pointing UV_OVERRIDE at
|
|
overrides-darwin-arm64.txt, which relaxes mlx-vlm/mlx-lm's transformers>=5
|
|
requirement to >=4.57.6. Without it, uv keeps the Studio transformers pin only
|
|
by silently backtracking mlx-vlm to an old, unsupported version (uv honours
|
|
UV_OVERRIDE; plain pip ignores it, so the transformers constraint below is the
|
|
pip-path safety net). We set UV_OVERRIDE ourselves, so a poisoned one in the
|
|
process env is ignored."""
|
|
env = {key: os.environ[key] for key in _MLX_ENV_ALLOWLIST if key in os.environ}
|
|
override = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "requirements"
|
|
/ "single-env"
|
|
/ "overrides-darwin-arm64.txt"
|
|
)
|
|
if override.is_file():
|
|
# uv truncates UV_OVERRIDE at the first space (issue #6503).
|
|
env.setdefault("UV_OVERRIDE", uv_safe_path(override))
|
|
return env
|
|
|
|
|
|
def _transformers_constraint_args() -> tuple[list[str], str | None]:
|
|
"""Pin transformers to the running version for the mlx install.
|
|
|
|
The install must never upgrade transformers underneath a running Studio
|
|
(the single-env install pins transformers==4.57.6). With UV_OVERRIDE set this
|
|
is belt-and-suspenders; on the plain-pip path (no UV_OVERRIDE support) it is
|
|
the actual guard -- the resolver either finds an mlx build compatible with the
|
|
pin or fails, leaving us chat-only rather than breaking Studio. Returns
|
|
(pip args, temp file path to clean up).
|
|
|
|
Read the version from installed metadata rather than `import transformers`:
|
|
transformers can have valid metadata yet fail to import (e.g. an incompatible
|
|
huggingface_hub), and in that case we still want to pin it so the mlx install
|
|
cannot quietly upgrade it out from under Studio."""
|
|
from importlib.metadata import PackageNotFoundError, version as _dist_version
|
|
|
|
try:
|
|
transformers_version = _dist_version("transformers")
|
|
except PackageNotFoundError:
|
|
return [], None
|
|
except Exception:
|
|
return [], None
|
|
fd, path = tempfile.mkstemp(prefix = "mlx_repair_", suffix = ".txt")
|
|
with os.fdopen(fd, "w") as fh:
|
|
fh.write(f"transformers=={transformers_version}\n")
|
|
return ["--constraint", path], path
|
|
|
|
|
|
def attempt_mlx_repair(*, timeout: int = _REPAIR_TIMEOUT_S) -> bool:
|
|
"""Install a usable mlx/mlx-lm/mlx-vlm stack by name into the running venv.
|
|
Best-effort; returns True iff the resulting stack meets unsloth-zoo's minimums
|
|
(so a backtracked old mlx-vlm is rejected, not accepted). transformers is held
|
|
at its pinned version so the install can never upgrade it underneath Studio."""
|
|
# Prepare the constraint inside the try: this runs on a daemon thread, so an
|
|
# exception here (e.g. tempfile.mkstemp failing on a full disk or bad TMPDIR)
|
|
# must leave Studio chat-only, not crash the background self-heal thread.
|
|
constraint_path = None
|
|
try:
|
|
constraint_args, constraint_path = _transformers_constraint_args()
|
|
cmd = _uv_install_cmd(
|
|
"--upgrade",
|
|
_ONLY_BINARY_ARG,
|
|
*_MLX_REINSTALL_ARGS,
|
|
*constraint_args,
|
|
*MLX_PACKAGES,
|
|
)
|
|
if cmd is None:
|
|
logger.warning(
|
|
"MLX self-heal requires uv so Studio can apply dependency overrides; "
|
|
"staying chat-only. Run `unsloth studio update` to restore uv."
|
|
)
|
|
return False
|
|
logger.info("MLX self-heal: installing %s", ", ".join(MLX_PACKAGES))
|
|
result = subprocess.run(
|
|
cmd,
|
|
env = _mlx_install_env(),
|
|
stdout = subprocess.PIPE,
|
|
stderr = subprocess.STDOUT,
|
|
text = True,
|
|
timeout = timeout,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
logger.warning("MLX self-heal timed out after %ss; staying chat-only", timeout)
|
|
return False
|
|
except Exception as exc: # pragma: no cover - environment dependent
|
|
logger.warning("MLX self-heal could not start: %s", exc)
|
|
return False
|
|
finally:
|
|
if constraint_path and os.path.exists(constraint_path):
|
|
try:
|
|
os.remove(constraint_path)
|
|
except OSError:
|
|
pass
|
|
if result.returncode != 0:
|
|
tail = (result.stdout or "")[-2000:]
|
|
logger.warning("MLX self-heal failed (staying chat-only):\n%s", tail)
|
|
return False
|
|
importlib.invalidate_caches()
|
|
if not mlx_stack_available():
|
|
logger.warning(
|
|
"MLX self-heal produced an incomplete or too-old MLX stack "
|
|
"(need %s); staying chat-only.",
|
|
", ".join(f"{name}>={ver}" for name, ver in _MLX_MIN_VERSIONS.items()),
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def _run_repair_and_redetect() -> None:
|
|
if not attempt_mlx_repair():
|
|
return
|
|
try:
|
|
from utils.hardware import hardware as hw
|
|
hw.detect_hardware() # flips CHAT_ONLY / DEVICE now that mlx imports
|
|
logger.info(
|
|
"MLX self-heal succeeded; Train/Export enabled (reload the page). chat_only=%s",
|
|
hw.CHAT_ONLY,
|
|
)
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
logger.warning("MLX installed but hardware re-detection failed: %s", exc)
|
|
|
|
|
|
def start_mlx_autorepair_if_needed() -> bool:
|
|
"""If this is an Apple Silicon host whose MLX stack is missing or too old,
|
|
reinstall it on a daemon thread (off the startup critical path) and re-detect
|
|
on success. Returns True iff a repair thread was started. No-op (returns False)
|
|
off Apple Silicon, when the stack is already adequate, when already attempted
|
|
this process, or when disabled via UNSLOTH_DISABLE_MLX_AUTOREPAIR=1."""
|
|
global _attempted
|
|
if os.environ.get(DISABLE_ENV_VAR) == "1":
|
|
return False
|
|
if not is_apple_silicon():
|
|
return False
|
|
if mlx_stack_available():
|
|
return False
|
|
with _attempted_lock:
|
|
if _attempted:
|
|
return False
|
|
_attempted = True
|
|
logger.warning(
|
|
"Apple Silicon without a usable MLX stack; attempting a one-time background "
|
|
"reinstall of mlx/mlx-lm/mlx-vlm to re-enable Train/Export. "
|
|
"Set %s=1 to disable.",
|
|
DISABLE_ENV_VAR,
|
|
)
|
|
threading.Thread(
|
|
target = _run_repair_and_redetect,
|
|
daemon = True,
|
|
name = "mlx-autorepair",
|
|
).start()
|
|
return True
|