diff --git a/install.sh b/install.sh index a558a8dafc..b901747709 100755 --- a/install.sh +++ b/install.sh @@ -1854,14 +1854,8 @@ if [ "$_NO_TORCH_FLAG" = true ] || [ "$MAC_INTEL" = true ]; then SKIP_TORCH=true fi -# Apple Silicon: exclude broken mlx-lm 0.31.3 (QK-norm load regression for -# gemma4 / qwen3_5; mlx-lm #1242). A curl-piped install has no overrides file -# and skips the guarded MLX step (SKIP_STUDIO_BASE=1), so this is the only cover. -_MLX_LM_EXCLUDE_ARG="" - # Apple Silicon: override mlx-vlm / mlx-lm's transformers pin (see overrides file). if [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then - _MLX_LM_EXCLUDE_ARG="mlx-lm!=0.31.3" _OVERRIDES_FILE="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)/studio/backend/requirements/single-env/overrides-darwin-arm64.txt" if [ -f "$_OVERRIDES_FILE" ]; then # uv splits UV_OVERRIDE on whitespace, so a repo path with whitespace @@ -4119,13 +4113,11 @@ if [ "$_MIGRATED" = true ]; then run_install_cmd_retry "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" fi else - # Pin mlx-lm away from 0.31.3 here too: a curl-piped migration has no - # overrides file, so UV_OVERRIDE is unset and this positional is the only cover. _build_unsloth_torch_overrides run_install_cmd_retry "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \ ${_UNSLOTH_TORCH_OVERRIDES:+--overrides "$_UNSLOTH_TORCH_OVERRIDES"} \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.8.7" "unsloth-zoo>=2026.8.5" ${_MLX_LM_EXCLUDE_ARG:-} + "unsloth>=2026.8.7" "unsloth-zoo>=2026.8.5" [ -n "$_UNSLOTH_TORCH_OVERRIDES" ] && rm -f "$_UNSLOTH_TORCH_OVERRIDES" _UNSLOTH_TORCH_OVERRIDES="" fi @@ -4388,7 +4380,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then else run_install_cmd_retry "install unsloth" uv pip install --python "$_VENV_PY" \ ${_UNSLOTH_TORCH_OVERRIDES:+--overrides "$_UNSLOTH_TORCH_OVERRIDES"} \ - --upgrade-package unsloth -- "$PACKAGE_NAME" ${_MLX_LM_EXCLUDE_ARG:-} + --upgrade-package unsloth -- "$PACKAGE_NAME" fi [ -n "$_UNSLOTH_TORCH_OVERRIDES" ] && rm -f "$_UNSLOTH_TORCH_OVERRIDES" _UNSLOTH_TORCH_OVERRIDES="" diff --git a/scripts/install_qwen3_6_mlx.sh b/scripts/install_qwen3_6_mlx.sh index ef0a3ee9e3..64d982b705 100644 --- a/scripts/install_qwen3_6_mlx.sh +++ b/scripts/install_qwen3_6_mlx.sh @@ -7,12 +7,8 @@ set -euo pipefail # Qwen3.6 MLX — One-command setup + inference # # Supply-chain hardening: -# - All third-party downloads (uv installer, mlx_vlm qwen3_5 -# patches) are pinned to an immutable git commit SHA and verified -# against a hardcoded SHA-256. Any mismatch aborts the install -# before the bytes are copied into site-packages. -# - To rotate any pin, fetch the new file with `curl`, run -# `shasum -a 256`, and update the corresponding constant below. +# - The uv installer is verified against a hardcoded SHA-256 before +# execution. Rotate the digest only after verifying the new payload. # ============================================================ # # Usage: @@ -144,8 +140,23 @@ fi _VENV_PY="$VENV_DIR/bin/python" # ── Install dependencies ────────────────────────────────────── -step "install" "installing mlx-vlm..." -uv pip install --python "$_VENV_PY" -q mlx-vlm +step "install" "installing current mlx-vlm..." +# Reinstall even when the existing version satisfies the request. Older copies of +# this installer modified mlx-vlm in place, so version metadata alone is not proof +# that a reused environment still contains the resolver-selected distribution. +uv pip install --python "$_VENV_PY" -q \ + --upgrade-package mlx-vlm --reinstall-package mlx-vlm mlx-vlm +# The old installer also added a legacy module that current mlx-vlm wheels do +# not own. Remove it only when it is absent from the selected distribution. +"$_VENV_PY" - <<'PY' +from importlib.metadata import distribution + +dist = distribution("mlx-vlm") +legacy_module = dist.locate_file("mlx_vlm/generate.py") +owned_files = {str(path) for path in (dist.files or ())} +if legacy_module.is_file() and "mlx_vlm/generate.py" not in owned_files: + legacy_module.unlink() +PY substep "done" step "install" "installing transformers>=5.2.0..." @@ -171,62 +182,29 @@ else fail "Installation verification failed. Please ensure Python >=3.10 and try again." fi -# ── Apply patches for multi-turn image chat ────────────────── -# -# Pin every patch to an immutable commit SHA and verify the body -# against a hardcoded SHA-256. The mlx_vlm_qwen3_5 patch tree -# currently only exists on the upstream `fix/ui-fix` branch; we pin -# to the branch HEAD commit, NOT the floating ref, so a forced push -# on `fix/ui-fix` cannot swap the bytes under us. -# -# Rotate by: -# _PATCH_COMMIT= -# curl -sSLf "https://raw.githubusercontent.com/unslothai/unsloth/$_PATCH_COMMIT/unsloth/models/patches/mlx_vlm_qwen3_5/qwen3_5.py" | shasum -a 256 -# curl -sSLf "https://raw.githubusercontent.com/unslothai/unsloth/$_PATCH_COMMIT/unsloth/models/patches/mlx_vlm_qwen3_5/generate.py" | shasum -a 256 -_PATCH_COMMIT="013c99e51bbb8c4b83d88f3b150a1e53251a19d2" -_PATCH_BASE="https://raw.githubusercontent.com/unslothai/unsloth/${_PATCH_COMMIT}/unsloth/models/patches/mlx_vlm_qwen3_5" -_PATCH_SHA_QWEN35="4b6fbbcc59b1d6b935e7204351aae1476836d25542a11c7885402b672d2efa64" -_PATCH_SHA_GENERATE="50c4cbb8c3d94c0c74a4d209db6d2b23b102944c147c6421f2eded427b8edaf7" +# Verify the active Qwen runtime rather than overwriting the package selected +# by the resolver with an older copy of its internal modules. +if "$_VENV_PY" - <<'PY' +from mlx_vlm.generate import stream_generate +from mlx_vlm.models.base import InputEmbeddingsFeatures +from mlx_vlm.models.qwen3_5.config import ModelConfig +from mlx_vlm.models.qwen3_5.qwen3_5 import Model, ModelConfig as RuntimeModelConfig, sanitize_key -_SITE_PKGS=$("$_VENV_PY" -c "import site; print(site.getsitepackages()[0])") - -step "patch" "fixing multi-turn image chat..." - -# Stage all downloads in an isolated tmpdir; we only copy into -# site-packages after every checksum has matched. -_PATCH_TMP=$(mktemp -d) -trap 'rm -rf "$_PATCH_TMP"' EXIT - -apply_pinned_patch() { - # apply_pinned_patch - _name="$1"; _expected="$2"; _dest="$3" - _staged="$_PATCH_TMP/$_name" - if ! curl -sSLf "${_PATCH_BASE}/${_name}" -o "$_staged"; then - step "warning" "failed to download ${_name} patch — multi-turn image chat may not work" "$C_WARN" - return 1 - fi - _actual=$(shasum -a 256 "$_staged" | awk '{print $1}') - if [ "$_actual" != "$_expected" ]; then - step "warning" "${_name} SHA-256 mismatch (got $_actual expected $_expected) — refusing to install patch" "$C_WARN" - return 1 - fi - mkdir -p "$(dirname "$_dest")" - cp "$_staged" "$_dest" - return 0 -} - -if apply_pinned_patch "qwen3_5.py" "$_PATCH_SHA_QWEN35" "${_SITE_PKGS}/mlx_vlm/models/qwen3_5/qwen3_5.py"; then - substep "patched qwen3_5.py (MRoPE position reset)" +required_fields = {"position_ids", "rope_deltas"} +available_fields = set(getattr(InputEmbeddingsFeatures, "__dataclass_fields__", ())) +if ModelConfig is not RuntimeModelConfig: + raise RuntimeError("Qwen3.5 model and config modules are inconsistent") +if not callable(Model) or not callable(sanitize_key) or not callable(stream_generate): + raise RuntimeError("Qwen3.5 model, sanitizer, or generation entry point is unavailable") +if not required_fields.issubset(available_fields): + raise RuntimeError("mlx-vlm lacks the Qwen3.5 request-owned position interface") +PY +then + substep "Qwen3.5 model + generation runtime verified" +else + fail "Installed mlx-vlm does not provide a coherent Qwen3.5/3.6 runtime. Please retry with a current mlx-vlm release." fi -if apply_pinned_patch "generate.py" "$_PATCH_SHA_GENERATE" "${_SITE_PKGS}/mlx_vlm/generate.py"; then - substep "patched generate.py (mask trim on cache reuse)" -fi - -# Clear pycache so patches take effect -find "${_SITE_PKGS}/mlx_vlm" -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true -substep "cleared bytecode cache" - # ── Done ────────────────────────────────────────────────────── echo "" printf " ${C_TITLE}%s${C_RST}\n" "Qwen3.6 MLX installed!" diff --git a/studio/backend/requirements/single-env/overrides-darwin-arm64.txt b/studio/backend/requirements/single-env/overrides-darwin-arm64.txt index 43f37b3183..bd2bdaee7e 100644 --- a/studio/backend/requirements/single-env/overrides-darwin-arm64.txt +++ b/studio/backend/requirements/single-env/overrides-darwin-arm64.txt @@ -11,8 +11,9 @@ transformers>=4.57.6 # wins the fight, so force one consistent <4.14 here too. anyio<4.14.0 -# mlx-lm 0.31.3 regressed QK-norm archs (gemma4 / qwen3_5): strict load_weights -# rejects q_norm/k_norm, so those checkpoints fail to load. mlx-lm #1242. -# The override also drops it from transitive resolution; keep the >=0.22.0 floor -# (mirrors mlx_repair.py _MLX_MIN_VERSIONS) or the resolver could go below it. -mlx-lm>=0.22.0,!=0.31.3 +# mlx-audio (pulled by mlx-vlm>=0.5.0) declares huggingface_hub>=1.0, which +# conflicts with the transformers==4.57.6 stack (hub<1.0; constraints.txt pins +# hub==0.36.2). Without this override uv silently backtracks mlx-vlm to 0.4.4, +# dropping newer VLM/audio support. Keep <1.0 so the resolver can never pair +# hub 1.x with the pinned transformers. +huggingface_hub>=0.34.0,<1.0 diff --git a/studio/backend/tests/test_mlx_repair.py b/studio/backend/tests/test_mlx_repair.py index 47a695ccbd..81f54d317f 100644 --- a/studio/backend/tests/test_mlx_repair.py +++ b/studio/backend/tests/test_mlx_repair.py @@ -271,29 +271,6 @@ def test_stack_available_requires_runtime_imports_and_versions(monkeypatch): assert imported == list(mr._MLX_RUNTIME_IMPORTS) -def test_mlx_packages_exclude_known_bad_mlx_lm(): - # mlx-lm 0.31.3 regressed QK-norm archs (gemma4 / qwen3_5); the install spec - # must exclude it so the resolver picks 0.31.2 or >=0.31.4. See mlx-lm #1242. - (mlx_lm_spec,) = [p for p in mr.MLX_PACKAGES if p.startswith("mlx-lm")] - assert mlx_lm_spec == "mlx-lm>=0.22.0,!=0.31.3" - - -@pytest.mark.parametrize("bad_form", ["0.31.3", "0.31.3.0"]) -def test_known_bad_installed_mlx_lm_triggers_repair(monkeypatch, bad_form): - # An installed 0.31.3 counts as unsatisfied so the self-heal replaces it; - # parsed-Version compare also catches the trailing-zero form 0.31.3.0. - import importlib.metadata as metadata - - def _version(name): - return bad_form if name == "mlx-lm" else mr._MLX_MIN_VERSIONS[name] - - monkeypatch.setattr(metadata, "version", _version) - monkeypatch.setattr( - mr.importlib, "import_module", lambda _n: pytest.fail("versions must gate imports") - ) - assert mr.mlx_stack_available() is False - - def test_no_op_off_apple_silicon(monkeypatch): monkeypatch.setattr(mr, "is_apple_silicon", lambda: False) called = {"n": 0} diff --git a/studio/backend/utils/mlx_repair.py b/studio/backend/utils/mlx_repair.py index 33dd8bd7cc..0d2b3a2e92 100644 --- a/studio/backend/utils/mlx_repair.py +++ b/studio/backend/utils/mlx_repair.py @@ -46,21 +46,9 @@ DISABLE_ENV_VAR = "UNSLOTH_DISABLE_MLX_AUTOREPAIR" # 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_PACKAGES = tuple(f"{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) ) @@ -153,12 +141,7 @@ def _mlx_versions_satisfy_minimums() -> bool: 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, ())): + if Version(_dist_version(name)) < Version(minimum): return False except PackageNotFoundError: return False diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index 6818c86a79..3bc9f84520 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -3140,10 +3140,6 @@ LOCAL_DD_UNSTRUCTURED_PLUGIN = ( ) LOCAL_DD_GITHUB_PLUGIN = SCRIPT_DIR / "backend" / "plugins" / "data-designer-github-repo-seed" -# mlx-lm 0.31.3 broke gemma4 / qwen3_5 loading (strict load_weights rejects the -# QK-norm q_norm/k_norm tensors); exclude just that release. See mlx-lm #1242. -MLX_LM_BAD_VERSION_EXCLUSION = "!=0.31.3" - # Apple Silicon: override mlx-vlm/mlx-lm's transformers pin (see overrides). # _uv_safe_path: uv truncates UV_OVERRIDE at the first space too (issue #6503). _MLX_OVERRIDES = SINGLE_ENV / "overrides-darwin-arm64.txt" @@ -3805,8 +3801,6 @@ def install_python_stack() -> int: # macOS arm64: install MLX stack at latest (UV_OVERRIDE relaxes the # mlx-vlm / mlx-lm transformers pin -- set at module load). - # Exclude mlx-lm 0.31.3 (see MLX_LM_BAD_VERSION_EXCLUSION); it broke - # gemma4 / qwen3_5 QK-norm loading. mlx-lm #1242. if IS_MAC_ARM and not skip_base: _progress("MLX stack (Apple Silicon)") pip_install( @@ -3815,7 +3809,7 @@ def install_python_stack() -> int: "--upgrade", "mlx", "mlx-metal", - f"mlx-lm{MLX_LM_BAD_VERSION_EXCLUSION}", + "mlx-lm", "mlx-vlm", ) diff --git a/studio/setup.sh b/studio/setup.sh index c6c4ffcdf8..0d579b2abf 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1064,6 +1064,11 @@ fast_install() { python -m pip install "$@" } +fast_install_sidecar() ( + unset UV_OVERRIDE + fast_install "$@" +) + cd "$SCRIPT_DIR" # On Colab without a venv, skip venv-dependent Python deps sections but @@ -1272,30 +1277,30 @@ if [ "$_NEED_T5_INSTALL" = true ]; then [ -d "$VENV_T5_530_DIR" ] && rm -rf "$VENV_T5_530_DIR" mkdir -p "$VENV_T5_530_DIR" : > "$VENV_T5_530_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true - run_quiet "install transformers 5.3.0" fast_install --target "$VENV_T5_530_DIR" --no-deps "transformers==5.3.0" - run_quiet "install huggingface_hub for t5_530" fast_install --target "$VENV_T5_530_DIR" --no-deps "huggingface_hub==1.8.0" - run_quiet "install hf_xet for t5_530" fast_install --target "$VENV_T5_530_DIR" --no-deps "hf_xet==1.4.2" - run_quiet "install tiktoken for t5_530" fast_install --target "$VENV_T5_530_DIR" "tiktoken" + run_quiet "install transformers 5.3.0" fast_install_sidecar --target "$VENV_T5_530_DIR" --no-deps "transformers==5.3.0" + run_quiet "install huggingface_hub for t5_530" fast_install_sidecar --target "$VENV_T5_530_DIR" --no-deps "huggingface_hub==1.8.0" + run_quiet "install hf_xet for t5_530" fast_install_sidecar --target "$VENV_T5_530_DIR" --no-deps "hf_xet==1.4.2" + run_quiet "install tiktoken for t5_530" fast_install_sidecar --target "$VENV_T5_530_DIR" "tiktoken" step "transformers" "5.3.0 pre-installed" _assert_studio_owned_or_absent "$VENV_T5_550_DIR" "transformers 5.5 sidecar venv" [ -d "$VENV_T5_550_DIR" ] && rm -rf "$VENV_T5_550_DIR" mkdir -p "$VENV_T5_550_DIR" : > "$VENV_T5_550_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true - run_quiet "install transformers 5.5.0" fast_install --target "$VENV_T5_550_DIR" --no-deps "transformers==5.5.0" - run_quiet "install huggingface_hub for t5_550" fast_install --target "$VENV_T5_550_DIR" --no-deps "huggingface_hub==1.8.0" - run_quiet "install hf_xet for t5_550" fast_install --target "$VENV_T5_550_DIR" --no-deps "hf_xet==1.4.2" - run_quiet "install tiktoken for t5_550" fast_install --target "$VENV_T5_550_DIR" "tiktoken" + run_quiet "install transformers 5.5.0" fast_install_sidecar --target "$VENV_T5_550_DIR" --no-deps "transformers==5.5.0" + run_quiet "install huggingface_hub for t5_550" fast_install_sidecar --target "$VENV_T5_550_DIR" --no-deps "huggingface_hub==1.8.0" + run_quiet "install hf_xet for t5_550" fast_install_sidecar --target "$VENV_T5_550_DIR" --no-deps "hf_xet==1.4.2" + run_quiet "install tiktoken for t5_550" fast_install_sidecar --target "$VENV_T5_550_DIR" "tiktoken" step "transformers" "5.5.0 pre-installed" _assert_studio_owned_or_absent "$VENV_T5_510_DIR" "transformers 5.10 sidecar venv" [ -d "$VENV_T5_510_DIR" ] && rm -rf "$VENV_T5_510_DIR" mkdir -p "$VENV_T5_510_DIR" : > "$VENV_T5_510_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true - run_quiet "install transformers 5.10.2" fast_install --target "$VENV_T5_510_DIR" --no-deps "transformers==5.10.2" - run_quiet "install huggingface_hub for t5_510" fast_install --target "$VENV_T5_510_DIR" --no-deps "huggingface_hub==1.8.0" - run_quiet "install hf_xet for t5_510" fast_install --target "$VENV_T5_510_DIR" --no-deps "hf_xet==1.4.2" - run_quiet "install tiktoken for t5_510" fast_install --target "$VENV_T5_510_DIR" "tiktoken" + run_quiet "install transformers 5.10.2" fast_install_sidecar --target "$VENV_T5_510_DIR" --no-deps "transformers==5.10.2" + run_quiet "install huggingface_hub for t5_510" fast_install_sidecar --target "$VENV_T5_510_DIR" --no-deps "huggingface_hub==1.8.0" + run_quiet "install hf_xet for t5_510" fast_install_sidecar --target "$VENV_T5_510_DIR" --no-deps "hf_xet==1.4.2" + run_quiet "install tiktoken for t5_510" fast_install_sidecar --target "$VENV_T5_510_DIR" "tiktoken" step "transformers" "5.10.2 pre-installed" fi fi diff --git a/tests/python/test_tokenizers_and_torch_constraint.py b/tests/python/test_tokenizers_and_torch_constraint.py index ec30b28465..57733774e5 100644 --- a/tests/python/test_tokenizers_and_torch_constraint.py +++ b/tests/python/test_tokenizers_and_torch_constraint.py @@ -14,6 +14,7 @@ _TESTS_DIR = pathlib.Path(__file__).resolve().parent.parent # tests/ _REPO_ROOT = _TESTS_DIR.parent # unsloth/ _INSTALL_SH = _REPO_ROOT / "install.sh" _INSTALL_PS1 = _REPO_ROOT / "install.ps1" +_SETUP_SH = _REPO_ROOT / "studio" / "setup.sh" _SETUP_PS1 = _REPO_ROOT / "studio" / "setup.ps1" _NO_TORCH_RT = _REPO_ROOT / "studio" / "backend" / "requirements" / "no-torch-runtime.txt" @@ -167,6 +168,21 @@ class TestSetupPs1FastInstallIndex: assert 'Remove-Item "Env:$n"' in self._ps1 +def test_setup_sh_sidecar_installs_isolate_uv_override(): + source = _read(_SETUP_SH) + helper = re.search(r"fast_install_sidecar\(\) \(\n.*?\n\)", source, re.S) + assert helper is not None + script = ( + "UV_OVERRIDE=base\nfast_install() { printf 'child=%s\\n' \"${UV_OVERRIDE-unset}\"; }\n" + f"{helper.group()}\nfast_install_sidecar\nprintf 'parent=%s\\n' \"$UV_OVERRIDE\"\n" + ) + result = subprocess.run(["bash", "-c", script], capture_output = True, text = True) + assert result.returncode == 0 and result.stdout.splitlines() == ["child=unset", "parent=base"] + sidecars = source.split("# ── 6b.", 1)[1].split("# ── GPU detection", 1)[0] + assert sidecars.count("fast_install_sidecar --target") == 12 + assert " fast_install --target" not in sidecars + + class TestInstallShUvDefaultIndex: """Linux/Mac installer torch indexes must override inherited uv defaults."""